feat: UserState
continuous-integration/drone/push Build is failing Details

This commit is contained in:
kieran 2024-04-22 14:38:14 +01:00
parent 5a7657a95d
commit 80a4b5d8e6
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
103 changed files with 4179 additions and 1165 deletions

View File

@ -1,41 +0,0 @@
module.exports = {
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "formatjs", "react-refresh", "simple-import-sort"],
rules: {
"formatjs/enforce-id": [
"error",
{
idInterpolationPattern: "[sha512:contenthash:base64:6]",
},
],
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"react-refresh/only-export-components": "error",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"@typescript-eslint/no-unused-vars": "error",
"max-lines": ["warn", { max: 300, skipBlankLines: true, skipComments: true }],
},
overrides: [
{
files: ["*.tsx"],
rules: {
"max-lines": ["warn", { max: 200, skipBlankLines: true, skipComments: true }],
},
},
],
root: true,
ignorePatterns: ["build/", "*.test.ts", "*.js"],
env: {
browser: true,
worker: true,
commonjs: true,
node: false,
},
};

View File

@ -0,0 +1,44 @@
/* eslint-disable import/no-anonymous-default-export */
export default [
{
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "formatjs", "react-refresh", "simple-import-sort"],
rules: {
"formatjs/enforce-id": [
"error",
{
idInterpolationPattern: "[sha512:contenthash:base64:6]",
},
],
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"react-refresh/only-export-components": "error",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"@typescript-eslint/no-unused-vars": "error",
"max-lines": ["warn", { max: 300, skipBlankLines: true, skipComments: true }],
},
overrides: [
{
files: ["*.tsx"],
rules: {
"max-lines": ["warn", { max: 200, skipBlankLines: true, skipComments: true }],
},
},
],
root: true,
ignores: ["build/", "*.test.ts", "*.js"],
env: {
browser: true,
worker: true,
commonjs: true,
node: false,
},
},
];

View File

@ -106,6 +106,7 @@
"autoprefixer": "^10.4.16",
"config": "^3.3.9",
"eslint": "^8.48.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-formatjs": "^4.11.3",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
@ -120,6 +121,7 @@
"tinybench": "^2.5.1",
"typescript": "^5.2.2",
"vite": "^5.2.8",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-pwa": "^0.19.2",
"vite-plugin-version-mark": "^0.0.10",
"vitest": "^0.34.6"

View File

@ -1,10 +1,10 @@
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { MixCloudRegex } from "@/Utils/Const";
const MixCloudEmbed = ({ link }: { link: string }) => {
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
const theme = useLogin(s => s.appData.json.preferences.theme);
const theme = usePreferences(s => s.theme);
const lightParams = theme === "light" ? "light=1" : "light=0";
return (
<>

View File

@ -8,30 +8,31 @@ import AsyncButton from "@/Components/Button/AsyncButton";
import { Toastore } from "@/Components/Toaster/Toaster";
import FollowListBase from "@/Components/User/FollowListBase";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { dedupe, findTag, getDisplayName, hexToBech32 } from "@/Utils";
import { useWallet } from "@/Wallet";
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
const wallet = useWallet();
const login = useLogin();
const { publisher } = useEventPublisher();
const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
const { publisher, system } = useEventPublisher();
const ids = dedupe(ev.tags.filter(a => a[0] === "p").map(a => a[1]));
async function zapAll() {
for (const pk of ids) {
try {
const profile = await UserCache.get(pk);
const amtSend = login.appData.json.preferences.defaultZapAmount;
const amtSend = defaultZapAmount;
const lnurl = profile?.lud16 || profile?.lud06;
if (lnurl) {
const svc = new LNURL(lnurl);
await svc.load();
const relays = await system.requestRouter?.forReplyTo(pk);
const zap = await publisher?.zap(
amtSend * 1000,
pk,
Object.keys(login.relays.item),
relays ?? [],
undefined,
`Zap from ${hexToBech32("note", ev.id)}`,
);
@ -74,7 +75,7 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
defaultMessage="Zap all {n} sats"
id="IVbtTS"
values={{
n: <FormattedNumber value={login.appData.json.preferences.defaultZapAmount * ids.length} />,
n: <FormattedNumber value={defaultZapAmount * ids.length} />,
}}
/>
</AsyncButton>

View File

@ -20,6 +20,8 @@ import { Toastore } from "@/Components/Toaster/Toaster";
import ProfileImage from "@/Components/User/ProfileImage";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import useRelays from "@/Hooks/useRelays";
import { useNoteCreator } from "@/State/NoteCreator";
import { openFile, trackEvent } from "@/Utils";
import useFileUpload from "@/Utils/Upload";
@ -56,11 +58,12 @@ const quoteNoteOptions = {
export function NoteCreator() {
const { formatMessage } = useIntl();
const uploader = useFileUpload();
const login = useLogin(s => ({ relays: s.relays, publicKey: s.publicKey, pow: s.appData.json.preferences.pow }));
const publicKey = useLogin(s => s.publicKey);
const pow = usePreferences(s => s.pow);
const relays = useRelays();
const { system, publisher: pub } = useEventPublisher();
const publisher = login.pow ? pub?.pow(login.pow, GetPowWorker()) : pub;
const publisher = pow ? pub?.pow(pow, GetPowWorker()) : pub;
const note = useNoteCreator();
const relays = login.relays;
useEffect(() => {
const draft = localStorage.getItem("msgDraft");
@ -367,8 +370,9 @@ export function NoteCreator() {
function renderRelayCustomisation() {
return (
<div className="flex flex-col g8">
{Object.keys(relays.item || {})
.filter(el => relays.item[el].write)
{Object.entries(relays)
.filter(el => el[1].write)
.map(a => a[0])
.map((r, i, a) => (
<div className="p flex justify-between note-creator-relay" key={r}>
<div>{r}</div>
@ -523,7 +527,7 @@ export function NoteCreator() {
<div className="flex justify-between">
<div className="flex items-center g8">
<ProfileImage
pubkey={login.publicKey ?? ""}
pubkey={publicKey ?? ""}
className="note-creator-icon"
link=""
showUsername={false}

View File

@ -8,21 +8,16 @@ import IconButton from "@/Components/Button/IconButton";
import Icon from "@/Components/Icons/Icon";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { saveRelays } from "@/Pages/settings/saveRelays";
import { getRelayName } from "@/Utils";
import { removeRelay } from "@/Utils/Login";
export function OkResponseRow({ rsp, close }: { rsp: OkResponse; close: () => void }) {
const [r, setResult] = useState(rsp);
const { formatMessage } = useIntl();
const { publisher, system } = useEventPublisher();
const { system } = useEventPublisher();
const login = useLogin();
async function removeRelayFromResult(r: OkResponse) {
if (publisher) {
removeRelay(login, unwrap(sanitizeRelayUrl(r.relay)));
await saveRelays(system, publisher, login.relays.item);
}
await login.state.removeRelay(unwrap(sanitizeRelayUrl(r.relay)), true);
close();
}

View File

@ -1,10 +1,10 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
const hideMutedNotes = useLogin(s => s.appData.json.preferences.hideMutedNotes);
const hideMutedNotes = usePreferences(s => s.hideMutedNotes);
const [show, setShow] = useState(false);
if (hideMutedNotes) return;

View File

@ -1,4 +1,4 @@
import { HexKey, NostrLink, NostrPrefix } from "@snort/system";
import { EventKind, HexKey, NostrLink, NostrPrefix } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -10,7 +10,7 @@ import SnortApi from "@/External/SnortApi";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import { setBookmarked, setPinned } from "@/Utils/Login";
import usePreferences from "@/Hooks/usePreferences";
import { getCurrentSubscription, SubscriptionType } from "@/Utils/Subscription";
import { ReBroadcaster } from "../../ReBroadcaster";
@ -18,7 +18,8 @@ import { ReBroadcaster } from "../../ReBroadcaster";
export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
const { formatMessage } = useIntl();
const login = useLogin();
const { mute, block } = useModeration();
const autoTranslate = usePreferences(s => s.autoTranslate);
const { mute } = useModeration();
const { publisher, system } = useEventPublisher();
const [showBroadcast, setShowBroadcast] = useState(false);
const lang = window.navigator.language;
@ -26,6 +27,7 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
type: "language",
});
const isMine = ev.pubkey === login.publicKey;
const link = NostrLink.fromEvent(ev);
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
@ -78,7 +80,7 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
useEffect(() => {
const sub = getCurrentSubscription(login.subscriptions);
if (sub?.type === SubscriptionType.Premium && (login.appData.json.preferences.autoTranslate ?? true)) {
if (sub?.type === SubscriptionType.Premium && (autoTranslate ?? true)) {
translate();
}
}, []);
@ -90,19 +92,13 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
async function pin(id: HexKey) {
if (publisher) {
const es = [...login.pinned.item, id];
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
//todo: PIN note
}
}
async function bookmark(id: string) {
if (publisher) {
const es = [...login.bookmarked.item, id];
const ev = await publisher.bookmarks(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
//todo: bookmark note
}
}
@ -132,13 +128,13 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
<Icon name="share" />
<FormattedMessage {...messages.Share} />
</MenuItem>
{!login.pinned.item.includes(ev.id) && !login.readonly && (
{!login.state.isOnList(EventKind.PinList, link) && !login.readonly && (
<MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!login.bookmarked.item.includes(ev.id) && !login.readonly && (
{!login.state.isOnList(EventKind.BookmarksList, link) && !login.readonly && (
<MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />
@ -158,12 +154,6 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
<Icon name="relay" />
<FormattedMessage defaultMessage="Broadcast Event" id="Gxcr08" />
</MenuItem>
{ev.pubkey !== login.publicKey && !login.readonly && (
<MenuItem onClick={() => block(ev.pubkey)}>
<Icon name="block" />
<FormattedMessage {...messages.Block} />
</MenuItem>
)}
<MenuItem onClick={() => translate()}>
<Icon name="translate" />
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />

View File

@ -11,6 +11,7 @@ import { ZapsSummary } from "@/Components/Event/ZapsSummary";
import ZapModal from "@/Components/ZapModal/ZapModal";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { getDisplayName } from "@/Utils";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
import { ZapPoolController } from "@/Utils/ZapPoolController";
@ -23,15 +24,11 @@ export interface ZapIconProps {
}
export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
const {
publicKey,
readonly,
preferences: prefs,
} = useLogin(s => ({
const { publicKey, readonly } = useLogin(s => ({
publicKey: s.publicKey,
readonly: s.readonly,
preferences: s.appData.json.preferences,
}));
const preferences = usePreferences(s => ({ autoZap: s.autoZap, defaultZapAmount: s.defaultZapAmount }));
const walletState = useWallet();
const wallet = walletState.wallet;
const link = NostrLink.fromEvent(ev);
@ -75,7 +72,7 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
if (canFastZap && lnurl) {
setZapping(true);
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
await fastZapInner(lnurl, preferences.defaultZapAmount);
} catch (e) {
console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") {
@ -110,13 +107,13 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
const targets = getZapTarget();
useEffect(() => {
if (prefs.autoZap && !didZap && !isMine && !zapping) {
if (preferences.autoZap && !didZap && !isMine && !zapping) {
const lnurl = getZapTarget();
if (wallet?.isReady() && lnurl) {
setZapping(true);
queueMicrotask(async () => {
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
await fastZapInner(lnurl, preferences.defaultZapAmount);
} catch {
// ignored
} finally {
@ -125,7 +122,7 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
});
}
}
}, [prefs.autoZap, author, zapping]);
}, [preferences.autoZap, author, zapping]);
return (
<>

View File

@ -9,6 +9,7 @@ import { ReplyButton } from "@/Components/Event/Note/NoteFooter/ReplyButton";
import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton";
import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
export interface NoteFooterProps {
replyCount?: number;
@ -25,17 +26,14 @@ export default function NoteFooter(props: NoteFooterProps) {
const { replies, reactions, zaps, reposts } = useEventReactions(link, related);
const { positive } = reactions;
const { preferences: prefs, readonly } = useLogin(s => ({
preferences: s.appData.json.preferences,
publicKey: s.publicKey,
readonly: s.readonly,
}));
const readonly = useLogin(s => s.readonly);
const enableReactions = usePreferences(s => s.enableReactions);
return (
<div className="flex flex-row gap-4 overflow-hidden max-w-full h-6 items-center">
<ReplyButton ev={ev} replyCount={props.replyCount ?? replies.length} readonly={readonly} />
<RepostButton ev={ev} reposts={reposts} />
{prefs.enableReactions && <LikeButton ev={ev} positiveReactions={positive} />}
{enableReactions && <LikeButton ev={ev} positiveReactions={positive} />}
{CONFIG.showPowIcon && <PowIcon ev={ev} />}
<FooterZapButton ev={ev} zaps={zaps} onClickZappers={() => setShowReactions(true)} />
{showReactions && <ReactionsModal initialTab={1} onClose={() => setShowReactions(false)} event={ev} />}

View File

@ -9,16 +9,15 @@ import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { useNoteCreator } from "@/State/NoteCreator";
export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: TaggedNostrEvent[] }) => {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const { publisher, system } = useEventPublisher();
const { publicKey, preferences: prefs } = useLogin(s => ({
preferences: s.appData.json.preferences,
publicKey: s.publicKey,
}));
const publicKey = useLogin(s => s.publicKey);
const confirmReposts = usePreferences(s => s.confirmReposts);
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
const hasReposted = () => {
@ -27,7 +26,7 @@ export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: T
const repost = async () => {
if (!hasReposted() && publisher) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
if (!confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev);
system.BroadcastEvent(evRepost);
}

View File

@ -1,4 +1,4 @@
import { HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -13,7 +13,6 @@ import messages from "@/Components/messages";
import ProfileImage from "@/Components/User/ProfileImage";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { setBookmarked, setPinned } from "@/Utils/Login";
export default function NoteHeader(props: {
ev: TaggedNostrEvent;
@ -24,28 +23,21 @@ export default function NoteHeader(props: {
const [showReactions, setShowReactions] = useState(false);
const { ev, options, setTranslated } = props;
const { formatMessage } = useIntl();
const { pinned, bookmarked } = useLogin();
const { publisher, system } = useEventPublisher();
const { publisher } = useEventPublisher();
const login = useLogin();
async function unpin(id: HexKey) {
async function unpin() {
if (options.canUnpin && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
const es = pinned.item.filter(e => e !== id);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
await login.state.removeFromList(EventKind.PinList, NostrLink.fromEvent(ev));
}
}
}
async function unbookmark(id: HexKey) {
async function unbookmark() {
if (options.canUnbookmark && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
const es = bookmarked.item.filter(e => e !== id);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
await login.state.removeFromList(EventKind.BookmarksList, NostrLink.fromEvent(ev));
}
}
}
@ -66,7 +58,7 @@ export default function NoteHeader(props: {
{(options.showTime || options.showBookmarked) && (
<>
{options.showBookmarked && (
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark(ev.id)}>
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark()}>
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
</div>
)}
@ -74,7 +66,7 @@ export default function NoteHeader(props: {
</>
)}
{options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.id)}>
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin()}>
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
</div>
)}

View File

@ -6,14 +6,14 @@ import { NoteProps } from "@/Components/Event/EventComponent";
import { NoteTranslation } from "@/Components/Event/Note/types";
import Reveal from "@/Components/Event/Reveal";
import Text from "@/Components/Text/Text";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
const TEXT_TRUNCATE_LENGTH = 400;
export const NoteText = memo(function InnerContent(
props: NoteProps & { translated: NoteTranslation; showTranslation?: boolean },
) {
const { data: ev, options, translated, showTranslation } = props;
const appData = useLogin(s => s.appData);
const showContentWarningPosts = usePreferences(s => s.showContentWarningPosts);
const [showMore, setShowMore] = useState(false);
const body = translated && !translated.skipped && showTranslation ? translated.text : ev?.content ?? "";
const id = translated && !translated.skipped && showTranslation ? `${ev.id}-translated` : ev.id;
@ -53,7 +53,7 @@ export const NoteText = memo(function InnerContent(
</>
);
if (!appData.json.showContentWarningPosts) {
if (!showContentWarningPosts) {
const contentWarning = ev.tags.find(a => a[0] === "content-warning");
if (contentWarning) {
return (

View File

@ -8,6 +8,7 @@ import Spinner from "@/Components/Icons/Spinner";
import ZapModal from "@/Components/ZapModal/ZapModal";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { unwrap } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import { useWallet } from "@/Wallet";
@ -21,13 +22,10 @@ type PollTally = "zaps" | "pubkeys";
export default function Poll(props: PollProps) {
const { formatMessage } = useIntl();
const { publisher } = useEventPublisher();
const { publisher, system } = useEventPublisher();
const { wallet } = useWallet();
const {
preferences: prefs,
publicKey: myPubKey,
relays,
} = useLogin(s => ({ preferences: s.appData.json.preferences, publicKey: s.publicKey, relays: s.relays }));
const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
const myPubKey = useLogin(s => s.publicKey);
const pollerProfile = useUserProfile(props.ev.pubkey);
const [tallyBy, setTallyBy] = useState<PollTally>("pubkeys");
const [error, setError] = useState("");
@ -45,7 +43,7 @@ export default function Poll(props: PollProps) {
ev.stopPropagation();
if (voting || !publisher) return;
const amount = prefs.defaultZapAmount;
const amount = defaultZapAmount;
try {
if (amount <= 0) {
throw new Error(
@ -62,9 +60,14 @@ export default function Poll(props: PollProps) {
}
setVoting(opt);
const r = Object.keys(relays.item);
const zap = await publisher.zap(amount * 1000, props.ev.pubkey, r, NostrLink.fromEvent(props.ev), undefined, eb =>
eb.tag(["poll_option", opt.toString()]),
const r = await system.requestRouter?.forReplyTo(props.ev.pubkey);
const zap = await publisher.zap(
amount * 1000,
props.ev.pubkey,
r ?? [],
NostrLink.fromEvent(props.ev),
undefined,
eb => eb.tag(["poll_option", opt.toString()]),
);
const lnurl = props.ev.tags.find(a => a[0] === "zap")?.[1] || pollerProfile?.lud16 || pollerProfile?.lud06;
@ -121,7 +124,7 @@ export default function Poll(props: PollProps) {
defaultMessage="You are voting with {amount} sats"
id="3qnJlS"
values={{
amount: formatShort(prefs.defaultZapAmount),
amount: formatShort(defaultZapAmount),
}}
/>
</small>

View File

@ -6,6 +6,7 @@ import { MediaElement } from "@/Components/Embed/MediaElement";
import Reveal from "@/Components/Event/Reveal";
import useFollowsControls from "@/Hooks/useFollowControls";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { FileExtensionRegex } from "@/Utils/Const";
interface RevealMediaProps {
@ -17,15 +18,13 @@ interface RevealMediaProps {
}
export default function RevealMedia(props: RevealMediaProps) {
const { preferences, publicKey } = useLogin(s => ({
preferences: s.appData.json.preferences,
publicKey: s.publicKey,
}));
const publicKey = useLogin(s => s.publicKey);
const autoLoadMedia = usePreferences(s => s.autoLoadMedia);
const { isFollowing } = useFollowsControls();
const hideNonFollows = preferences.autoLoadMedia === "follows-only" && !isFollowing(props.creator);
const hideNonFollows = autoLoadMedia === "follows-only" && !isFollowing(props.creator);
const isMine = props.creator === publicKey;
const hideMedia = preferences.autoLoadMedia === "none" || (!isMine && hideNonFollows);
const hideMedia = autoLoadMedia === "none" || (!isMine && hideNonFollows);
const hostname = new URL(props.link).hostname;
const url = new URL(props.link);

View File

@ -123,8 +123,6 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
<pre>{JSON.stringify(thread.root, undefined, " ")}</pre>
<h1>Data</h1>
<pre>{JSON.stringify(thread.data, undefined, " ")}</pre>
<h1>Reactions</h1>
<pre>{JSON.stringify(thread.reactions, undefined, " ")}</pre>
</div>
)}
{parent && (

View File

@ -3,9 +3,8 @@ import { FormattedMessage } from "react-intl";
import Icon from "@/Components/Icons/Icon";
import { RootTabRoutePath } from "@/Pages/Root/RootTabRoutes";
import { Newest } from "@/Utils/Login";
export function rootTabItems(base: string, pubKey: string | undefined, tags: Newest<Array<string>>) {
export function rootTabItems(base: string, pubKey: string | undefined, tags: Array<string>) {
const menuItems = [
{
tab: "for-you",
@ -98,7 +97,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: New
{
tab: "tags",
path: `${base}/topics`,
show: tags.item.length > 0,
show: tags.length > 0,
element: (
<>
<Icon name="hash" />

View File

@ -7,26 +7,26 @@ import { useLocation, useNavigate } from "react-router-dom";
import { rootTabItems } from "@/Components/Feed/RootTabItems";
import Icon from "@/Components/Icons/Icon";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { RootTabRoutePath } from "@/Pages/Root/RootTabRoutes";
import { EventKind } from "@snort/system";
import { unwrap } from "@snort/shared";
export function RootTabs({ base = "/" }: { base: string }) {
const navigate = useNavigate();
const location = useLocation();
const {
publicKey: pubKey,
tags,
preferences,
} = useLogin(s => ({
const { publicKey: pubKey, tags } = useLogin(s => ({
publicKey: s.publicKey,
tags: s.tags,
preferences: s.appData.json.preferences,
tags: s.state.getList(EventKind.InterestSet),
}));
const defaultRootTab = usePreferences(s => s.defaultRootTab);
const menuItems = useMemo(() => rootTabItems(base, pubKey, tags), [base, pubKey, tags]);
const hashTags = tags.filter(a => a.toEventTag()?.[0] === "t").map(a => unwrap(a.toEventTag())[1]);
const menuItems = useMemo(() => rootTabItems(base, pubKey, hashTags), [base, pubKey, tags]);
let defaultTab: RootTabRoutePath;
if (pubKey) {
defaultTab = preferences.defaultRootTab;
defaultTab = defaultRootTab;
} else {
defaultTab = `trending/notes`;
}

View File

@ -26,7 +26,11 @@ export interface TimelineFollowsProps {
* A list of notes by "subject"
*/
const TimelineFollows = (props: TimelineFollowsProps) => {
const login = useLogin();
const login = useLogin(s => ({
publicKey: s.publicKey,
feedDisplayAs: s.feedDisplayAs,
tags: s.state.getList(EventKind.InterestSet),
}));
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
@ -38,12 +42,12 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
items: followList,
discriminator: login.publicKey?.slice(0, 12),
extra: rb => {
if (login.tags.item.length > 0) {
rb.withFilter().kinds([EventKind.TextNote, EventKind.Repost]).tag("t", login.tags.item);
if (login.tags.length > 0) {
rb.withFilter().kinds([EventKind.TextNote, EventKind.Repost]).tags(login.tags);
}
},
}) as TimelineSubject,
[followList, login.tags.item],
[login.publicKey, followList, login.tags],
);
const feed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);

View File

@ -16,12 +16,12 @@ export default function UsersFeed({ keyword, sortPopular = true }: { keyword: st
{ method: "LIMIT_UNTIL" },
);
const { muted, isEventMuted } = useModeration();
const { isEventMuted } = useModeration();
const filterPosts = useCallback(
(nts: readonly TaggedNostrEvent[]) => {
return nts.filter(a => !isEventMuted(a));
},
[muted],
[isEventMuted],
);
const usersFeed = useMemo(() => filterPosts(feed.main ?? []).map(p => p.pubkey), [feed, filterPosts]);

View File

@ -2,10 +2,10 @@ import { useSyncExternalStore } from "react";
import { getLocale } from "@/Components/IntlProvider/IntlProviderUtils";
import { LangOverride } from "@/Components/IntlProvider/langStore";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
export function useLocale() {
const { language } = useLogin(s => ({ language: s.appData.json.preferences.language }));
const language = usePreferences(s => s.language);
const loggedOutLang = useSyncExternalStore(
c => LangOverride.hook(c),
() => LangOverride.snapshot(),

View File

@ -7,6 +7,7 @@ import { FormattedMessage, useIntl } from "react-intl";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { createPublisher, LoginStore, sessionNeedsPin } from "@/Utils/Login";
import { GetPowWorker } from "@/Utils/wasm";
@ -97,6 +98,7 @@ export function PinPrompt({
export function LoginUnlock() {
const login = useLogin();
const pow = usePreferences(s => s.pow);
const { publisher } = useEventPublisher();
async function encryptMigration(pin: string) {
@ -104,8 +106,8 @@ export function LoginUnlock() {
const newPin = await PinEncrypted.create(k, pin);
const pub = EventPublisher.privateKey(k);
if (login.appData.json.preferences.pow) {
pub.pow(login.appData.json.preferences.pow, GetPowWorker());
if (pow) {
pub.pow(pow, GetPowWorker());
}
LoginStore.setPublisher(login.id, pub);
LoginStore.updateSession({
@ -121,8 +123,8 @@ export function LoginUnlock() {
await key.unlock(pin);
const pub = createPublisher(login);
if (pub) {
if (login.appData.json.preferences.pow) {
pub.pow(login.appData.json.preferences.pow, GetPowWorker());
if (pow) {
pub.pow(pow, GetPowWorker());
}
LoginStore.setPublisher(login.id, pub);
LoginStore.updateSession({

View File

@ -4,7 +4,7 @@ import { useContext, useState } from "react";
import { FormattedMessage } from "react-intl";
import Modal from "@/Components/Modal/Modal";
import useLogin from "@/Hooks/useLogin";
import useRelays from "@/Hooks/useRelays";
import AsyncButton from "./Button/AsyncButton";
import messages from "./messages";
@ -12,7 +12,7 @@ import messages from "./messages";
export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: TaggedNostrEvent }) {
const [selected, setSelected] = useState<Array<string>>();
const system = useContext(SnortContext);
const { relays } = useLogin(s => ({ relays: s.relays }));
const relays = useRelays();
async function sendReBroadcast() {
if (selected) {
@ -25,8 +25,8 @@ export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: Tagged
function renderRelayCustomisation() {
return (
<div className="flex flex-col g8">
{Object.keys(relays.item || {})
.filter(el => relays.item[el].write)
{Object.keys(relays)
.filter(el => relays[el].write)
.map((r, i, a) => (
<div key={r} className="card flex justify-between">
<div>{r}</div>
@ -36,7 +36,7 @@ export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: Tagged
checked={!selected || selected.includes(r)}
onChange={e =>
setSelected(
e.target.checked && selected && selected.length == a.length - 1
e.target.checked && selected && selected.length === a.length - 1
? undefined
: a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))),
)

View File

@ -1,17 +1,14 @@
import "./Relay.css";
import { unixNowMs } from "@snort/shared";
import { RelaySettings } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import classNames from "classnames";
import { useContext, useMemo } from "react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { AsyncIcon } from "@/Components/Button/AsyncIcon";
import useRelayState from "@/Feed/RelayState";
import useLogin from "@/Hooks/useLogin";
import { getRelayName, unwrap } from "@/Utils";
import { removeRelay, setRelays } from "@/Utils/Login";
import { getRelayName } from "@/Utils";
import { RelayFavicon } from "./RelaysMetadata";
@ -21,35 +18,29 @@ export interface RelayProps {
export default function Relay(props: RelayProps) {
const navigate = useNavigate();
const system = useContext(SnortContext);
const login = useLogin();
const state = useLogin(s => s.state);
const relaySettings = unwrap(login.relays.item[props.addr] ?? system.pool.getConnection(props.addr)?.Settings ?? {});
const state = useRelayState(props.addr);
const name = useMemo(() => getRelayName(props.addr), [props.addr]);
const connection = useRelayState(props.addr);
function configure(o: RelaySettings) {
setRelays(
login,
{
...login.relays.item,
[props.addr]: o,
},
unixNowMs(),
);
const relaySettings = state.relays?.find(a => a.url === props.addr)?.settings;
if (!relaySettings || !connection) return;
async function configure(o: RelaySettings) {
await state.updateRelay(props.addr, o);
}
return (
<>
<div className="relay bg-dark">
<div className={classNames("flex items-center", state?.IsClosed === false ? "bg-success" : "bg-error")}>
<div className={classNames("flex items-center", connection.isOpen ? "bg-success" : "bg-error")}>
<RelayFavicon url={props.addr} />
</div>
<div className="flex flex-col g8">
<div>
<b>{name}</b>
</div>
{!state?.Ephemeral && (
{!connection?.Ephemeral && (
<div className="flex g8">
<AsyncIcon
iconName="write"
@ -77,13 +68,13 @@ export default function Relay(props: RelayProps) {
iconName="trash"
iconSize={16}
className="button-icon-sm transparent trash-icon"
onClick={() => removeRelay(login, props.addr)}
onClick={() => state.removeRelay(props.addr)}
/>
<AsyncIcon
iconName="gear"
iconSize={16}
className="button-icon-sm transparent"
onClick={() => navigate(state?.Id ?? "")}
onClick={() => navigate(connection?.Id ?? "")}
/>
</div>
)}

View File

@ -17,15 +17,15 @@ enum Provider {
}
export default function SuggestedProfiles() {
const login = useLogin(s => ({ publicKey: s.publicKey, follows: s.contacts }));
const publicKey = useLogin(s => s.publicKey);
const [provider, setProvider] = useState(Provider.NostrBand);
const getUrlAndKey = () => {
if (!login.publicKey) return { url: null, key: null };
if (!publicKey) return { url: null, key: null };
switch (provider) {
case Provider.NostrBand: {
const api = new NostrBandApi();
const url = api.suggestedFollowsUrl(hexToBech32(NostrPrefix.PublicKey, login.publicKey));
const url = api.suggestedFollowsUrl(hexToBech32(NostrPrefix.PublicKey, publicKey));
return { url, key: `nostr-band-${url}` };
}
default:

View File

@ -33,7 +33,7 @@ export default function TrendingNotes({ count = Infinity, small = false }: { cou
const ev = a.event;
if (!System.optimizer.schnorrVerify(ev)) {
console.error(`Event with invalid sig\n\n${ev}\n\nfrom ${trendingNotesUrl}`);
return;
return undefined;
}
System.HandleEvent("*", ev as TaggedNostrEvent);
return ev;

View File

@ -1,25 +0,0 @@
import { HexKey } from "@snort/system";
import { FormattedMessage } from "react-intl";
import useModeration from "@/Hooks/useModeration";
import messages from "../messages";
interface BlockButtonProps {
pubkey: HexKey;
}
const BlockButton = ({ pubkey }: BlockButtonProps) => {
const { block, unblock, isBlocked } = useModeration();
return isBlocked(pubkey) ? (
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
<FormattedMessage {...messages.Unblock} />
</button>
) : (
<button className="secondary" type="button" onClick={() => block(pubkey)}>
<FormattedMessage {...messages.Block} />
</button>
);
};
export default BlockButton;

View File

@ -1,15 +0,0 @@
import BlockButton from "@/Components/User/BlockButton";
import ProfilePreview from "@/Components/User/ProfilePreview";
import useModeration from "@/Hooks/useModeration";
export default function BlockList() {
const { blocked } = useModeration();
return (
<div className="main-content p">
{blocked.map(a => {
return <ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
})}
</div>
);
}

View File

@ -1,4 +1,4 @@
import { HexKey } from "@snort/system";
import { HexKey, NostrPrefix } from "@snort/system";
import { FormattedMessage } from "react-intl";
import MuteButton from "@/Components/User/MuteButton";
@ -11,26 +11,31 @@ export interface MutedListProps {
pubkeys: HexKey[];
}
export default function MutedList({ pubkeys }: MutedListProps) {
const { isMuted, muteAll } = useModeration();
const hasAllMuted = pubkeys.every(isMuted);
export default function MutedList() {
const { muteList } = useModeration();
return (
<div className="p">
<div className="flex justify-between">
<div className="bold">
<FormattedMessage {...messages.MuteCount} values={{ n: pubkeys?.length }} />
<FormattedMessage {...messages.MuteCount} values={{ n: muteList?.length }} />
</div>
<button
disabled={hasAllMuted || pubkeys.length === 0}
className="transparent"
type="button"
onClick={() => muteAll(pubkeys)}>
<FormattedMessage {...messages.MuteAll} />
</button>
</div>
{pubkeys?.map(a => {
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
{muteList?.map(a => {
switch (a.type) {
case NostrPrefix.Profile:
case NostrPrefix.PublicKey: {
return (
<ProfilePreview
actions={<MuteButton pubkey={a.id} />}
pubkey={a.id}
options={{ about: false }}
key={a.id}
/>
);
}
}
return undefined;
})}
</div>
);

View File

@ -7,6 +7,7 @@ import messages from "@/Components/messages";
import { ZapType } from "@/Components/ZapModal/ZapType";
import { ZapTypeSelector } from "@/Components/ZapModal/ZapTypeSelector";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { formatShort } from "@/Utils/Number";
import { Zapper } from "@/Utils/Zapper";
@ -21,10 +22,8 @@ export function ZapModalInput(props: {
onChange?: (v: SendSatsInputSelection) => void;
onNextStage: (v: SendSatsInputSelection) => Promise<void>;
}) {
const { defaultZapAmount, readonly } = useLogin(s => ({
defaultZapAmount: s.appData.json.preferences.defaultZapAmount,
readonly: s.readonly,
}));
const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
const readonly = useLogin(s => s.readonly);
const { formatMessage } = useIntl();
const amounts: Record<string, string> = {
[defaultZapAmount.toString()]: "",

View File

@ -5,15 +5,15 @@ import { useMemo } from "react";
import useLogin from "@/Hooks/useLogin";
export function useArticles() {
const { publicKey, follows } = useLogin();
const { publicKey, follows } = useLogin(s => ({ publicKey: s.publicKey, follows: s.state.follows }));
const sub = useMemo(() => {
if (!publicKey) return null;
const rb = new RequestBuilder(`articles:${publicKey}`);
rb.withFilter().kinds([EventKind.LongFormTextNote]).authors(follows.item).limit(20);
rb.withFilter().kinds([EventKind.LongFormTextNote]).authors(follows).limit(20);
return rb;
}, [follows.timestamp]);
}, [follows, publicKey]);
return useRequestBuilder(sub);
}

View File

@ -5,7 +5,7 @@ import { useMemo } from "react";
import useLogin from "@/Hooks/useLogin";
export default function useFollowsFeed(pubkey?: HexKey) {
const { publicKey, follows } = useLogin();
const { publicKey, follows } = useLogin(s => ({ publicKey: s.publicKey, follows: s.state.follows }));
const isMe = publicKey === pubkey;
const sub = useMemo(() => {
@ -18,11 +18,11 @@ export default function useFollowsFeed(pubkey?: HexKey) {
const contactFeed = useRequestBuilder(sub);
return useMemo(() => {
if (isMe) {
return follows.item;
return follows;
}
return getFollowing(contactFeed ?? [], pubkey);
}, [contactFeed, follows, pubkey]);
}, [isMe, contactFeed, follows, pubkey]);
}
export function getFollowing(notes: readonly TaggedNostrEvent[], pubkey?: HexKey) {

View File

@ -1,42 +1,33 @@
import { EventKind, NostrLink, parseRelayTags, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useEffect, useMemo } from "react";
import { Nip28ChatSystem } from "@/chat/nip28";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "@/Utils";
import usePreferences from "@/Hooks/usePreferences";
import { bech32ToHex, unwrap } from "@/Utils";
import { SnortPubKey } from "@/Utils/Const";
import {
addSubscription,
LoginStore,
setBlocked,
setBookmarked,
setMuted,
setPinned,
setRelays,
setTags,
updateSession,
} from "@/Utils/Login";
import { addSubscription } from "@/Utils/Login";
import { SubscriptionEvent } from "@/Utils/Subscription";
/**
* Managed loading data for the current logged in user
*/
export default function useLoginFeed() {
const login = useLogin();
const { publicKey: pubKey, contacts } = login;
const { publicKey: pubKey } = login;
const checkSigs = usePreferences(s => s.checkSigs);
const { publisher, system } = useEventPublisher();
useEffect(() => {
if (login.appData.json) {
system.checkSigs = login.appData.json.preferences.checkSigs;
system.checkSigs = checkSigs;
}, [system, checkSigs]);
if (publisher) {
login.appData.sync(publisher.signer, system);
}
useEffect(() => {
if (publisher) {
login.state.init(publisher.signer, system).catch(console.error);
}
}, [login, publisher]);
}, [login, publisher, system]);
const subLogin = useMemo(() => {
if (!login || !pubKey) return null;
@ -44,18 +35,7 @@ export default function useLoginFeed() {
b.withOptions({
leaveOpen: true,
});
b.withFilter()
.authors([pubKey])
.kinds([
EventKind.ContactList,
EventKind.Relays,
EventKind.MuteList,
EventKind.PinList,
EventKind.BookmarksList,
EventKind.InterestsList,
EventKind.PublicChatsList,
EventKind.DirectMessage,
]);
b.withFilter().authors([pubKey]).kinds([EventKind.DirectMessage]);
if (CONFIG.features.subscriptions && !login.readonly) {
b.withFilter()
.relay("wss://relay.snort.social/")
@ -66,121 +46,32 @@ export default function useLoginFeed() {
}
return b;
}, [login]);
}, [pubKey, login]);
const loginFeed = useRequestBuilder(subLogin);
// update relays and follow lists
useEffect(() => {
if (loginFeed) {
const contactList = getNewest(loginFeed.filter(a => a.kind === EventKind.ContactList));
if (contactList) {
updateSession(login.id, s => {
s.contacts = contactList.tags;
});
}
const relays = getNewest(loginFeed.filter(a => a.kind === EventKind.Relays));
if (relays) {
const parsedRelays = parseRelayTags(relays.tags.filter(a => a[0] === "r")).map(a => [a.url, a.settings]);
setRelays(login, Object.fromEntries(parsedRelays), relays.created_at * 1000);
}
if (publisher) {
const subs = loginFeed.filter(
a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey),
);
Promise.all(
subs.map(async a => {
const dx = await publisher.decryptDm(a);
if (dx) {
const ex = JSON.parse(dx);
return {
id: a.id,
...ex,
} as SubscriptionEvent;
}
}),
).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap)));
}
if (loginFeed && publisher) {
const subs = loginFeed.filter(
a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey),
);
Promise.all(
subs.map(async a => {
const dx = await publisher.decryptDm(a);
if (dx) {
const ex = JSON.parse(dx);
return {
id: a.id,
...ex,
} as SubscriptionEvent;
}
}),
).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap)));
}
}, [loginFeed, publisher]);
async function handleMutedFeed(mutedFeed: TaggedNostrEvent[]) {
const latest = getNewest(mutedFeed);
if (!latest) return;
const muted = NostrLink.fromTags(latest.tags);
setMuted(
login,
muted.map(a => a.id),
latest.created_at * 1000,
);
if (latest?.content && publisher && pubKey) {
try {
const privMutes = await publisher.nip4Decrypt(latest.content, pubKey);
const blocked = JSON.parse(privMutes) as Array<Array<string>>;
const keys = blocked.filter(a => a[0] === "p").map(a => a[1]);
setBlocked(login, keys, latest.created_at * 1000);
} catch (error) {
console.debug("Failed to parse mute list", error, latest);
}
}
}
function handlePinnedFeed(pinnedFeed: TaggedNostrEvent[]) {
const newest = getNewestEventTagsByKey(pinnedFeed, "e");
if (newest) {
setPinned(login, newest.keys, newest.createdAt * 1000);
}
}
function handleTagFeed(tagFeed: TaggedNostrEvent[]) {
const newest = getNewestEventTagsByKey(tagFeed, "t");
if (newest) {
setTags(login, newest.keys, newest.createdAt * 1000);
}
}
function handleBookmarkFeed(bookmarkFeed: TaggedNostrEvent[]) {
const newest = getNewestEventTagsByKey(bookmarkFeed, "e");
if (newest) {
setBookmarked(login, newest.keys, newest.createdAt * 1000);
}
}
function handlePublicChatsListFeed(bookmarkFeed: TaggedNostrEvent[]) {
const newest = getNewestEventTagsByKey(bookmarkFeed, "e");
if (newest) {
LoginStore.updateSession({
...login,
extraChats: newest.keys.map(Nip28ChatSystem.chatId),
});
}
}
}, [login, loginFeed, publisher]);
useEffect(() => {
if (loginFeed) {
const mutedFeed = loginFeed.filter(a => a.kind === EventKind.MuteList);
handleMutedFeed(mutedFeed);
const pinnedFeed = loginFeed.filter(a => a.kind === EventKind.PinList);
handlePinnedFeed(pinnedFeed);
const tagsFeed = loginFeed.filter(a => a.kind === EventKind.InterestsList);
handleTagFeed(tagsFeed);
const bookmarkFeed = loginFeed.filter(a => a.kind === EventKind.BookmarksList);
handleBookmarkFeed(bookmarkFeed);
const publicChatsFeed = loginFeed.filter(a => a.kind === EventKind.PublicChatsList);
handlePublicChatsListFeed(publicChatsFeed);
}
}, [loginFeed]);
useEffect(() => {
const pTags = contacts.filter(a => a[0] === "p").map(a => a[1]);
system.profileLoader.TrackKeys(pTags); // always track follows profiles
}, [contacts]);
system.profileLoader.TrackKeys(login.state.follows ?? []); // always track follows profiles
}, [system, login.state.follows]);
}

View File

@ -1,4 +1,4 @@
import { EventExt, EventKind, NostrLink, RequestBuilder } from "@snort/system";
import { EventKind, Nip10, NostrLink, RequestBuilder } from "@snort/system";
import { SnortContext, useRequestBuilder } from "@snort/system-react";
import { useContext, useEffect, useMemo, useState } from "react";
@ -39,7 +39,7 @@ export default function useThreadFeed(link: NostrLink) {
.relay(rootRelays ?? []);
}
return sub;
}, [allEvents.length, rootRelays]);
}, [allEvents, link, root, rootRelays]);
const store = useRequestBuilder(sub);
@ -53,27 +53,21 @@ export default function useThreadFeed(link: NostrLink) {
.flat();
setAllEvents(links);
// load the thread structure from the current note
const current = store.find(a => link.matchesEvent(a));
if (current) {
const t = EventExt.extractThread(current);
const t = Nip10.parseThread(current);
if (t) {
const rootOrReplyAsRoot = t?.root ?? t?.replyTo;
if (rootOrReplyAsRoot) {
setRoot(
NostrLink.fromTag([
rootOrReplyAsRoot.key,
rootOrReplyAsRoot.value ?? "",
rootOrReplyAsRoot.relay ?? "",
...(rootOrReplyAsRoot.marker ?? []),
]),
);
setRoot(rootOrReplyAsRoot);
}
} else {
setRoot(link);
}
}
}
}, [store?.length]);
}, [link, store, store.length]);
useEffect(() => {
if (root) {
@ -87,12 +81,15 @@ export default function useThreadFeed(link: NostrLink) {
relays.relays.filter(a => a.settings.read).map(a => a.url),
3,
);
if (root.relays) {
readRelays.push(...root.relays);
}
setRootRelays(readRelays);
}
});
}
}
}, [link, root, store?.length]);
}, [link, root, store, store.length, system.relayCache]);
return store ?? [];
}

View File

@ -3,8 +3,8 @@ import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilderAdvanced } from "@snort/system-react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import usePreferences from "@/Hooks/usePreferences";
import useTimelineWindow from "@/Hooks/useTimelineWindow";
import { SearchRelays } from "@/Utils/Const";
@ -30,7 +30,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
window: options.window,
now: options.now ?? unixNow(),
});
const pref = useLogin(s => s.appData.json.preferences);
const autoShowLatest = usePreferences(s => s.autoShowLatest);
const { isEventMuted } = useModeration();
const createBuilder = useCallback(() => {
@ -93,7 +93,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
}
return rb;
}
}, [until, since, options.method, pref, createBuilder]);
}, [until, since, options.method, createBuilder]);
const mainQuery = useRequestBuilderAdvanced(sub);
const main = useSyncExternalStore(
@ -108,7 +108,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
const subRealtime = useMemo(() => {
const rb = createBuilder();
if (rb && !pref.autoShowLatest && options.method !== "LIMIT_UNTIL") {
if (rb && !autoShowLatest && options.method !== "LIMIT_UNTIL") {
rb.withOptions({
leaveOpen: true,
});
@ -118,7 +118,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
}
return rb;
}
}, [pref.autoShowLatest, createBuilder]);
}, [autoShowLatest, createBuilder]);
const latestQuery = useRequestBuilderAdvanced(subRealtime);
const latest = useSyncExternalStore(

View File

@ -1,48 +1,32 @@
import { DiffSyncTags, EventKind, NostrLink, NostrPrefix } from "@snort/system";
import { useMemo } from "react";
import { NostrLink } from "@snort/system";
import useEventPublisher from "./useEventPublisher";
import useLogin from "./useLogin";
/**
* Simple hook for adding / removing follows
*/
export default function useFollowsControls() {
const { publisher, system } = useEventPublisher();
const { pubkey, contacts, relays } = useLogin(s => ({
pubkey: s.publicKey,
contacts: s.contacts,
readonly: s.readonly,
relays: s.relays.item,
}));
const state = useLogin(s => s.state);
return useMemo(() => {
const link = new NostrLink(NostrPrefix.Event, "", EventKind.ContactList, pubkey);
const sync = new DiffSyncTags(link);
const content = JSON.stringify(relays);
return {
isFollowing: (pk: string) => {
return contacts.some(a => a[0] === "p" && a[1] === pk);
},
addFollow: async (pk: Array<string>) => {
sync.add(pk.map(a => ["p", a]));
if (publisher) {
await sync.persist(publisher.signer, system, content);
}
},
removeFollow: async (pk: Array<string>) => {
sync.remove(pk.map(a => ["p", a]));
if (publisher) {
await sync.persist(publisher.signer, system, content);
}
},
setFollows: async (pk: Array<string>) => {
sync.replace(pk.map(a => ["p", a]));
if (publisher) {
await sync.persist(publisher.signer, system, content);
}
},
followList: contacts.filter(a => a[0] === "p").map(a => a[1]),
};
}, [contacts, relays, publisher, system]);
return {
isFollowing: (pk: string) => {
return state.follows?.includes(pk);
},
addFollow: async (pk: Array<string>) => {
for (const p of pk) {
await state.follow(NostrLink.publicKey(p), false);
}
await state.saveContacts();
},
removeFollow: async (pk: Array<string>) => {
for (const p of pk) {
await state.unfollow(NostrLink.publicKey(p), false);
}
await state.saveContacts();
},
setFollows: async (pk: Array<string>) => {
await state.replaceFollows(pk.map(a => NostrLink.publicKey(a)));
},
followList: state.follows ?? [],
};
}

View File

@ -1,7 +1,7 @@
import * as utils from "@noble/curves/abstract/utils";
import { base64 } from "@scure/base";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { hmacSha256, unwrap } from "@/Utils";
export interface ImgProxySettings {
@ -11,10 +11,10 @@ export interface ImgProxySettings {
}
export default function useImgProxy() {
const settings = useLogin(s => s.appData.json.preferences.imgProxyConfig);
const imgProxyConfig = usePreferences(s => s.imgProxyConfig);
return {
proxy: (url: string, resize?: number, sha256?: string) => proxyImg(url, settings, resize, sha256),
proxy: (url: string, resize?: number, sha256?: string) => proxyImg(url, imgProxyConfig, resize, sha256),
};
}

View File

@ -36,14 +36,6 @@ export function usePinList(pubkey: string | undefined) {
});
}
export function useMuteList(pubkey: string | undefined) {
return useLinkList(`list:mute:${pubkey?.slice(0, 12)}`, rb => {
if (pubkey) {
rb.withFilter().kinds([EventKind.MuteList]).authors([pubkey]);
}
});
}
export function useBookmarkList(pubkey: string | undefined) {
return useLinkListEvents(`list:bookmark:${pubkey?.slice(0, 12)}`, rb => {
if (pubkey) {

View File

@ -2,10 +2,10 @@ import { RelaySettings, SystemInterface } from "@snort/system";
import { useEffect } from "react";
import useEventPublisher from "./useEventPublisher";
import useLogin from "./useLogin";
import useRelays from "./useRelays";
export function useLoginRelays() {
const relays = useLogin(s => s.relays.item);
const relays = useRelays();
const { system } = useEventPublisher();
useEffect(() => {

View File

@ -1,80 +1,48 @@
import { HexKey, NostrEvent, TaggedNostrEvent } from "@snort/system";
import { EventKind, NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { appendDedupe } from "@/Utils";
import { setBlocked, setMuted } from "@/Utils/Login";
import { dedupe } from "@snort/shared";
export default function useModeration() {
const login = useLogin();
const { muted, blocked, appData } = login;
const { publisher, system } = useEventPublisher();
const state = useLogin(s => s.state);
async function setMutedList(pub: HexKey[], priv: HexKey[]) {
if (publisher) {
const ev = await publisher.muted(pub, priv);
system.BroadcastEvent(ev);
return ev.created_at * 1000;
function isMuted(id: string) {
const link = NostrLink.publicKey(id);
return state.muted.includes(link);
}
async function unmute(id: string) {
const link = NostrLink.publicKey(id);
await state.unmute(link, true);
}
async function mute(id: string) {
const link = NostrLink.publicKey(id);
await state.mute(link, true);
}
async function muteAll(ids: string[]) {
const links = dedupe(ids).map(a => NostrLink.publicKey(a));
for (const link of links) {
await state.mute(link, false);
}
return 0;
}
function isMuted(id: HexKey) {
return muted.item.includes(id) || blocked.item.includes(id);
}
function isBlocked(id: HexKey) {
return blocked.item.includes(id);
}
async function unmute(id: HexKey) {
const newMuted = muted.item.filter(p => p !== id);
const ts = await setMutedList(newMuted, blocked.item);
setMuted(login, newMuted, ts);
}
async function unblock(id: HexKey) {
const newBlocked = blocked.item.filter(p => p !== id);
const ts = await setMutedList(muted.item, newBlocked);
setBlocked(login, newBlocked, ts);
}
async function mute(id: HexKey) {
const newMuted = muted.item.includes(id) ? muted.item : muted.item.concat([id]);
const ts = await setMutedList(newMuted, blocked.item);
setMuted(login, newMuted, ts);
}
async function block(id: HexKey) {
const newBlocked = blocked.item.includes(id) ? blocked.item : blocked.item.concat([id]);
const ts = await setMutedList(muted.item, newBlocked);
setBlocked(login, newBlocked, ts);
}
async function muteAll(ids: HexKey[]) {
const newMuted = appendDedupe(muted.item, ids);
const ts = await setMutedList(newMuted, blocked.item);
setMuted(login, newMuted, ts);
await state.saveList(EventKind.MuteList);
}
function isMutedWord(word: string) {
return appData.json.mutedWords.includes(word.toLowerCase());
return false;
}
function isEventMuted(ev: TaggedNostrEvent | NostrEvent) {
return isMuted(ev.pubkey) || appData.json.mutedWords.some(w => ev.content.toLowerCase().includes(w));
return isMuted(ev.pubkey) || false;
}
return {
muted: muted.item,
muteList: state.muted,
mute,
muteAll,
unmute,
isMuted,
blocked: blocked.item,
block,
unblock,
isBlocked,
isMutedWord,
isEventMuted,
};

View File

@ -1,10 +1,32 @@
import { updateAppData, UserPreferences } from "@/Utils/Login";
import { DefaultPreferences, updateAppData, UserPreferences } from "@/Utils/Login";
import useEventPublisher from "./useEventPublisher";
import useLogin from "./useLogin";
export default function usePreferences() {
const { id, pref } = useLogin(s => ({ id: s.id, pref: s.appData.json.preferences }));
export default function usePreferences<T = UserPreferences>(selector?: (v: UserPreferences) => T): T {
const defaultSelector = (v: UserPreferences) => v as unknown as T;
return useLogin(s => {
const pref = s.state.appdata?.preferences ?? {
...DefaultPreferences,
...CONFIG.defaultPreferences,
};
return (selector || defaultSelector)(pref);
});
}
export function useAllPreferences() {
const { id, pref } = useLogin(s => {
const pref = s.state.appdata?.preferences ?? {
...DefaultPreferences,
...CONFIG.defaultPreferences,
};
return {
id: s.id,
pref: pref,
};
});
const { system } = useEventPublisher();
return {

View File

@ -2,10 +2,10 @@ import { socialGraphInstance } from "@snort/system";
import { useEffect, useMemo, useState } from "react";
import fuzzySearch from "@/Db/FuzzySearch";
import useTimelineFeed from "@/Feed/TimelineFeed";
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
import { debounce } from "@/Utils";
const options = { method: "LIMIT_UNTIL" };
const options: TimelineFeedOptions = { method: "LIMIT_UNTIL" };
export default function useProfileSearch(search: string) {
const [debouncedSearch, setDebouncedSearch] = useState(search);
@ -17,11 +17,12 @@ export default function useProfileSearch(search: string) {
}, [search]);
const subject = useMemo(
() => ({
type: "profile_keyword",
items: debouncedSearch ? [debouncedSearch] : [],
discriminator: debouncedSearch,
}),
() =>
({
type: "profile_keyword",
items: debouncedSearch ? [debouncedSearch] : [],
discriminator: debouncedSearch,
}) as TimelineSubject,
[debouncedSearch],
);
const feed = useTimelineFeed(subject, options);

View File

@ -0,0 +1,6 @@
import useLogin from "./useLogin";
export default function useRelays() {
const relays = useLogin(s => s.state.relays);
return relays ? Object.fromEntries(relays.map(a => [a.url, a.settings])) : CONFIG.defaultRelays;
}

View File

@ -1,9 +1,9 @@
import { useEffect } from "react";
import useLogin from "./useLogin";
import usePreferences from "./usePreferences";
export function useTheme() {
const { preferences } = useLogin(s => ({ preferences: s.appData.json.preferences }));
const theme = usePreferences(s => s.theme);
function setTheme(theme: "light" | "dark") {
const elm = document.documentElement;
@ -16,17 +16,15 @@ export function useTheme() {
useEffect(() => {
const osTheme = window.matchMedia("(prefers-color-scheme: light)");
setTheme(
preferences.theme === "system" && osTheme.matches ? "light" : preferences.theme === "light" ? "light" : "dark",
);
setTheme(theme === "system" && osTheme.matches ? "light" : theme === "light" ? "light" : "dark");
osTheme.onchange = e => {
if (preferences.theme === "system") {
if (theme === "system") {
setTheme(e.matches ? "light" : "dark");
}
};
return () => {
osTheme.onchange = null;
};
}, [preferences.theme]);
}, [theme]);
}

View File

@ -13,6 +13,7 @@ import Toaster from "@/Components/Toaster/Toaster";
import useLoginFeed from "@/Feed/LoginFeed";
import useLogin from "@/Hooks/useLogin";
import { useLoginRelays } from "@/Hooks/useLoginRelays";
import usePreferences from "@/Hooks/usePreferences";
import { useTheme } from "@/Hooks/useTheme";
import { ArticlesCol, MediaCol, NotesCol, NotificationsCol } from "@/Pages/Deck/Columns";
import NavSidebar from "@/Pages/Layout/NavSidebar";
@ -40,8 +41,8 @@ export function SnortDeckLayout() {
const login = useLogin(s => ({
publicKey: s.publicKey,
subscriptions: s.subscriptions,
telemetry: s.appData.json.preferences.telemetry,
}));
const telemetry = usePreferences(s => s.telemetry);
const navigate = useNavigate();
const [deckState, setDeckState] = useState<DeckState>({
thread: undefined,
@ -60,7 +61,7 @@ export function SnortDeckLayout() {
}, [login]);
useEffect(() => {
if (CONFIG.features.analytics && (login.telemetry ?? true)) {
if (CONFIG.features.analytics && (telemetry ?? true)) {
trackEvent("pageview");
}
}, [location]);

View File

@ -1,5 +1,5 @@
import { dedupe } from "@snort/shared";
import { EventKind, RequestBuilder } from "@snort/system";
import { EventKind, NostrHashtagLink, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import classNames from "classnames";
import { useMemo } from "react";
@ -9,15 +9,22 @@ import { Link, useParams } from "react-router-dom";
import AsyncButton from "@/Components/Button/AsyncButton";
import Timeline from "@/Components/Feed/Timeline";
import ProfileImage from "@/Components/User/ProfileImage";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { setTags } from "@/Utils/Login";
import { formatShort } from "@/Utils/Number";
import { TimelineSubject } from "@/Feed/TimelineFeed";
const HashTagsPage = () => {
const params = useParams();
const tag = (params.tag ?? "").toLowerCase();
const subject = useMemo(() => ({ type: "hashtag", items: [tag], discriminator: tag }), [tag]);
const subject = useMemo(
() =>
({
type: "hashtag",
items: [tag],
discriminator: tag,
}) as TimelineSubject,
[tag],
);
return (
<>
@ -32,23 +39,10 @@ const HashTagsPage = () => {
export default HashTagsPage;
export function HashTagHeader({ tag, events, className }: { tag: string; events?: number; className?: string }) {
const login = useLogin();
const state = useLogin(s => s.state);
const isFollowing = useMemo(() => {
return login.tags.item.includes(tag);
}, [login, tag]);
const { publisher, system } = useEventPublisher();
async function followTags(ts: string[]) {
if (publisher) {
const ev = await publisher.generic(eb => {
eb.kind(EventKind.InterestsList);
ts.forEach(a => eb.tag(["t", a]));
return eb;
});
setTags(login, ts, ev.created_at * 1000);
await system.BroadcastEvent(ev);
}
}
return state.isOnList(EventKind.InterestsList, new NostrHashtagLink(tag));
}, [state, tag]);
const sub = useMemo(() => {
const rb = new RequestBuilder(`hashtag-counts:${tag}`);
@ -78,11 +72,13 @@ export function HashTagHeader({ tag, events, className }: { tag: string; events?
)}
</div>
{isFollowing ? (
<AsyncButton className="secondary" onClick={() => followTags(login.tags.item.filter(t => t !== tag))}>
<AsyncButton
className="secondary"
onClick={() => state.removeFromList(EventKind.InterestsList, new NostrHashtagLink(tag), true)}>
<FormattedMessage defaultMessage="Unfollow" id="izWS4J" />
</AsyncButton>
) : (
<AsyncButton onClick={() => followTags(login.tags.item.concat([tag]))}>
<AsyncButton onClick={() => state.addToList(EventKind.InterestsList, new NostrHashtagLink(tag), true)}>
<FormattedMessage defaultMessage="Follow" id="ieGrWo" />
</AsyncButton>
)}

View File

@ -1,4 +1,4 @@
import { NostrLink, NostrPrefix, parseNostrLink } from "@snort/system";
import { EventKind, NostrLink, NostrPrefix, parseNostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import classNames from "classnames";
import React, { useCallback, useMemo } from "react";
@ -13,6 +13,7 @@ import useLogin from "@/Hooks/useLogin";
import { LogoHeader } from "@/Pages/Layout/LogoHeader";
import NotificationsHeader from "@/Pages/Layout/NotificationsHeader";
import { bech32ToHex } from "@/Utils";
import { unwrap } from "@snort/shared";
export function Header() {
const navigate = useNavigate();
@ -27,10 +28,17 @@ export function Header() {
}
}, [pageName]);
const { publicKey, tags } = useLogin();
const { publicKey, tags } = useLogin(s => ({
publicKey: s.publicKey,
tags: s.state.getList(EventKind.InterestsList),
}));
const isRootTab = useMemo(() => {
return location.pathname === "/" || rootTabItems("", publicKey, tags).some(item => item.path === location.pathname);
// todo: clean this up, its also in other places
const hashTags = tags.filter(a => a.toEventTag()?.[0] === "t").map(a => unwrap(a.toEventTag())[1]);
return (
location.pathname === "/" || rootTabItems("", publicKey, hashTags).some(item => item.path === location.pathname)
);
}, [location.pathname, publicKey, tags]);
const scrollUp = useCallback(() => {

View File

@ -13,6 +13,7 @@ import { useCommunityLeaders } from "@/Hooks/useCommunityLeaders";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import useLogin from "@/Hooks/useLogin";
import { useLoginRelays } from "@/Hooks/useLoginRelays";
import usePreferences from "@/Hooks/usePreferences";
import { useTheme } from "@/Hooks/useTheme";
import Footer from "@/Pages/Layout/Footer";
import { Header } from "@/Pages/Layout/Header";
@ -24,11 +25,11 @@ import RightColumn from "./RightColumn";
export default function Index() {
const location = useLocation();
const { id, stalker, telemetry } = useLogin(s => ({
const { id, stalker } = useLogin(s => ({
id: s.id,
stalker: s.stalker ?? false,
telemetry: s.appData.json.preferences.telemetry,
}));
const telemetry = usePreferences(s => s.telemetry);
useTheme();
useLoginRelays();

View File

@ -8,6 +8,6 @@ import { LoginStore } from "@/Utils/Login";
export const avatar = (node: NodeObject<NodeObject<GraphNode>>) => {
const login = LoginStore.snapshot();
return node.profile?.picture
? proxyImg(node.profile?.picture, login.appData.json.preferences.imgProxyConfig)
? proxyImg(node.profile?.picture, login.state.appdata?.preferences.imgProxyConfig)
: defaultAvatar(node.address);
};

View File

@ -1,5 +1,4 @@
import { unwrap } from "@snort/shared";
import { EventExt, EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import { EventKind, NostrLink, TaggedNostrEvent, Nip10 } from "@snort/system";
export function getNotificationContext(ev: TaggedNostrEvent) {
switch (ev.kind) {
@ -20,10 +19,10 @@ export function getNotificationContext(ev: TaggedNostrEvent) {
}
case EventKind.Repost:
case EventKind.Reaction: {
const thread = EventExt.extractThread(ev);
const tag = unwrap(thread?.replyTo ?? thread?.root ?? { value: ev.id, key: "e" });
if (tag.key === "e" || tag.key === "a") {
return NostrLink.fromThreadTag(tag);
const thread = Nip10.parseThread(ev);
const tag = thread?.replyTo ?? thread?.root;
if (tag) {
return tag;
} else {
throw new Error("Unknown thread context");
}

View File

@ -13,7 +13,7 @@ import { UserWebsiteLink } from "@/Components/User/UserWebsiteLink";
import ZapModal from "@/Components/ZapModal/ZapModal";
import useProfileBadges from "@/Feed/BadgesFeed";
import useFollowsFeed from "@/Feed/FollowsFeed";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { MusicStatus } from "@/Pages/Profile/MusicStatus";
const ProfileDetails = ({
@ -31,9 +31,9 @@ const ProfileDetails = ({
lnurl?: LNURL;
}) => {
const follows = useFollowsFeed(id);
const { showStatus, showBadges } = useLogin(s => ({
showStatus: s.appData.json.preferences.showStatus ?? false,
showBadges: s.appData.json.preferences.showBadges ?? false,
const { showStatus, showBadges } = usePreferences(s => ({
showStatus: s.showStatus ?? false,
showBadges: s.showBadges ?? false,
}));
const [showLnQr, setShowLnQr] = useState<boolean>(false);
const badges = useProfileBadges(showBadges ? id : undefined);

View File

@ -9,14 +9,11 @@ import { useLocation, useNavigate, useParams } from "react-router-dom";
import { ProxyImg } from "@/Components/ProxyImg";
import { SpotlightMediaModal } from "@/Components/Spotlight/SpotlightMedia";
import { Tab, TabSelector } from "@/Components/TabSelectors/TabSelectors";
import BlockList from "@/Components/User/BlockList";
import FollowsList from "@/Components/User/FollowListBase";
import MutedList from "@/Components/User/MutedList";
import useFollowsFeed from "@/Feed/FollowsFeed";
import useHorizontalScroll from "@/Hooks/useHorizontalScroll";
import { useMuteList } from "@/Hooks/useLists";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import AvatarSection from "@/Pages/Profile/AvatarSection";
import ProfileDetails from "@/Pages/Profile/ProfileDetails";
import {
@ -63,18 +60,13 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
}, [user]);
// feeds
const { blocked } = useModeration();
const muted = useMuteList(id);
const follows = useFollowsFeed(id);
// tabs
const [tab, setTab] = useState<Tab>(ProfileTabSelectors.Notes);
const optionalTabs = [
ProfileTabSelectors.Zaps,
ProfileTabSelectors.Relays,
ProfileTabSelectors.Bookmarks,
ProfileTabSelectors.Muted,
].filter(a => unwrap(a)) as Tab[];
const optionalTabs = [ProfileTabSelectors.Zaps, ProfileTabSelectors.Relays, ProfileTabSelectors.Bookmarks].filter(a =>
unwrap(a),
) as Tab[];
const horizontalScroll = useHorizontalScroll();
useEffect(() => {
@ -128,12 +120,6 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
case ProfileTabType.FOLLOWERS: {
return <FollowersTab id={id} />;
}
case ProfileTabType.MUTED: {
return <MutedList pubkeys={muted.map(a => a.id)} />;
}
case ProfileTabType.BLOCKED: {
return <BlockList />;
}
case ProfileTabType.RELAYS: {
return <RelaysTab id={id} />;
}
@ -143,6 +129,9 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
case ProfileTabType.REACTIONS: {
return <ReactionsTab id={id} />;
}
case ProfileTabType.MUTED: {
return <MutedList />;
}
}
}
@ -186,7 +175,7 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
ProfileTabSelectors.Follows,
].map(renderTabSelector)}
{optionalTabs.map(renderTabSelector)}
{isMe && blocked.length > 0 && renderTabSelector(ProfileTabSelectors.Blocked)}
{isMe && renderTabSelector(ProfileTabSelectors.Muted)}
</div>
</div>
<div className="main-content">{tabContent()}</div>

View File

@ -5,7 +5,6 @@ export enum ProfileTabType {
FOLLOWS = 3,
ZAPS = 4,
MUTED = 5,
BLOCKED = 6,
RELAYS = 7,
BOOKMARKS = 8,
}

View File

@ -1,12 +1,13 @@
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { RootTabRoutes } from "@/Pages/Root/RootTabRoutes";
export const DefaultTab = () => {
const { preferences, publicKey } = useLogin(s => ({
preferences: s.appData.json.preferences,
const { publicKey } = useLogin(s => ({
publicKey: s.publicKey,
}));
const tab = publicKey ? preferences.defaultRootTab : `trending/notes`;
const defaultRootTab = usePreferences(s => s.defaultRootTab);
const tab = publicKey ? defaultRootTab : `trending/notes`;
const elm = RootTabRoutes.find(a => a.path === tab)?.element;
return elm ?? RootTabRoutes.find(a => a.path === preferences.defaultRootTab)?.element;
return elm ?? RootTabRoutes.find(a => a.path === defaultRootTab)?.element;
};

View File

@ -15,8 +15,9 @@ import messages from "@/Pages/messages";
import { System } from "@/system";
const FollowsHint = () => {
const { publicKey, contacts } = useLogin();
if (contacts.length === 0 && publicKey) {
const publicKey = useLogin(s => s.publicKey);
const { followList } = useFollowsControls();
if (followList.length === 0 && publicKey) {
return (
<FormattedMessage
{...messages.NoFollows}
@ -30,7 +31,6 @@ const FollowsHint = () => {
/>
);
}
return null;
};
let forYouFeed = {
@ -62,7 +62,11 @@ const getReactedByFollows = (follows: string[]) => {
export const ForYouTab = memo(function ForYouTab() {
const [notes, setNotes] = useState<NostrEvent[]>(forYouFeed.events);
const login = useLogin();
const login = useLogin(s => ({
feedDisplayAs: s.feedDisplayAs,
publicKey: s.publicKey,
tags: s.state.getList(EventKind.InterestSet),
}));
const displayAsInitial = login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const navigationType = useNavigationType();
@ -82,12 +86,12 @@ export const ForYouTab = memo(function ForYouTab() {
items: followList,
discriminator: login.publicKey?.slice(0, 12),
extra: rb => {
if (login.tags.item.length > 0) {
rb.withFilter().kinds([EventKind.TextNote]).tag("t", login.tags.item);
if (login.tags.length > 0) {
rb.withFilter().kinds([EventKind.TextNote]).tags(login.tags);
}
},
}) as TimelineSubject,
[followList, login.tags.item],
[login.publicKey, followList, login.tags],
);
// also get "follows" feed so data is loaded from relays and there's a fallback if "for you" feed is empty
const latestFeed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);
@ -164,7 +168,7 @@ export const ForYouTab = memo(function ForYouTab() {
const frags = useMemo(() => {
return [
{
events: combinedFeed,
events: combinedFeed as Array<TaggedNostrEvent>,
refTime: Date.now(),
},
];
@ -175,7 +179,13 @@ export const ForYouTab = memo(function ForYouTab() {
<DisplayAsSelector activeSelection={displayAs} onSelect={a => setDisplayAs(a)} />
<FollowsHint />
<TaskList />
<TimelineRenderer frags={frags} latest={[]} displayAs={displayAs} loadMore={() => latestFeed.loadMore()} />
<TimelineRenderer
frags={frags}
latest={[]}
displayAs={displayAs}
loadMore={() => latestFeed.loadMore()}
showLatest={() => {}}
/>
</>
);
});

View File

@ -1,17 +1,19 @@
import { NostrLink } from "@snort/system";
import { NostrEvent, NostrLink } from "@snort/system";
import { useContext, useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import TimelineFollows from "@/Components/Feed/TimelineFollows";
import { TaskList } from "@/Components/Tasks/TaskList";
import useFollowsControls from "@/Hooks/useFollowControls";
import useLogin from "@/Hooks/useLogin";
import { DeckContext } from "@/Pages/Deck/DeckLayout";
import messages from "@/Pages/messages";
const FollowsHint = () => {
const { publicKey: pubKey, follows } = useLogin();
if (follows.item?.length === 0 && pubKey) {
const publicKey = useLogin(s => s.publicKey);
const { followList } = useFollowsControls();
if (followList.length === 0 && publicKey) {
return (
<FormattedMessage
{...messages.NoFollows}
@ -25,15 +27,15 @@ const FollowsHint = () => {
/>
);
}
return null;
};
export const NotesTab = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const deckContext = useContext(DeckContext);
const noteOnClick = useMemo(() => {
if (deckContext) {
return ev => {
return (ev: NostrEvent) => {
deckContext.setThread(NostrLink.fromEvent(ev));
};
}

View File

@ -2,15 +2,22 @@ import { useMemo } from "react";
import Timeline from "@/Components/Feed/Timeline";
import useLogin from "@/Hooks/useLogin";
import { EventKind } from "@snort/system";
import { unwrap } from "@snort/shared";
import { TimelineSubject } from "@/Feed/TimelineFeed";
export function TopicsPage() {
const { tags, pubKey } = useLogin(s => ({ tags: s.tags.item, pubKey: s.publicKey }));
const { tags, pubKey } = useLogin(s => ({
pubKey: s.publicKey,
tags: s.state.getList(EventKind.InterestSet),
}));
const subject = useMemo(
() => ({
type: "hashtag",
items: tags,
discriminator: pubKey ?? "",
}),
() =>
({
type: "hashtag",
items: tags.filter(a => a.toEventTag()?.[0] === "t").map(a => unwrap(a.toEventTag())[1]),
discriminator: pubKey ?? "",
}) as TimelineSubject,
[tags, pubKey],
);

View File

@ -1,9 +1,9 @@
import { useMemo, useSyncExternalStore } from "react";
import { SnortContext } from "@snort/system-react";
import { useContext, useMemo, useSyncExternalStore } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { ZapPoolTarget } from "@/Pages/ZapPool/ZapPoolTarget";
import { bech32ToHex, getRelayName, trackEvent, unwrap } from "@/Utils";
import { SnortPubKey } from "@/Utils/Const";
@ -19,8 +19,8 @@ const DataProviders = [
];
export function ZapPoolPageInner() {
const login = useLogin();
const { system } = useEventPublisher();
const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
const system = useContext(SnortContext);
const zapPool = useSyncExternalStore(
c => unwrap(ZapPoolController).hook(c),
() => unwrap(ZapPoolController).snapshot(),
@ -39,7 +39,7 @@ export function ZapPoolPageInner() {
})
.filter(a => a !== undefined)
.map(unwrap);
}, [login.relays]);
}, []);
const sumPending = zapPool.reduce((acc, v) => acc + v.sum, 0);
return (
@ -66,7 +66,7 @@ export function ZapPoolPageInner() {
values={{
number: (
<b>
<FormattedNumber value={login.appData.json.preferences.defaultZapAmount} />
<FormattedNumber value={defaultZapAmount} />
</b>
),
}}
@ -79,14 +79,12 @@ export function ZapPoolPageInner() {
values={{
nIn: (
<b>
<FormattedNumber value={login.appData.json.preferences.defaultZapAmount} />
<FormattedNumber value={defaultZapAmount} />
</b>
),
nOut: (
<b>
<FormattedNumber
value={ZapPoolController?.calcAllocation(login.appData.json.preferences.defaultZapAmount) ?? 0}
/>
<FormattedNumber value={ZapPoolController?.calcAllocation(defaultZapAmount) ?? 0} />
</b>
),
}}

View File

@ -2,14 +2,13 @@ import { useUserProfile } from "@snort/system-react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import ProfilePreview from "@/Components/User/ProfilePreview";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { ZapPoolController, ZapPoolRecipient } from "@/Utils/ZapPoolController";
function ZapPoolTargetInner({ target }: { target: ZapPoolRecipient }) {
const login = useLogin();
const profile = useUserProfile(target.pubkey);
const hasAddress = profile?.lud16 || profile?.lud06;
const defaultZapMount = Math.ceil(login.appData.json.preferences.defaultZapAmount * (target.split / 100));
const defaultZapMount = usePreferences(s => s.defaultZapAmount * (target.split / 100));
return (
<ProfilePreview
pubkey={target.pubkey}

View File

@ -5,15 +5,14 @@ import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { appendDedupe } from "@/Utils";
import { SnortAppData, updateAppData } from "@/Utils/Login";
import { useAllPreferences } from "@/Hooks/usePreferences";
export default function ModerationSettingsPage() {
const login = useLogin();
const state = useAllPreferences();
const [muteWord, setMuteWord] = useState("");
const appData = login.appData.json;
const { system } = useEventPublisher();
function addMutedWord() {
updateAppData(login.id, system, ad => ({
updateAppData(login.id, ad => ({
...ad,
mutedWords: appendDedupe(appData.mutedWords, [muteWord]),
}));
@ -21,14 +20,14 @@ export default function ModerationSettingsPage() {
}
const handleToggle = (setting: keyof SnortAppData) => {
updateAppData(login.id, system, ad => ({
updateAppData(login.id, ad => ({
...ad,
[setting]: !appData[setting],
}));
};
function removeMutedWord(word: string) {
updateAppData(login.id, system, ad => ({
updateAppData(login.id, ad => ({
...ad,
mutedWords: appData.mutedWords.filter(a => a !== word),
}));

View File

@ -7,7 +7,7 @@ import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import { AllLanguageCodes } from "@/Components/IntlProvider/IntlProviderUtils";
import { useLocale } from "@/Components/IntlProvider/useLocale";
import usePreferences from "@/Hooks/usePreferences";
import { useAllPreferences } from "@/Hooks/usePreferences";
import { unwrap } from "@/Utils";
import { DefaultImgProxy } from "@/Utils/Const";
import { UserPreferences } from "@/Utils/Login";
@ -16,7 +16,7 @@ import messages from "./messages";
const PreferencesPage = () => {
const { formatMessage } = useIntl();
const { preferences, update: updatePerf } = usePreferences();
const { preferences, update: updatePerf } = useAllPreferences();
const [pref, setPref] = useState<UserPreferences>(preferences);
const [error, setError] = useState("");
const { lang } = useLocale();

View File

@ -1,12 +1,12 @@
import { FormattedMessage } from "react-intl";
import { useNavigate, useParams } from "react-router-dom";
import AsyncButton from "@/Components/Button/AsyncButton";
import ProfilePreview from "@/Components/User/ProfilePreview";
import useRelayState from "@/Feed/RelayState";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { parseId, unwrap } from "@/Utils";
import { removeRelay } from "@/Utils/Login";
import messages from "./messages";
@ -104,14 +104,13 @@ const RelayInfo = () => {
))}
</div>
<div className="flex mt10 justify-end">
<div
className="btn error"
onClick={() => {
removeRelay(login, unwrap(conn).Address);
<AsyncButton
onClick={async () => {
await login.state.removeRelay(unwrap(conn).Address, true);
navigate("/settings/relays");
}}>
<FormattedMessage {...messages.Remove} />
</div>
</AsyncButton>
</div>
</div>
</>

View File

@ -1,4 +1,4 @@
import { unixNowMs, unwrap } from "@snort/shared";
import { unwrap } from "@snort/shared";
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
@ -7,31 +7,37 @@ import Relay from "@/Components/Relay/Relay";
import SnortApi, { RelayDistance } from "@/External/SnortApi";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import useRelays from "@/Hooks/useRelays";
import { saveRelays } from "@/Pages/settings/saveRelays";
import { getCountry, getRelayName, sanitizeRelayUrl } from "@/Utils";
import { setRelays } from "@/Utils/Login";
import { formatShort } from "@/Utils/Number";
import messages from "./messages";
const RelaySettingsPage = () => {
const { publisher, system } = useEventPublisher();
const login = useLogin();
const relays = login.relays;
const relays = useRelays();
const { readonly, state } = useLogin(s => ({ state: s.state, readonly: s.readonly }));
const [newRelay, setNewRelay] = useState<string>();
const otherConnections = useMemo(() => {
return [...system.pool].filter(([k]) => relays.item[k] === undefined).map(([, v]) => v);
}, [relays]);
return [...system.pool].filter(([k]) => relays[k] === undefined).map(([, v]) => v);
}, [system.pool, relays]);
const handleNewRelayChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = event.target.value;
const protocol = window.location.protocol;
if ((protocol === "https:" && inputValue.startsWith("wss://")) || protocol === "http:") {
setNewRelay(inputValue);
}
setNewRelay(inputValue);
};
async function addNewRelay() {
const url = sanitizeRelayUrl(newRelay);
if (url) {
await state.addRelay(url, { read: true, write: true }, false);
setNewRelay("");
}
}
function addRelay() {
return (
<div className="flex flex-col g8">
@ -45,35 +51,24 @@ const RelaySettingsPage = () => {
value={newRelay}
onChange={handleNewRelayChange}
/>
<button className="secondary" onClick={() => addNewRelay()}>
<AsyncButton className="secondary" onClick={() => addNewRelay()}>
<FormattedMessage {...messages.Add} />
</button>
</AsyncButton>
</div>
);
}
function addNewRelay() {
if ((newRelay?.length ?? 0) > 0) {
const parsed = new URL(newRelay ?? "");
const payload = {
...relays.item,
[parsed.toString()]: { read: true, write: true },
};
setRelays(login, payload, unixNowMs());
}
}
return (
<div className="flex flex-col g8">
<h3>
<FormattedMessage {...messages.Relays} />
</h3>
<div className="flex flex-col g8">
{Object.keys(relays.item || {}).map(a => (
{Object.keys(relays || {}).map(a => (
<Relay addr={a} key={a} />
))}
</div>
<AsyncButton type="button" onClick={() => saveRelays(system, publisher, relays.item)} disabled={login.readonly}>
<AsyncButton type="button" onClick={() => saveRelays(system, publisher, relays.item)} disabled={readonly}>
<FormattedMessage {...messages.Save} />
</AsyncButton>
{addRelay()}
@ -96,14 +91,22 @@ export function CloseRelays() {
const [relays, setRecommendedRelays] = useState<Array<RelayDistance>>();
const country = getCountry();
const [location, setLocation] = useState<{ lat: number; lon: number }>(country);
const login = useLogin();
const relayUrls = Object.keys(login.relays.item);
const currentRelays = useRelays();
const state = useLogin(s => s.state);
const relayUrls = Object.keys(currentRelays);
async function loadRelays() {
const api = new SnortApi();
setRecommendedRelays(await api.closeRelays(location.lat, location.lon, 10));
}
async function addNewRelay(newRelay: string) {
const url = sanitizeRelayUrl(newRelay);
if (url) {
await state.addRelay(url, { read: true, write: true }, false);
}
}
useEffect(() => {
loadRelays().catch(console.error);
}, [location]);
@ -138,17 +141,7 @@ export function CloseRelays() {
<div key={a.url} className="bg-dark p br flex flex-col g8">
<div className="flex justify-between items-center">
<div className="bold">{getRelayName(a.url)}</div>
<AsyncButton
onClick={async () => {
setRelays(
login,
{
...login.relays.item,
[a.url]: { read: true, write: true },
},
unixNowMs(),
);
}}>
<AsyncButton onClick={() => addNewRelay(a.url)}>
<FormattedMessage defaultMessage="Add" id="2/2yg+" />
</AsyncButton>
</div>

View File

@ -17,14 +17,13 @@ const enum PruneStage {
}
export function PruneFollowList() {
const { followList: follows } = useFollowsControls();
const followControls = useFollowsControls();
const { system } = useEventPublisher();
const uniqueFollows = dedupe(follows);
const uniqueFollows = dedupe(followControls.followList);
const [status, setStatus] = useState<PruneStage>();
const [progress, setProgress] = useState(0);
const [lastPost, setLastPosts] = useState<Record<string, number>>();
const [unfollow, setUnfollow] = useState<Array<string>>([]);
const followControls = useFollowsControls();
async function fetchLastPosts() {
setStatus(PruneStage.FetchLastPostTimestamp);
@ -121,8 +120,8 @@ export function PruneFollowList() {
defaultMessage="{x} follows ({y} duplicates)"
id="iICVoL"
values={{
x: follows.length,
y: follows.length - uniqueFollows.length,
x: followControls.followList.length,
y: followControls.followList.length - uniqueFollows.length,
}}
/>
</div>

View File

@ -6,6 +6,7 @@ import { FormattedMessage, FormattedNumber } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import useLogin from "@/Hooks/useLogin";
import useRelays from "@/Hooks/useRelays";
import { SearchRelays } from "@/Utils/Const";
export default function SyncAccountTool() {
@ -13,9 +14,10 @@ export default function SyncAccountTool() {
const login = useLogin();
const [scan, setScan] = useState<number>();
const [results, setResults] = useState<Array<TaggedNostrEvent>>([]);
const myRelays = useRelays();
async function start() {
const relays = Object.entries(login.relays.item)
const relays = Object.entries(myRelays)
.filter(([, v]) => v.write)
.map(([k]) => k);
const sync = new RangeSync(system);

View File

@ -22,40 +22,6 @@ import { SubscriptionEvent } from "@/Utils/Subscription";
import { Nip7OsSigner } from "./Nip7OsSigner";
export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) {
if (import.meta.env.VITE_SINGLE_RELAY) {
state.relays.item = {
[import.meta.env.VITE_SINGLE_RELAY]: { read: true, write: true },
};
state.relays.timestamp = 100;
LoginStore.updateSession(state);
return;
}
if (state.relays.timestamp >= createdAt) {
return;
}
// filter out non-websocket urls
const filtered = new Map<string, RelaySettings>();
for (const [k, v] of Object.entries(relays)) {
if (k.startsWith("wss://") || k.startsWith("ws://")) {
const url = sanitizeRelayUrl(k);
if (url) {
filtered.set(url, v as RelaySettings);
}
}
}
state.relays.item = Object.fromEntries(filtered.entries());
state.relays.timestamp = createdAt;
LoginStore.updateSession(state);
}
export function removeRelay(state: LoginSession, addr: string) {
delete state.relays.item[addr];
LoginStore.updateSession(state);
}
export function logout(id: string) {
LoginStore.removeSession(id);
GiftsCache.clear();
@ -141,33 +107,6 @@ export function generateRandomKey() {
};
}
export function setTags(state: LoginSession, tags: Array<string>, ts: number) {
if (state.tags.timestamp >= ts) {
return;
}
state.tags.item = tags;
state.tags.timestamp = ts;
LoginStore.updateSession(state);
}
export function setMuted(state: LoginSession, muted: Array<string>, ts: number) {
if (state.muted.timestamp >= ts) {
return;
}
state.muted.item = muted;
state.muted.timestamp = ts;
LoginStore.updateSession(state);
}
export function setBlocked(state: LoginSession, blocked: Array<string>, ts: number) {
if (state.blocked.timestamp >= ts) {
return;
}
state.blocked.item = blocked;
state.blocked.timestamp = ts;
LoginStore.updateSession(state);
}
export function updateSession(id: string, fn: (state: LoginSession) => void) {
const session = LoginStore.get(id);
if (session) {
@ -176,37 +115,19 @@ export function updateSession(id: string, fn: (state: LoginSession) => void) {
}
}
export function setPinned(state: LoginSession, pinned: Array<string>, ts: number) {
if (state.pinned.timestamp >= ts) {
return;
}
state.pinned.item = pinned;
state.pinned.timestamp = ts;
LoginStore.updateSession(state);
}
export function setBookmarked(state: LoginSession, bookmarked: Array<string>, ts: number) {
if (state.bookmarked.timestamp >= ts) {
return;
}
state.bookmarked.item = bookmarked;
state.bookmarked.timestamp = ts;
LoginStore.updateSession(state);
}
export async function setAppData(state: LoginSession, data: SnortAppData, system: SystemInterface) {
export async function setAppData(state: LoginSession, data: SnortAppData) {
const pub = LoginStore.getPublisher(state.id);
if (!pub) return;
await state.appData.updateJson(data, pub.signer, system);
await state.state.setAppData(data);
LoginStore.updateSession(state);
}
export async function updateAppData(id: string, system: SystemInterface, fn: (data: SnortAppData) => SnortAppData) {
export async function updateAppData(id: string, fn: (data: SnortAppData) => SnortAppData) {
const session = LoginStore.get(id);
if (session) {
const next = fn(session.appData.json);
await setAppData(session, next, system);
if (session?.state.appdata) {
const next = fn(session.state.appdata);
await setAppData(session, next);
}
}

View File

@ -1,4 +1,4 @@
import { HexKey, JsonEventSync, KeyStorage, RelaySettings } from "@snort/system";
import { HexKey, KeyStorage, UserState } from "@snort/system";
import { DisplayAs } from "@/Components/Feed/DisplayAsSelector";
import { UserPreferences } from "@/Utils/Login/index";
@ -21,9 +21,6 @@ export const enum LoginSessionType {
}
export interface SnortAppData {
id: string;
mutedWords: Array<string>;
showContentWarningPosts: boolean;
preferences: UserPreferences;
}
@ -64,40 +61,7 @@ export interface LoginSession {
*/
publicKey?: HexKey;
/**
* All the logged in users relays
*/
relays: Newest<Record<string, RelaySettings>>;
/**
* A list of pubkeys this user follows
*/
contacts: Array<Array<string>>;
/**
* A list of tags this user follows
*/
tags: Newest<Array<string>>;
/**
* A list of event ids this user has pinned
*/
pinned: Newest<Array<string>>;
/**
* A list of event ids this user has bookmarked
*/
bookmarked: Newest<Array<string>>;
/**
* A list of pubkeys this user has muted
*/
muted: Newest<Array<HexKey>>;
/**
* A list of pubkeys this user has muted privately
*/
blocked: Newest<Array<HexKey>>;
state: UserState<SnortAppData>;
/**
* Timestamp of last read notification
@ -114,11 +78,6 @@ export interface LoginSession {
*/
remoteSignerRelays?: Array<string>;
/**
* Snort application data
*/
appData: JsonEventSync<SnortAppData>;
/**
* A list of chats which we have joined (NIP-28/NIP-29)
*/

View File

@ -3,16 +3,14 @@ import * as utils from "@noble/curves/abstract/utils";
import * as secp from "@noble/curves/secp256k1";
import { ExternalStore, unwrap } from "@snort/shared";
import {
EventKind,
EventPublisher,
HexKey,
JsonEventSync,
KeyStorage,
NostrLink,
NostrPrefix,
NotEncrypted,
RelaySettings,
socialGraphInstance,
UserState,
UserStateObject,
} from "@snort/system";
import { v4 as uuid } from "uuid";
@ -29,8 +27,6 @@ const LoggedOut = {
item: [],
timestamp: 0,
},
contacts: [],
follows: [],
muted: {
item: [],
timestamp: 0,
@ -54,18 +50,15 @@ const LoggedOut = {
latestNotification: 0,
readNotifications: 0,
subscriptions: [],
appData: new JsonEventSync<SnortAppData>(
{
id: "",
preferences: DefaultPreferences,
mutedWords: [],
showContentWarningPosts: false,
},
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData),
true,
),
extraChats: [],
stalker: false,
state: new UserState<SnortAppData>("", {
initAppdata: {
preferences: DefaultPreferences,
},
encryptAppdata: true,
appdataId: "snort",
}),
} as LoginSession;
export class MultiAccountStore extends ExternalStore<LoginSession> {
@ -75,7 +68,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
constructor() {
super();
if (typeof ServiceWorkerGlobalScope !== "undefined" && self instanceof ServiceWorkerGlobalScope) {
if (typeof ServiceWorkerGlobalScope !== "undefined" && globalThis instanceof ServiceWorkerGlobalScope) {
// return if sw. we might want to use localForage (idb) to share keys between sw and app
return;
}
@ -103,14 +96,23 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (v.privateKeyData) {
v.privateKeyData = KeyStorage.fromPayload(v.privateKeyData as object);
}
v.appData = new JsonEventSync<SnortAppData>(
v.appData as unknown as SnortAppData,
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, v.publicKey),
true,
const stateObj = v.state as unknown as UserStateObject<SnortAppData> | undefined;
const stateClass = new UserState<SnortAppData>(
v.publicKey!,
{
initAppdata: stateObj?.appdata ?? {
preferences: {
...DefaultPreferences,
...CONFIG.defaultPreferences,
},
},
encryptAppdata: true,
appdataId: "snort",
},
stateObj,
);
v.appData.on("change", () => {
this.#save();
});
stateClass.on("change", () => this.#save());
v.state = stateClass;
}
this.#loadIrisKeyIfExists();
}
@ -174,24 +176,22 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
item: initRelays,
timestamp: 1,
},
appData: new JsonEventSync<SnortAppData>(
{
id: "",
state: new UserState<SnortAppData>(key, {
initAppdata: {
preferences: {
...DefaultPreferences,
...CONFIG.defaultPreferences,
},
mutedWords: [],
showContentWarningPosts: false,
},
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, key),
true,
),
encryptAppdata: true,
appdataId: "snort",
}),
remoteSignerRelays,
privateKeyData: privateKey,
stalker: stalker ?? false,
} as LoginSession;
newSession.state.on("change", () => this.#save());
const pub = createPublisher(newSession);
if (pub) {
this.setPublisher(newSession.id, pub);
@ -229,20 +229,18 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
item: initRelays,
timestamp: 1,
},
appData: new JsonEventSync<SnortAppData>(
{
id: "",
state: new UserState<SnortAppData>(pubKey, {
initAppdata: {
preferences: {
...DefaultPreferences,
...CONFIG.defaultPreferences,
},
mutedWords: [],
showContentWarningPosts: false,
},
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, pubKey),
true,
),
encryptAppdata: true,
appdataId: "snort",
}),
} as LoginSession;
newSession.state.on("change", () => this.#save());
if ("nostr_os" in window && window?.nostr_os) {
window?.nostr_os.saveKey(key.value);
@ -300,14 +298,43 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
#migrate() {
let didMigrate = false;
// delete some old keys
for (const [, acc] of this.#accounts) {
if ("item" in acc.appData) {
if ("appData" in acc) {
delete acc["appData"];
didMigrate = true;
}
if ("contacts" in acc) {
delete acc["contacts"];
didMigrate = true;
}
if ("follows" in acc) {
delete acc["follows"];
didMigrate = true;
}
if ("relays" in acc) {
delete acc["relays"];
didMigrate = true;
}
if ("blocked" in acc) {
delete acc["blocked"];
didMigrate = true;
}
if ("bookmarked" in acc) {
delete acc["bookmarked"];
didMigrate = true;
}
if ("muted" in acc) {
delete acc["muted"];
didMigrate = true;
}
if ("pinned" in acc) {
delete acc["pinned"];
didMigrate = true;
}
if ("tags" in acc) {
delete acc["tags"];
didMigrate = true;
acc.appData = new JsonEventSync<SnortAppData>(
acc.appData.item as SnortAppData,
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, acc.publicKey),
true,
);
}
}
@ -326,17 +353,18 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (v.privateKeyData instanceof KeyStorage) {
toSave.push({
...v,
appData: v.appData.json,
state: v.state instanceof UserState ? v.state.serialize() : v.state,
privateKeyData: v.privateKeyData.toPayload(),
});
} else {
toSave.push({
...v,
appData: v.appData.json,
state: v.state instanceof UserState ? v.state.serialize() : v.state,
});
}
}
console.debug("Trying to save", toSave);
window.localStorage.setItem(AccountStoreKey, JSON.stringify(toSave));
this.notifyChange();
}

View File

@ -102,6 +102,11 @@ export interface UserPreferences {
* Hides muted notes when selected
*/
hideMutedNotes: boolean;
/**
* Show posts with content warning
*/
showContentWarningPosts: boolean;
}
export const DefaultPreferences = {
@ -123,4 +128,5 @@ export const DefaultPreferences = {
checkSigs: true,
autoTranslate: true,
hideMutedNotes: false,
showContentWarningPosts: false,
} as UserPreferences;

View File

@ -1,18 +1,22 @@
import { unwrap } from "@snort/shared";
import { EventExt, NostrLink, TaggedNostrEvent } from "@snort/system";
import { Nip10, NostrLink, TaggedNostrEvent } from "@snort/system";
/**
* Get the chain key as a reply event
*
* ie. Get the key for which this event is replying to
*/
export function replyChainKey(ev: TaggedNostrEvent) {
const t = EventExt.extractThread(ev);
return t?.replyTo?.value ?? t?.root?.value;
const t = Nip10.parseThread(ev);
const tag = t?.replyTo ?? t?.root;
return tag?.tagKey;
}
/**
* Get the chain key of this event
*
* ie. Get the key which ties replies to this event
*/
export function chainKey(ev: TaggedNostrEvent) {
const link = NostrLink.fromEvent(ev);
return unwrap(link.toEventTag())[1];
return link.tagKey;
}

View File

@ -12,13 +12,13 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
const location = useLocation();
const [currentId, setCurrentId] = useState(unwrap(link.toEventTag())[1]);
const feed = useThreadFeed(link);
const { isBlocked } = useModeration();
const { isMuted } = useModeration();
const chains = useMemo(() => {
const chains = new Map<u256, Array<TaggedNostrEvent>>();
if (feed) {
feed
?.filter(a => !isBlocked(a.pubkey))
?.filter(a => !isMuted(a.pubkey))
.forEach(v => {
const replyTo = replyChainKey(v);
if (replyTo) {
@ -31,7 +31,7 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
});
}
return chains;
}, [feed]);
}, [feed, isMuted]);
// Root is the parent of the current note or the current note if its a root note or the root of the thread
const root = useMemo(() => {
@ -46,7 +46,7 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
return currentNote;
}
}
}, [feed.length, currentId, location]);
}, [feed, location.state, currentId]);
const ctxValue = useMemo<ThreadContextState>(() => {
return {
@ -56,7 +56,7 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
data: feed,
setCurrent: v => setCurrentId(v),
};
}, [root, chains]);
}, [currentId, root, chains, feed]);
return <ThreadContext.Provider value={ctxValue}>{children}</ThreadContext.Provider>;
}

View File

@ -3,7 +3,7 @@ import { useState } from "react";
import { v4 as uuid } from "uuid";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { bech32ToHex, unwrap } from "@/Utils";
import { KieranPubKey } from "@/Utils/Const";
import NostrBuild from "@/Utils/Upload/NostrBuild";
@ -65,7 +65,7 @@ export interface UploadProgress {
export type UploadStage = "starting" | "hashing" | "uploading" | "done" | undefined;
export default function useFileUpload(): Uploader {
const fileUploader = useLogin(s => s.appData.json.preferences.fileUploader);
const fileUploader = usePreferences(s => s.fileUploader);
const { publisher } = useEventPublisher();
const [progress, setProgress] = useState<Array<UploadProgress>>([]);
const [stage, setStage] = useState<UploadStage>();

View File

@ -437,9 +437,10 @@ export function getUrlHostname(url?: string) {
}
}
export function sanitizeRelayUrl(url: string) {
export function sanitizeRelayUrl(url?: string) {
if ((url?.length ?? 0) === 0) return;
try {
return new URL(url).toString();
return new URL(url!).toString();
} catch {
// ignore
}
@ -537,7 +538,7 @@ export function trackEvent(
if (
!import.meta.env.DEV &&
CONFIG.features.analytics &&
(LoginStore.snapshot().appData.json.preferences.telemetry ?? true)
(LoginStore.snapshot().state.appdata?.preferences.telemetry ?? true)
) {
fetch("https://pa.v0l.io/api/event", {
method: "POST",
@ -547,9 +548,9 @@ export function trackEvent(
body: JSON.stringify({
d: CONFIG.hostname,
n: event,
r: document.referrer === location.href ? null : document.referrer,
r: document.referrer === window.location.href ? null : document.referrer,
p: props,
u: e?.destination?.url ?? `${location.protocol}//${location.host}${location.pathname}`,
u: e?.destination?.url ?? `${window.location.protocol}//${window.location.host}${window.location.pathname}`,
}),
});
}

View File

@ -169,19 +169,19 @@ export function useChatSystem(chat: ChatSystem) {
const login = useLogin();
const sub = useMemo(() => {
return chat.subscription(login);
}, [login.publicKey]);
}, [chat, login]);
const data = useRequestBuilder(sub);
const { isBlocked } = useModeration();
const { isMuted } = useModeration();
return useMemo(() => {
if (login.publicKey) {
return chat.listChats(
login.publicKey,
data.filter(a => !isBlocked(a.pubkey)),
data.filter(a => !isMuted(a.pubkey)),
);
}
return [];
}, [login.publicKey, data]);
}, [chat, login, data, isMuted]);
}
export function useChatSystems() {

View File

@ -12,7 +12,6 @@ import { ThreadRoute } from "@/Components/Event/Thread/ThreadRoute";
import { IntlProvider } from "@/Components/IntlProvider/IntlProvider";
import { db } from "@/Db";
import { addCachedMetadataToFuzzySearch } from "@/Db/FuzzySearch";
import { updateRelayConnections } from "@/Hooks/useLoginRelays";
import { AboutPage } from "@/Pages/About";
import { SnortDeckLayout } from "@/Pages/Deck/DeckLayout";
import DonatePage from "@/Pages/Donate/DonatePage";
@ -39,10 +38,10 @@ import { WalletSendPage } from "@/Pages/wallet/send";
import ZapPoolPage from "@/Pages/ZapPool/ZapPool";
import { System } from "@/system";
import { storeRefCode, unwrap } from "@/Utils";
import { LoginStore } from "@/Utils/Login";
import { hasWasm, wasmInit, WasmPath } from "@/Utils/wasm";
import { Wallets } from "@/Wallet";
import { setupWebLNWalletConfig } from "@/Wallet";
import { LoginStore } from "./Utils/Login";
async function initSite() {
storeRefCode();
@ -50,15 +49,14 @@ async function initSite() {
await wasmInit(WasmPath);
await initRelayWorker();
}
const login = LoginStore.takeSnapshot();
updateRelayConnections(System, login.relays.item).catch(console.error);
setupWebLNWalletConfig(Wallets);
db.ready = await db.isAvailable();
if (db.ready) {
const pTags = login.contacts.filter(a => a[0] === "p").map(a => a[1]);
await preload(pTags);
await System.PreloadSocialGraph();
const login = LoginStore.snapshot();
preload(login.state.follows); // dont await this
System.PreloadSocialGraph(); // dont await this
}
queueMicrotask(() => {

View File

@ -1,10 +1,6 @@
/// <reference lib="webworker" />
import { CacheableResponsePlugin } from "workbox-cacheable-response";
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: (string | PrecacheEntry)[];
};
import { encodeTLVEntries, NostrLink, NostrPrefix, TLVEntryType, tryParseNostrLink } from "@snort/system";
import { clientsClaim } from "workbox-core";
import { ExpirationPlugin } from "workbox-expiration";
@ -15,12 +11,16 @@ import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies";
import { defaultAvatar, hexToBech32 } from "@/Utils";
import { formatShort } from "@/Utils/Number";
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: (string | PrecacheEntry)[];
};
precacheAndRoute(self.__WB_MANIFEST);
clientsClaim();
// cache everything in current domain /assets because precache doesn't seem to include everything
registerRoute(
({ url }) => url.origin === location.origin && url.pathname.startsWith("/assets"),
({ url }) => url.origin === window.location.origin && url.pathname.startsWith("/assets"),
new StaleWhileRevalidate({
cacheName: "assets-cache",
plugins: [

View File

@ -201,7 +201,7 @@ export async function fetchNostrAddress(name: string, domain: string, timeout =
}
export function removeUndefined<T>(v: Array<T | undefined>) {
return v.filter(a => a != undefined).map(a => unwrap(a));
return v.filter(a => a !== undefined).map(a => unwrap(a));
}
/**

View File

@ -5,7 +5,6 @@ import { EventEmitter } from "eventemitter3";
import { Connection, RelaySettings } from "./connection";
import { NostrEvent, OkResponse, TaggedNostrEvent } from "./nostr";
import { SystemInterface } from ".";
import LRUSet from "@snort/shared/src/LRUSet";
export interface NostrConnectionPoolEvents {
connected: (address: string, wasReconnect: boolean) => void;
@ -38,7 +37,6 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
* All currently connected websockets
*/
#sockets = new Map<string, Connection>();
#requestedIds = new LRUSet<string>(1000);
constructor(system: SystemInterface) {
super();
@ -49,7 +47,8 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
* Get a connection object from the pool
*/
getConnection(id: string) {
return this.#sockets.get(id);
const addr = unwrap(sanitizeRelayUrl(id));
return this.#sockets.get(addr);
}
/**

View File

@ -51,6 +51,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
#activity: number = unixNowMs();
#expectAuth = false;
#ephemeral: boolean;
#closing = false;
#downCount = 0;
#activeRequests = new Set<string>();
Id: string;
readonly Address: string;
@ -58,26 +61,22 @@ export class Connection extends EventEmitter<ConnectionEvents> {
PendingRaw: Array<object> = [];
PendingRequests: Array<ConnectionQueueItem> = [];
ActiveRequests = new Set<string>();
Settings: RelaySettings;
Info?: RelayInfo;
ConnectTimeout: number = DefaultConnectTimeout;
HasStateChange: boolean = true;
IsClosed: boolean;
ReconnectTimer?: ReturnType<typeof setTimeout>;
EventsCallback: Map<u256, (msg: Array<string | boolean>) => void>;
AwaitingAuth: Map<string, boolean>;
Authed = false;
Down = true;
constructor(addr: string, options: RelaySettings, ephemeral: boolean = false) {
super();
this.Id = uuid();
this.Address = addr;
this.Settings = options;
this.IsClosed = false;
this.EventsCallback = new Map();
this.AwaitingAuth = new Map();
this.#ephemeral = ephemeral;
@ -93,7 +92,20 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.#setupEphemeral();
}
get isOpen() {
return this.Socket?.readyState === WebSocket.OPEN;
}
get isDown() {
return this.#downCount > 0;
}
get ActiveRequests() {
return [...this.#activeRequests];
}
async connect() {
if (this.isOpen) return;
try {
if (this.Info === undefined) {
const u = new URL(this.Address);
@ -116,7 +128,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
// ignored
}
const wasReconnect = this.Socket !== null && !this.IsClosed;
const wasReconnect = this.Socket !== null;
if (this.Socket) {
this.Id = uuid();
this.Socket.onopen = null;
@ -125,7 +137,6 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.Socket.onclose = null;
this.Socket = null;
}
this.IsClosed = false;
this.Socket = new WebSocket(this.Address);
this.Socket.onopen = () => this.#onOpen(wasReconnect);
this.Socket.onmessage = e => this.#onMessage(e);
@ -133,52 +144,57 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.Socket.onclose = e => this.#onClose(e);
}
close() {
this.IsClosed = true;
close(final = true) {
if (final) {
this.#closing = true;
}
this.Socket?.close();
}
#onOpen(wasReconnect: boolean) {
this.ConnectTimeout = DefaultConnectTimeout;
this.#downCount = 0;
this.#log(`Open!`);
this.Down = false;
this.#setupEphemeral();
this.emit("connected", wasReconnect);
this.#sendPendingRaw();
}
#onClose(e: WebSocket.CloseEvent) {
if (this.ReconnectTimer) {
clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = undefined;
}
// remote server closed the connection, dont re-connect
if (e.code === 4000) {
this.IsClosed = true;
this.#log(`Closed! (Remote)`);
} else if (!this.IsClosed) {
this.ConnectTimeout = this.ConnectTimeout * this.ConnectTimeout;
this.#log(
`Closed (code=${e.code}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`,
);
this.ReconnectTimer = setTimeout(() => {
try {
this.connect();
} catch {
this.emit("disconnect", -1);
}
}, this.ConnectTimeout);
// todo: stats disconnect
if (!this.#closing) {
this.#downCount++;
this.#reconnectTimer(e);
} else {
this.#log(`Closed!`);
this.ReconnectTimer = undefined;
this.#downCount = 0;
if (this.ReconnectTimer) {
clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = undefined;
}
}
this.emit("disconnect", e.code);
this.#reset();
}
#reconnectTimer(e: WebSocket.CloseEvent) {
if (this.ReconnectTimer) {
clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = undefined;
}
this.ConnectTimeout = this.ConnectTimeout * 2;
this.#log(
`Closed (code=${e.code}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`,
);
this.ReconnectTimer = setTimeout(() => {
try {
this.connect();
} catch {
this.emit("disconnect", -1);
}
}, this.ConnectTimeout);
}
#onMessage(e: WebSocket.MessageEvent) {
this.#activity = unixNowMs();
if ((e.data as string).length > 0) {
@ -332,14 +348,13 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.#expectAuth = true;
this.#log("Setting expectAuth flag %o", requestKinds);
}
if (this.ActiveRequests.size >= this.#maxSubscriptions) {
if (this.#activeRequests.size >= this.#maxSubscriptions) {
this.PendingRequests.push({
obj: cmd,
cb: cbSent,
});
this.#log("Queuing: %O", cmd);
} else {
this.ActiveRequests.add(cmd[1]);
this.#sendRequestCommand({
obj: cmd,
cb: cbSent,
@ -350,7 +365,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
}
closeReq(id: string) {
if (this.ActiveRequests.delete(id)) {
if (this.#activeRequests.delete(id)) {
this.send(["CLOSE", id]);
this.emit("eose", id);
this.#sendQueuedRequests();
@ -359,7 +374,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
}
#sendQueuedRequests() {
const canSend = this.#maxSubscriptions - this.ActiveRequests.size;
const canSend = this.#maxSubscriptions - this.#activeRequests.size;
if (canSend > 0) {
for (let x = 0; x < canSend; x++) {
const p = this.PendingRequests.shift();
@ -375,12 +390,12 @@ export class Connection extends EventEmitter<ConnectionEvents> {
try {
const cmd = item.obj;
if (cmd[0] === "REQ" || cmd[0] === "GET") {
this.ActiveRequests.add(cmd[1]);
this.#activeRequests.add(cmd[1]);
this.send(cmd);
} else if (cmd[0] === "SYNC") {
const [_, id, eventSet, ...filters] = cmd;
const lastResortSync = () => {
if (filters.some(a => a.since || a.until)) {
if (filters.some(a => a.since || a.until || a.ids)) {
this.queueReq(["REQ", id, ...filters], item.cb);
} else {
const latest = eventSet.reduce((acc, v) => (acc = v.created_at > acc ? v.created_at : acc), 0);
@ -391,7 +406,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.queueReq(["REQ", id, ...newFilters], item.cb);
}
};
if (this.Address.startsWith("wss://relay.snort.social")) {
if (this.Info?.negentropy === "v1") {
const newFilters = filters;
const neg = new NegentropyFlow(id, this, eventSet, newFilters);
neg.once("finish", filters => {
@ -419,19 +434,34 @@ export class Connection extends EventEmitter<ConnectionEvents> {
// reset connection Id on disconnect, for query-tracking
this.Id = uuid();
this.#expectAuth = false;
this.ActiveRequests.clear();
this.#log(
"Reset active=%O, pending=%O, raw=%O",
[...this.#activeRequests],
[...this.PendingRequests],
[...this.PendingRaw],
);
for (const active of this.#activeRequests) {
this.emit("closed", active, "connection closed");
}
for (const pending of this.PendingRequests) {
this.emit("closed", pending.obj[1], "connection closed");
}
for (const raw of this.PendingRaw) {
if (Array.isArray(raw) && raw[0] === "REQ") {
this.emit("closed", raw[1], "connection closed");
}
}
this.#activeRequests.clear();
this.PendingRequests = [];
this.PendingRaw = [];
this.emit("change");
}
send(obj: object) {
const authPending = !this.Authed && (this.AwaitingAuth.size > 0 || this.Info?.limitation?.auth_required === true);
if (!this.Socket || this.Socket?.readyState !== WebSocket.OPEN || authPending) {
if (!this.isOpen || authPending) {
this.PendingRaw.push(obj);
if (this.Socket?.readyState === WebSocket.CLOSED && this.Ephemeral && this.IsClosed) {
this.connect();
}
return false;
}
@ -503,11 +533,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
if (this.Ephemeral) {
this.#ephemeralCheck = setInterval(() => {
const lastActivity = unixNowMs() - this.#activity;
if (lastActivity > 30_000 && !this.IsClosed) {
if (this.ActiveRequests.size > 0) {
this.#log("Inactive connection has %d active requests! %O", this.ActiveRequests.size, this.ActiveRequests);
if (lastActivity > 30_000 && !this.#closing) {
if (this.#activeRequests.size > 0) {
this.#log(
"Inactive connection has %d active requests! %O",
this.#activeRequests.size,
this.#activeRequests,
);
} else {
this.close();
this.close(false);
}
}
}, 5_000);

View File

@ -1,4 +1,4 @@
import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner, PowMiner } from ".";
import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner, PowMiner, NotSignedNostrEvent } from ".";
import { HashtagRegex, MentionNostrEntityRegex } from "./const";
import { getPublicKey, jitter, unixNow } from "@snort/shared";
import { EventExt } from "./event-ext";
@ -14,6 +14,10 @@ export class EventBuilder {
#powMiner?: PowMiner;
#jitter?: number;
get pubkey() {
return this.#pubkey;
}
/**
* Populate builder with values from link
*/

View File

@ -11,6 +11,7 @@ export interface Tag {
value?: string;
relay?: string;
marker?: string; // NIP-10
author?: string; // NIP-10 "pubkey-stub"
}
export interface Thread {
@ -48,9 +49,6 @@ export abstract class EventExt {
const sig = secp.schnorr.sign(e.id, key);
e.sig = utils.bytesToHex(sig);
if (!secp.schnorr.verify(e.sig, e.id, e.pubkey)) {
throw new Error("Signing failed");
}
return e;
}
@ -102,10 +100,15 @@ export abstract class EventExt {
value: tag[1],
} as Tag;
switch (ret.key) {
case "a":
case "a": {
ret.relay = tag[2];
ret.marker = tag[3];
break;
}
case "e": {
ret.relay = tag.length > 2 ? tag[2] : undefined;
ret.marker = tag.length > 3 ? tag[3] : undefined;
ret.relay = tag[2];
ret.marker = tag[3];
ret.author = tag[4];
break;
}
}

View File

@ -15,6 +15,7 @@ import {
PowMiner,
PrivateKeySigner,
RelaySettings,
settingsToRelayTag,
SignerSupports,
TaggedNostrEvent,
ToNostrEventTag,
@ -23,10 +24,10 @@ import {
} from ".";
import { EventBuilder } from "./event-builder";
import { EventExt } from "./event-ext";
import { findTag } from "./utils";
import { Nip7Signer } from "./impl/nip7";
import { base64 } from "@scure/base";
import { Nip10 } from "./impl/nip10";
type EventBuilderHook = (ev: EventBuilder) => EventBuilder;
@ -202,29 +203,7 @@ export class EventPublisher {
const eb = this.#eb(EventKind.TextNote);
eb.content(msg);
const link = NostrLink.fromEvent(replyTo);
const thread = EventExt.extractThread(replyTo);
if (thread) {
const rootOrReplyAsRoot = thread.root || thread.replyTo;
if (rootOrReplyAsRoot) {
eb.tag([rootOrReplyAsRoot.key, rootOrReplyAsRoot.value ?? "", rootOrReplyAsRoot.relay ?? "", "root"]);
}
eb.tag([...unwrap(link.toEventTag()), "reply"]);
eb.tag(["p", replyTo.pubkey]);
for (const pk of thread.pubKeys) {
if (pk === this.#pubKey) {
continue;
}
eb.tag(["p", pk]);
}
} else {
eb.tag([...unwrap(link.toEventTag()), "root"]);
// dont tag self in replies
if (replyTo.pubkey !== this.#pubKey) {
eb.tag(["p", replyTo.pubkey]);
}
}
Nip10.replyTo(replyTo, eb);
eb.processContent();
fnExtra?.(eb);
return await this.#sign(eb);
@ -247,15 +226,9 @@ export class EventPublisher {
}
const eb = this.#eb(EventKind.Relays);
for (const rx of relays) {
const rTag = ["r", rx.url];
if (rx.settings.read && !rx.settings.write) {
rTag.push("read");
}
if (rx.settings.write && !rx.settings.read) {
rTag.push("write");
}
if (rx.settings.read || rx.settings.write) {
eb.tag(rTag);
const tag = settingsToRelayTag(rx);
if (tag) {
eb.tag(tag);
}
}
return await this.#sign(eb);

View File

@ -0,0 +1,73 @@
import { dedupe, unwrap } from "@snort/shared";
import { EventBuilder } from "../event-builder";
import { NostrEvent } from "../nostr";
import { NostrLink } from "../nostr-link";
export interface Nip10Thread {
root?: NostrLink;
replyTo?: NostrLink;
mentions: Array<NostrLink>;
pubKeys: Array<NostrLink>;
}
/**
* Utility class which exports functions used in NIP-10
*/
export class Nip10 {
/**
* Reply to an event using NIP-10 tagging
*/
static replyTo(ev: NostrEvent, eb: EventBuilder) {
const link = NostrLink.fromEvent(ev);
const thread = Nip10.parseThread(ev);
if (thread) {
const rootOrReplyAsRoot = thread.root || thread.replyTo;
if (rootOrReplyAsRoot) {
eb.tag(unwrap(rootOrReplyAsRoot.toEventTag("root")));
}
eb.tag(unwrap(link.toEventTag("reply")));
for (const pk of thread.pubKeys) {
if (pk.id === eb.pubkey) {
continue;
}
eb.tag(unwrap(pk.toEventTag()));
}
} else {
eb.tag(unwrap(link.toEventTag("root")));
if (ev.pubkey !== eb.pubkey) {
eb.tag(["p", ev.pubkey]);
}
}
}
static parseThread(ev: NostrEvent) {
const ret = {
mentions: [],
pubKeys: [],
} as Nip10Thread;
const replyTags = ev.tags.filter(a => a[0] === "e" || a[0] === "a").map(a => NostrLink.fromTag(a));
if (replyTags.length > 0) {
const marked = replyTags.some(a => a.marker);
if (!marked) {
ret.root = replyTags[0];
if (replyTags.length > 1) {
ret.replyTo = replyTags[replyTags.length - 1];
}
if (replyTags.length > 2) {
ret.mentions = replyTags.slice(1, -1);
}
} else {
const root = replyTags.find(a => a.marker === "root");
const reply = replyTags.find(a => a.marker === "reply");
ret.root = root;
ret.replyTo = reply;
ret.mentions = replyTags.filter(a => a.marker === "mention");
}
} else {
return undefined;
}
ret.pubKeys = dedupe(ev.tags.filter(a => a[0] === "p").map(a => a[1])).map(a => NostrLink.publicKey(a));
return ret;
}
}

View File

@ -1,10 +1,10 @@
import { decodeInvoice, InvoiceDetails } from "@snort/shared";
import { NostrEvent } from "./nostr";
import { findTag } from "./utils";
import { EventExt } from "./event-ext";
import { NostrLink } from "./nostr-link";
import debug from "debug";
import { LRUCache } from "lru-cache";
import { decodeInvoice, InvoiceDetails } from "@snort/shared";
import { NostrEvent } from "../nostr";
import { findTag } from "../utils";
import { NostrLink } from "../nostr-link";
import { Nip10 } from "./nip10";
const Log = debug("zaps");
const ParsedZapCache = new LRUCache<string, ParsedZap>({ max: 1000 });
@ -35,7 +35,7 @@ export function parseZap(zapReceipt: NostrEvent): ParsedZap {
// old format, ignored
throw new Error("deprecated zap format");
}
const zapRequestThread = EventExt.extractThread(zapRequest);
const zapRequestThread = Nip10.parseThread(zapRequest);
const requestContext = zapRequestThread?.root;
const anonZap = zapRequest.tags.find(a => a[0] === "anon");
@ -44,7 +44,7 @@ export function parseZap(zapReceipt: NostrEvent): ParsedZap {
id: zapReceipt.id,
zapService: zapReceipt.pubkey,
amount: (invoice?.amount ?? 0) / 1000,
event: requestContext ? NostrLink.fromThreadTag(requestContext) : undefined,
event: requestContext,
sender: zapRequest.pubkey,
receiver: findTag(zapRequest, "p"),
valid: true,

View File

@ -30,7 +30,7 @@ export * from "./event-publisher";
export * from "./event-builder";
export * from "./nostr-link";
export * from "./profile-cache";
export * from "./zaps";
export * from "./impl/nip57";
export * from "./signer";
export * from "./text";
export * from "./pow";
@ -39,11 +39,14 @@ export * from "./query-optimizer";
export * from "./encrypted";
export * from "./outbox";
export * from "./sync";
export * from "./user-state";
export * from "./impl/nip4";
export * from "./impl/nip44";
export * from "./impl/nip7";
export * from "./impl/nip10";
export * from "./impl/nip44";
export * from "./impl/nip46";
export * from "./impl/nip57";
export * from "./cache/index";
export * from "./cache/user-relays";

View File

@ -19,7 +19,7 @@ export interface ToNostrEventTag {
export class NostrHashtagLink implements ToNostrEventTag {
constructor(readonly tag: string) {}
toEventTag(): string[] | undefined {
toEventTag() {
return ["t", this.tag];
}
}
@ -31,6 +31,7 @@ export class NostrLink implements ToNostrEventTag {
readonly kind?: number,
readonly author?: string,
readonly relays?: Array<string>,
readonly marker?: string,
) {
if (type !== NostrPrefix.Address && !isHex(id)) {
throw new Error("ID must be hex");
@ -52,22 +53,43 @@ export class NostrLink implements ToNostrEventTag {
}
}
toEventTag(marker?: string) {
const relayEntry = this.relays?.at(0) ? [this.relays[0]] : [];
get tagKey() {
if (this.type === NostrPrefix.Address) {
return `${this.kind}:${this.author}:${this.id}`;
}
return this.id;
}
/**
* Create an event tag for this link
*/
toEventTag(marker?: string) {
const suffix: Array<string> = [];
if (this.relays && this.relays.length > 0) {
suffix.push(this.relays[0]);
}
if (marker) {
if (relayEntry.length === 0) {
relayEntry.push("");
if (suffix[0] === undefined) {
suffix.push(""); // empty relay hint
}
relayEntry.push(marker);
suffix.push(marker);
}
if (this.type === NostrPrefix.PublicKey || this.type === NostrPrefix.Profile) {
return ["p", this.id, ...relayEntry];
return ["p", this.id, ...suffix];
} else if (this.type === NostrPrefix.Note || this.type === NostrPrefix.Event) {
return ["e", this.id, ...relayEntry];
if (this.author) {
if (suffix[0] === undefined) {
suffix.push(""); // empty relay hint
}
if (suffix[1] === undefined) {
suffix.push(""); // empty marker
}
suffix.push(this.author);
}
return ["e", this.id, ...suffix];
} else if (this.type === NostrPrefix.Address) {
return ["a", `${this.kind}:${this.author}:${this.id}`, ...relayEntry];
return ["a", `${this.kind}:${this.author}:${this.id}`, ...suffix];
}
}
@ -162,46 +184,38 @@ export class NostrLink implements ToNostrEventTag {
}
}
static fromThreadTag(tag: Tag) {
const relay = tag.relay ? [tag.relay] : undefined;
switch (tag.key) {
case "e": {
return new NostrLink(NostrPrefix.Event, unwrap(tag.value), undefined, undefined, relay);
}
case "p": {
return new NostrLink(NostrPrefix.Profile, unwrap(tag.value), undefined, undefined, relay);
}
case "a": {
const [kind, author, dTag] = unwrap(tag.value).split(":");
return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relay);
}
}
throw new Error(`Unknown tag kind ${tag.key}`);
}
static fromTag(tag: Array<string>, author?: string, kind?: number) {
static fromTag<T = NostrLink>(
tag: Array<string>,
author?: string,
kind?: number,
fnOther?: (tag: Array<string>) => T,
) {
const relays = tag.length > 2 ? [tag[2]] : undefined;
switch (tag[0]) {
case "e": {
return new NostrLink(NostrPrefix.Event, tag[1], kind, author, relays);
return new NostrLink(NostrPrefix.Event, tag[1], kind, author ?? tag[4], relays, tag[3]);
}
case "p": {
return new NostrLink(NostrPrefix.Profile, tag[1], kind, author, relays);
}
case "a": {
const [kind, author, dTag] = tag[1].split(":");
return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relays);
return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relays, tag[3]);
}
default: {
if (fnOther) {
return fnOther(tag);
}
}
}
throw new Error(`Unknown tag kind ${tag[0]}`);
}
static fromTags(tags: Array<Array<string>>) {
static fromTags<T = NostrLink>(tags: ReadonlyArray<Array<string>>, fnOther?: (tag: Array<string>) => T) {
return removeUndefined(
tags.map(a => {
try {
return NostrLink.fromTag(a);
return NostrLink.fromTag<T>(a, undefined, undefined, fnOther);
} catch {
// ignored, cant be mapped
}
@ -218,6 +232,14 @@ export class NostrLink implements ToNostrEventTag {
}
return new NostrLink(NostrPrefix.Event, ev.id, ev.kind, ev.pubkey, relays);
}
static profile(pk: string, relays?: Array<string>) {
return new NostrLink(NostrPrefix.Profile, pk, undefined, undefined, relays);
}
static publicKey(pk: string, relays?: Array<string>) {
return new NostrLink(NostrPrefix.PublicKey, pk, undefined, undefined, relays);
}
}
export function validateNostrLink(link: string): boolean {

View File

@ -70,8 +70,7 @@ export class KeyedReplaceableNoteStore extends HookedNoteStore {
ev.forEach(a => {
const keyOnEvent = this.#keyFn(a);
const existing = this.#events.get(keyOnEvent);
const existingCreated = existing?.created_at ?? 0;
if (a.created_at > existingCreated) {
if (a.created_at > (existing?.created_at ?? 0)) {
if (existing) {
a.relays = dedupe([...existing.relays, ...a.relays]);
}

View File

@ -1,5 +1,5 @@
import { EventKind, FullRelaySettings, NostrEvent, SystemInterface, UsersRelays } from "..";
import { sanitizeRelayUrl } from "@snort/shared";
import { removeUndefined, sanitizeRelayUrl } from "@snort/shared";
export const DefaultPickNRelays = 2;
@ -20,6 +20,7 @@ export type EventFetcher = {
};
export function parseRelayTag(tag: Array<string>) {
if (tag[0] !== "r") return;
return {
url: sanitizeRelayUrl(tag[1]),
settings: {
@ -30,7 +31,7 @@ export function parseRelayTag(tag: Array<string>) {
}
export function parseRelayTags(tag: Array<Array<string>>) {
return tag.map(parseRelayTag).filter(a => a !== null);
return removeUndefined(tag.map(parseRelayTag));
}
export function parseRelaysFromKind(ev: NostrEvent) {
@ -54,5 +55,21 @@ export function parseRelaysFromKind(ev: NostrEvent) {
}
}
/**
* Convert relay settings into NIP-65 relay tag
*/
export function settingsToRelayTag(rx: FullRelaySettings) {
const rTag = ["r", rx.url];
if (rx.settings.read && !rx.settings.write) {
rTag.push("read");
}
if (rx.settings.write && !rx.settings.read) {
rTag.push("write");
}
if (rx.settings.read || rx.settings.write) {
return rTag;
}
}
export * from "./outbox-model";
export * from "./relay-loader";

View File

@ -182,6 +182,16 @@ export class OutboxModel extends BaseRequestRouter {
return ret;
}
async forReplyTo(pk: string, pickN?: number | undefined): Promise<string[]> {
const recipients = [pk];
await this.updateRelayLists(recipients);
const relays = this.pickTopRelays(recipients, pickN ?? DefaultPickNRelays, "read");
const ret = removeUndefined(dedupe(relays.map(a => a.relays).flat()));
this.#log("Picked: pattern=%s, input=%s, output=%O", "inbox", pk, ret);
return ret;
}
/**
* Update relay cache with latest relay lists
* @param authors The authors to update relay lists for

View File

@ -4,6 +4,7 @@ import { BuiltRawReqFilter, RequestBuilder, SystemInterface, TaggedNostrEvent }
import { Query, TraceReport } from "./query";
import { FilterCacheLayer } from "./filter-cache-layer";
import { trimFilters } from "./request-trim";
import { eventMatchesFilter } from "./request-matcher";
interface QueryManagerEvents {
change: () => void;
@ -75,26 +76,19 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
* Async fetch results
*/
async fetch(req: RequestBuilder, cb?: (evs: Array<TaggedNostrEvent>) => void) {
const q = new Query(this.#system, req);
q.on("trace", r => this.emit("trace", r));
q.on("request", (subId, fx) => {
this.#send(q, fx);
});
const filters = req.buildRaw();
const q = this.query(req);
if (cb) {
q.on("event", evs => cb(evs));
q.on("event", cb);
}
await new Promise<void>(resolve => {
q.on("loading", loading => {
this.#log("loading %s %o", q.id, loading);
if (!loading) {
resolve();
}
});
q.once("done", resolve);
});
const results = q.feed.takeSnapshot();
q.cleanup();
this.#log("Fetch results for %s %o", q.id, results);
return results;
if (cb) {
q.off("event", cb);
}
return results.filter(a => filters.some(b => eventMatchesFilter(a, b)));
}
*[Symbol.iterator]() {
@ -112,6 +106,7 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const data = await this.#system.cacheRelay.query(["REQ", q.id, ...qSend.filters]);
if (data.length > 0) {
qSend.syncFrom = data as Array<TaggedNostrEvent>;
this.#log("Adding from cache: %O", data);
q.feed.add(data as Array<TaggedNostrEvent>);
}
}
@ -138,6 +133,8 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const qt = q.sendToRelay(s, qSend);
if (qt) {
return [qt];
} else {
this.#log("Query not sent to %s: %O", qSend.relay, qSend);
}
} else {
const nc = await this.#system.pool.connect(qSend.relay, { read: true, write: true }, true);
@ -145,6 +142,8 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const qt = q.sendToRelay(nc, qSend);
if (qt) {
return [qt];
} else {
this.#log("Query not sent to %s: %O", qSend.relay, qSend);
}
} else {
console.warn("Failed to connect to new relay for:", qSend.relay, q);
@ -158,6 +157,8 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const qt = q.sendToRelay(s, qSend);
if (qt) {
ret.push(qt);
} else {
this.#log("Query not sent to %s: %O", a, qSend);
}
}
}

View File

@ -99,11 +99,11 @@ export interface TraceReport {
}
export interface QueryEvents {
loading: (v: boolean) => void;
trace: (report: TraceReport) => void;
request: (subId: string, req: BuiltRawReqFilter) => void;
event: (evs: Array<TaggedNostrEvent>) => void;
end: () => void;
done: () => void;
}
const QueryCache = new LRUCache<string, Array<TaggedNostrEvent>>({
@ -357,7 +357,7 @@ export class Query extends EventEmitter<QueryEvents> {
const isFinished = this.progress === 1;
if (isFinished) {
this.#log("%s loading=%s, progress=%d, traces=%O", this.id, !isFinished, this.progress, this.#tracing);
this.emit("loading", !isFinished);
this.emit("done");
}
}
@ -384,6 +384,10 @@ export class Query extends EventEmitter<QueryEvents> {
if (q.relay && q.relay !== c.Address) {
return false;
}
// connection is down, dont send
if (c.isDown) {
return false;
}
// cannot send unless relay is tagged on ephemeral relay connection
if (!q.relay && c.Ephemeral) {
this.#log("Cant send non-specific REQ to ephemeral connection %O %O %O", q, q.relay, c);

View File

@ -18,4 +18,5 @@ export interface RelayInfo {
language_tags?: Array<string>;
tags?: Array<string>;
posting_policy?: string;
negentropy?: string;
}

View File

@ -3,7 +3,7 @@ import { v4 as uuid } from "uuid";
import { appendDedupe, dedupe, removeUndefined, sanitizeRelayUrl, unixNowMs, unwrap } from "@snort/shared";
import EventKind from "./event-kind";
import { FlatReqFilter, NostrLink, NostrPrefix, SystemInterface } from ".";
import { FlatReqFilter, NostrLink, NostrPrefix, SystemInterface, ToNostrEventTag } from ".";
import { ReqFilter, u256, HexKey, TaggedNostrEvent } from "./nostr";
import { RequestRouter } from "./request-router";
@ -230,6 +230,19 @@ export class RequestFilterBuilder {
return this;
}
/**
* Query by a nostr tag
*/
tags(tags: Array<ToNostrEventTag>) {
for (const tag of tags) {
const tt = tag.toEventTag();
if (tt) {
this.tag(tt[0], [tt[1]]);
}
}
return this;
}
search(keyword?: string) {
if (!keyword) return this;
this.#filter.search = keyword;

View File

@ -6,6 +6,15 @@ import { FlatReqFilter } from "./query-optimizer";
* Request router managed splitting of requests to one or more relays, and which relay to send events to.
*/
export interface RequestRouter {
/**
* Pick relays to send an event to
* @param pk The pubkey you are replying to
* @param system Nostr system interface
* @param pickN Number of relays to pick per recipient
* @returns
*/
forReplyTo(pk: string, pickN?: number): Promise<Array<string>>;
/**
* Pick relays to send an event to
* @param ev The reply event to send
@ -39,6 +48,7 @@ export interface RequestRouter {
}
export abstract class BaseRequestRouter implements RequestRouter {
abstract forReplyTo(pk: string, pickN?: number): Promise<Array<string>>;
abstract forReply(ev: NostrEvent, pickN?: number): Promise<Array<string>>;
abstract forRequest(filter: ReqFilter, pickN?: number): Array<ReqFilter>;
abstract forFlatRequest(filter: FlatReqFilter[], pickN?: number): Array<FlatReqFilter>;

View File

@ -4,7 +4,7 @@ import { EventExt } from "./event-ext";
import { Nip4WebCryptoEncryptor } from "./impl/nip4";
import { XChaCha20Encryptor } from "./impl/nip44";
import { MessageEncryptorVersion, decodeEncryptionPayload, encodeEncryptionPayload } from "./index";
import { NostrEvent } from "./nostr";
import { NostrEvent, NotSignedNostrEvent } from "./nostr";
import { base64 } from "@scure/base";
export type SignerSupports = "nip04" | "nip44" | string;
@ -16,7 +16,7 @@ export interface EventSigner {
nip4Decrypt(content: string, otherKey: string): Promise<string>;
nip44Encrypt(content: string, key: string): Promise<string>;
nip44Decrypt(content: string, otherKey: string): Promise<string>;
sign(ev: NostrEvent): Promise<NostrEvent>;
sign(ev: NostrEvent | NotSignedNostrEvent): Promise<NostrEvent>;
get supports(): Array<SignerSupports>;
}

View File

@ -1,100 +1,210 @@
import { EventBuilder, EventSigner, NostrLink, SystemInterface } from "..";
import { SafeSync } from "./safe-sync";
import { EventEmitter } from "eventemitter3";
import { EventBuilder, EventSigner, NostrEvent, NostrLink, NotSignedNostrEvent, SystemInterface, Tag } from "..";
import { SafeSync, SafeSyncEvents } from "./safe-sync";
import debug from "debug";
interface TagDiff {
type: "add" | "remove" | "replace";
type: "add" | "remove" | "replace" | "update";
tag: Array<string> | Array<Array<string>>;
}
/**
* Add/Remove tags from event
*/
export class DiffSyncTags {
export class DiffSyncTags extends EventEmitter<SafeSyncEvents> {
#log = debug("DiffSyncTags");
#sync = new SafeSync();
#sync: SafeSync;
#changes: Array<TagDiff> = [];
#changesEncrypted: Array<TagDiff> = [];
#decryptedContent?: string;
constructor(readonly link: NostrLink) {}
constructor(readonly link: NostrLink) {
super();
this.#sync = new SafeSync(link);
this.#sync.on("change", () => {
this.emit("change");
});
}
/**
* Get the raw storage event
*/
get value() {
return this.#sync.value;
}
/**
* Get the current tag set
*/
get tags() {
const next = this.#nextEvent();
return next.tags;
}
/**
* Get decrypted content
*/
get encryptedTags() {
if (this.#decryptedContent) {
const tags = JSON.parse(this.#decryptedContent) as Array<Array<string>>;
return tags;
}
return [];
}
/**
* Add a tag
*/
add(tag: Array<string> | Array<Array<string>>) {
this.#changes.push({
add(tag: Array<string> | Array<Array<string>>, encrypted = false) {
(encrypted ? this.#changesEncrypted : this.#changes).push({
type: "add",
tag,
});
this.emit("change");
}
/**
* Remove a tag
*/
remove(tag: Array<string> | Array<Array<string>>) {
this.#changes.push({
remove(tag: Array<string> | Array<Array<string>>, encrypted = false) {
(encrypted ? this.#changesEncrypted : this.#changes).push({
type: "remove",
tag,
});
this.emit("change");
}
/**
* Update a tag (remove+add)
*/
update(tag: Array<string> | Array<Array<string>>, encrypted = false) {
(encrypted ? this.#changesEncrypted : this.#changes).push({
type: "update",
tag,
});
this.emit("change");
}
/**
* Replace all the tags
*/
replace(tag: Array<Array<string>>) {
this.#changes.push({
replace(tag: Array<Array<string>>, encrypted = false) {
(encrypted ? this.#changesEncrypted : this.#changes).push({
type: "replace",
tag,
});
this.emit("change");
}
async sync(signer: EventSigner, system: SystemInterface) {
await this.#sync.sync(system);
if (
this.#sync.value?.content &&
this.#sync.value?.content.startsWith("[") &&
this.#sync.value?.content.endsWith("]")
) {
const decrypted = await signer.nip4Decrypt(this.#sync.value.content, await signer.getPubKey());
this.#decryptedContent = decrypted;
}
}
/**
* Apply changes and save
*/
async persist(signer: EventSigner, system: SystemInterface, content?: string) {
const cloneChanges = [...this.#changes];
this.#changes = [];
if (!this.#sync.didSync) {
await this.sync(signer, system);
}
// always start with sync
const res = await this.#sync.sync(this.link, system);
const isNew = this.#sync.value === undefined;
const next = this.#nextEvent(content);
// content is populated as tags, encrypt it
if (next.content.length > 0 && !content) {
next.content = await signer.nip4Encrypt(next.content, await signer.getPubKey());
}
await this.#sync.update(next, signer, system, !isNew);
}
#nextEvent(content?: string): NotSignedNostrEvent {
if (content !== undefined && this.#changesEncrypted.length > 0) {
throw new Error("Cannot have both encrypted tags and explicit content");
}
let isNew = false;
let next = res ? { ...res } : undefined;
let next = this.#sync.value ? { ...this.#sync.value } : undefined;
if (!next) {
const eb = new EventBuilder();
eb.fromLink(this.link);
next = eb.build();
isNew = true;
}
if (content) {
// apply changes onto next
this.#applyChanges(next.tags, this.#changes);
if (this.#changesEncrypted.length > 0 && !content) {
const encryptedTags = isNew ? [] : this.encryptedTags;
this.#applyChanges(encryptedTags, this.#changesEncrypted);
next.content = JSON.stringify(encryptedTags);
} else if (content) {
next.content = content;
}
// apply changes onto next
for (const change of cloneChanges) {
for (const changeTag of Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>)
: [change.tag as Array<string>]) {
const existing = next.tags.findIndex(a => a.every((b, i) => changeTag[i] === b));
switch (change.type) {
case "add": {
return next;
}
#applyChanges(tags: Array<Array<string>>, changes: Array<TagDiff>) {
for (const change of changes) {
if (change.tag.length === 0 && change.type !== "replace") continue;
switch (change.type) {
case "add": {
const changeTags = Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>)
: [change.tag as Array<string>];
for (const changeTag of changeTags) {
const existing = tags.findIndex(a => change.tag[0] === a[0] && change.tag[1] === a[1]);
if (existing === -1) {
next.tags.push(changeTag);
tags.push(changeTag);
} else {
this.#log("Tag already exists: %O", changeTag);
}
break;
}
case "remove": {
break;
}
case "remove": {
const changeTags = Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>)
: [change.tag as Array<string>];
for (const changeTag of changeTags) {
const existing = tags.findIndex(a => change.tag[0] === a[0] && change.tag[1] === a[1]);
if (existing !== -1) {
next.tags.splice(existing, 1);
tags.splice(existing, 1);
} else {
this.#log("Could not find tag to remove: %O", changeTag);
}
}
break;
}
case "update": {
const changeTags = Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>)
: [change.tag as Array<string>];
for (const changeTag of changeTags) {
const existing = tags.findIndex(a => change.tag[0] === a[0] && change.tag[1] === a[1]);
if (existing !== -1) {
tags[existing] = changeTag;
} else {
this.#log("Could not find tag to update: %O", changeTag);
}
}
break;
}
case "replace": {
tags.splice(0, tags.length);
tags.push(...(change.tag as Array<Array<string>>));
break;
}
}
}
await this.#sync.update(next, signer, system, !isNew);
}
}

View File

@ -1,7 +1,3 @@
export interface HasId {
id: string;
}
export * from "./safe-sync";
export * from "./range-sync";
export * from "./json-in-event-sync";

View File

@ -1,14 +1,9 @@
import { SafeSync } from "./safe-sync";
import { HasId } from ".";
import { EventBuilder, EventSigner, NostrEvent, NostrLink, NostrPrefix, SystemInterface } from "..";
import { SafeSync, SafeSyncEvents } from "./safe-sync";
import { EventBuilder, EventSigner, NostrEvent, NostrLink, SystemInterface } from "..";
import debug from "debug";
import EventEmitter from "eventemitter3";
export interface JsonSyncEvents {
change: () => void;
}
export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents> {
export class JsonEventSync<T> extends EventEmitter<SafeSyncEvents> {
#log = debug("JsonEventSync");
#sync: SafeSync;
#json: T;
@ -19,7 +14,7 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
readonly encrypt: boolean,
) {
super();
this.#sync = new SafeSync();
this.#sync = new SafeSync(link);
this.#json = initValue;
this.#sync.on("change", () => this.emit("change"));
@ -31,7 +26,7 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
}
async sync(signer: EventSigner, system: SystemInterface) {
const res = await this.#sync.sync(this.link, system);
const res = await this.#sync.sync(system);
this.#log("Sync result %O", res);
if (res) {
if (this.encrypt) {
@ -71,6 +66,5 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
await this.#sync.update(next, signer, system, !isNew);
this.#json = val;
this.#json.id = next.id;
}
}

View File

@ -1,5 +1,14 @@
import EventEmitter from "eventemitter3";
import { EventExt, EventSigner, EventType, NostrEvent, NostrLink, RequestBuilder, SystemInterface } from "..";
import {
EventExt,
EventSigner,
EventType,
NostrEvent,
NostrLink,
NotSignedNostrEvent,
RequestBuilder,
SystemInterface,
} from "..";
import { unixNow } from "@snort/shared";
import debug from "debug";
@ -21,6 +30,10 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
#base: NostrEvent | undefined;
#didSync = false;
constructor(readonly link: NostrLink) {
super();
}
get value() {
return this.#base ? Object.freeze({ ...this.#base }) : undefined;
}
@ -31,14 +44,13 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
/**
* Fetch the latest version
* @param link A link to the kind
*/
async sync(link: NostrLink, system: SystemInterface) {
if (link.kind === undefined || link.author === undefined) {
async sync(system: SystemInterface) {
if (this.link.kind === undefined || this.link.author === undefined) {
throw new Error("Kind must be set");
}
return await this.#sync(link, system);
return await this.#sync(system);
}
/**
@ -57,15 +69,23 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
* Event will be signed again inside
* @param ev
*/
async update(next: NostrEvent, signer: EventSigner, system: SystemInterface, mustExist?: boolean) {
next.id = "";
next.sig = "";
async update(
next: NostrEvent | NotSignedNostrEvent,
signer: EventSigner,
system: SystemInterface,
mustExist?: boolean,
) {
if ("sig" in next) {
next.id = "";
next.sig = "";
}
console.debug(this.#base, next);
const signed = await this.#signEvent(next, signer);
const link = NostrLink.fromEvent(signed);
// always attempt to get a newer version before broadcasting
await this.#sync(link, system);
await this.#sync(system);
this.#checkForUpdate(signed, mustExist ?? true);
system.BroadcastEvent(signed);
@ -73,28 +93,21 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
this.emit("change");
}
async #signEvent(next: NostrEvent, signer: EventSigner) {
next.created_at = unixNow();
if (this.#base) {
const prevTag = next.tags.find(a => a[0] === "previous");
if (prevTag) {
prevTag[1] = this.#base.id;
} else {
next.tags.push(["previous", this.#base.id]);
}
}
next.id = EventExt.createId(next);
return await signer.sign(next);
async #signEvent(next: NotSignedNostrEvent, signer: EventSigner) {
const toSign = { ...next, id: "", sig: "" } as NostrEvent;
toSign.created_at = unixNow();
toSign.id = EventExt.createId(toSign);
return await signer.sign(toSign);
}
async #sync(link: NostrLink, system: SystemInterface) {
const rb = new RequestBuilder(`sync:${link.encode()}`);
const f = rb.withFilter().link(link);
async #sync(system: SystemInterface) {
const rb = new RequestBuilder("sync");
const f = rb.withFilter().link(this.link);
if (this.#base) {
f.since(this.#base.created_at);
}
const results = await system.Fetch(rb);
const res = results.find(a => link.matchesEvent(a));
const res = results.find(a => this.link.matchesEvent(a));
this.#log("Got result %O", res);
if (res && res.created_at > (this.#base?.created_at ?? 0)) {
this.#base = res;

Some files were not shown because too many files have changed in this diff Show More