feat: safe sync appdata

This commit is contained in:
kieran 2024-04-15 13:23:26 +01:00
parent a089ae2ec6
commit edf64e4125
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
41 changed files with 386 additions and 211 deletions

View File

@ -4,14 +4,12 @@ import { MixCloudRegex } from "@/Utils/Const";
const MixCloudEmbed = ({ link }: { link: string }) => { const MixCloudEmbed = ({ link }: { link: string }) => {
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2); const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
const theme = useLogin(s => s.appData.item.preferences.theme); const theme = useLogin(s => s.appData.json.preferences.theme);
const lightParams = theme === "light" ? "light=1" : "light=0"; const lightParams = theme === "light" ? "light=1" : "light=0";
return ( return (
<> <>
<br /> <br />
<iframe <iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
title="SoundCloud player" title="SoundCloud player"
width="100%" width="100%"
height="120" height="120"

View File

@ -22,7 +22,7 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
for (const pk of ids) { for (const pk of ids) {
try { try {
const profile = await UserCache.get(pk); const profile = await UserCache.get(pk);
const amtSend = login.appData.item.preferences.defaultZapAmount; const amtSend = login.appData.json.preferences.defaultZapAmount;
const lnurl = profile?.lud16 || profile?.lud06; const lnurl = profile?.lud16 || profile?.lud06;
if (lnurl) { if (lnurl) {
const svc = new LNURL(lnurl); const svc = new LNURL(lnurl);
@ -74,7 +74,7 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
defaultMessage="Zap all {n} sats" defaultMessage="Zap all {n} sats"
id="IVbtTS" id="IVbtTS"
values={{ values={{
n: <FormattedNumber value={login.appData.item.preferences.defaultZapAmount * ids.length} />, n: <FormattedNumber value={login.appData.json.preferences.defaultZapAmount * ids.length} />,
}} }}
/> />
</AsyncButton> </AsyncButton>

View File

@ -56,7 +56,7 @@ const quoteNoteOptions = {
export function NoteCreator() { export function NoteCreator() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const uploader = useFileUpload(); const uploader = useFileUpload();
const login = useLogin(s => ({ relays: s.relays, publicKey: s.publicKey, pow: s.appData.item.preferences.pow })); const login = useLogin(s => ({ relays: s.relays, publicKey: s.publicKey, pow: s.appData.json.preferences.pow }));
const { system, publisher: pub } = useEventPublisher(); const { system, publisher: pub } = useEventPublisher();
const publisher = login.pow ? pub?.pow(login.pow, GetPowWorker()) : pub; const publisher = login.pow ? pub?.pow(login.pow, GetPowWorker()) : pub;
const note = useNoteCreator(); const note = useNoteCreator();

View File

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

View File

@ -78,7 +78,7 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
useEffect(() => { useEffect(() => {
const sub = getCurrentSubscription(login.subscriptions); const sub = getCurrentSubscription(login.subscriptions);
if (sub?.type === SubscriptionType.Premium && (login.appData.item.preferences.autoTranslate ?? true)) { if (sub?.type === SubscriptionType.Premium && (login.appData.json.preferences.autoTranslate ?? true)) {
translate(); translate();
} }
}, []); }, []);

View File

@ -30,7 +30,7 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
} = useLogin(s => ({ } = useLogin(s => ({
publicKey: s.publicKey, publicKey: s.publicKey,
readonly: s.readonly, readonly: s.readonly,
preferences: s.appData.item.preferences, preferences: s.appData.json.preferences,
})); }));
const walletState = useWallet(); const walletState = useWallet();
const wallet = walletState.wallet; const wallet = walletState.wallet;

View File

@ -26,7 +26,7 @@ export default function NoteFooter(props: NoteFooterProps) {
const { positive } = reactions; const { positive } = reactions;
const { preferences: prefs, readonly } = useLogin(s => ({ const { preferences: prefs, readonly } = useLogin(s => ({
preferences: s.appData.item.preferences, preferences: s.appData.json.preferences,
publicKey: s.publicKey, publicKey: s.publicKey,
readonly: s.readonly, readonly: s.readonly,
})); }));

View File

@ -16,7 +16,7 @@ export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: T
const navigate = useNavigate(); const navigate = useNavigate();
const { publisher, system } = useEventPublisher(); const { publisher, system } = useEventPublisher();
const { publicKey, preferences: prefs } = useLogin(s => ({ const { publicKey, preferences: prefs } = useLogin(s => ({
preferences: s.appData.item.preferences, preferences: s.appData.json.preferences,
publicKey: s.publicKey, publicKey: s.publicKey,
})); }));
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote })); const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));

View File

@ -53,7 +53,7 @@ export const NoteText = memo(function InnerContent(
</> </>
); );
if (!appData.item.showContentWarningPosts) { if (!appData.json.showContentWarningPosts) {
const contentWarning = ev.tags.find(a => a[0] === "content-warning"); const contentWarning = ev.tags.find(a => a[0] === "content-warning");
if (contentWarning) { if (contentWarning) {
return ( return (

View File

@ -27,7 +27,7 @@ export default function Poll(props: PollProps) {
preferences: prefs, preferences: prefs,
publicKey: myPubKey, publicKey: myPubKey,
relays, relays,
} = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, relays: s.relays })); } = useLogin(s => ({ preferences: s.appData.json.preferences, publicKey: s.publicKey, relays: s.relays }));
const pollerProfile = useUserProfile(props.ev.pubkey); const pollerProfile = useUserProfile(props.ev.pubkey);
const [tallyBy, setTallyBy] = useState<PollTally>("pubkeys"); const [tallyBy, setTallyBy] = useState<PollTally>("pubkeys");
const [error, setError] = useState(""); const [error, setError] = useState("");

View File

@ -17,7 +17,7 @@ interface RevealMediaProps {
export default function RevealMedia(props: RevealMediaProps) { export default function RevealMedia(props: RevealMediaProps) {
const { preferences, follows, publicKey } = useLogin(s => ({ const { preferences, follows, publicKey } = useLogin(s => ({
preferences: s.appData.item.preferences, preferences: s.appData.json.preferences,
follows: s.follows.item, follows: s.follows.item,
publicKey: s.publicKey, publicKey: s.publicKey,
})); }));

View File

@ -19,7 +19,7 @@ export function RootTabs({ base = "/" }: { base: string }) {
} = useLogin(s => ({ } = useLogin(s => ({
publicKey: s.publicKey, publicKey: s.publicKey,
tags: s.tags, tags: s.tags,
preferences: s.appData.item.preferences, preferences: s.appData.json.preferences,
})); }));
const menuItems = useMemo(() => rootTabItems(base, pubKey, tags), [base, pubKey, tags]); const menuItems = useMemo(() => rootTabItems(base, pubKey, tags), [base, pubKey, tags]);

View File

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

View File

@ -104,8 +104,8 @@ export function LoginUnlock() {
const newPin = await PinEncrypted.create(k, pin); const newPin = await PinEncrypted.create(k, pin);
const pub = EventPublisher.privateKey(k); const pub = EventPublisher.privateKey(k);
if (login.appData.item.preferences.pow) { if (login.appData.json.preferences.pow) {
pub.pow(login.appData.item.preferences.pow, GetPowWorker()); pub.pow(login.appData.json.preferences.pow, GetPowWorker());
} }
LoginStore.setPublisher(login.id, pub); LoginStore.setPublisher(login.id, pub);
LoginStore.updateSession({ LoginStore.updateSession({
@ -121,8 +121,8 @@ export function LoginUnlock() {
await key.unlock(pin); await key.unlock(pin);
const pub = createPublisher(login); const pub = createPublisher(login);
if (pub) { if (pub) {
if (login.appData.item.preferences.pow) { if (login.appData.json.preferences.pow) {
pub.pow(login.appData.item.preferences.pow, GetPowWorker()); pub.pow(login.appData.json.preferences.pow, GetPowWorker());
} }
LoginStore.setPublisher(login.id, pub); LoginStore.setPublisher(login.id, pub);
LoginStore.updateSession({ LoginStore.updateSession({

View File

@ -22,7 +22,7 @@ export function ZapModalInput(props: {
onNextStage: (v: SendSatsInputSelection) => Promise<void>; onNextStage: (v: SendSatsInputSelection) => Promise<void>;
}) { }) {
const { defaultZapAmount, readonly } = useLogin(s => ({ const { defaultZapAmount, readonly } = useLogin(s => ({
defaultZapAmount: s.appData.item.preferences.defaultZapAmount, defaultZapAmount: s.appData.json.preferences.defaultZapAmount,
readonly: s.readonly, readonly: s.readonly,
})); }));
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();

View File

@ -1,17 +1,15 @@
import { EventKind, NostrLink, parseRelayTags, RequestBuilder, TaggedNostrEvent } from "@snort/system"; import { EventKind, NostrLink, NostrPrefix, parseRelayTags, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { usePrevious } from "@uidotdev/usehooks";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Nip28ChatSystem } from "@/chat/nip28"; import { Nip28ChatSystem } from "@/chat/nip28";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { bech32ToHex, debounce, getNewest, getNewestEventTagsByKey, unwrap } from "@/Utils"; import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "@/Utils";
import { SnortPubKey } from "@/Utils/Const"; import { SnortPubKey } from "@/Utils/Const";
import { import {
addSubscription, addSubscription,
LoginStore, LoginStore,
setAppData,
setBlocked, setBlocked,
setBookmarked, setBookmarked,
setFollows, setFollows,
@ -19,7 +17,6 @@ import {
setPinned, setPinned,
setRelays, setRelays,
setTags, setTags,
SnortAppData,
} from "@/Utils/Login"; } from "@/Utils/Login";
import { SubscriptionEvent } from "@/Utils/Subscription"; import { SubscriptionEvent } from "@/Utils/Subscription";
/** /**
@ -31,22 +28,15 @@ export default function useLoginFeed() {
const { publisher, system } = useEventPublisher(); const { publisher, system } = useEventPublisher();
useEffect(() => { useEffect(() => {
system.checkSigs = login.appData.item.preferences.checkSigs; if (login.appData.json) {
}, [login]); system.checkSigs = login.appData.json.preferences.checkSigs;
const previous = usePrevious(login.appData.item); if (publisher) {
// write appdata after 10s of no changes const link = new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, pubKey);
useEffect(() => { login.appData.sync(link, publisher.signer, system);
if (!previous || JSON.stringify(previous) === JSON.stringify(login.appData.item)) {
return;
}
return debounce(10_000, async () => {
if (publisher && login.appData.item) {
const ev = await publisher.appData(login.appData.item, "snort");
await system.BroadcastEvent(ev);
} }
}); }
}, [previous]); }, [login, publisher]);
const subLogin = useMemo(() => { const subLogin = useMemo(() => {
if (!login || !pubKey) return null; if (!login || !pubKey) return null;
@ -68,7 +58,6 @@ export default function useLoginFeed() {
EventKind.DirectMessage, EventKind.DirectMessage,
]); ]);
if (CONFIG.features.subscriptions && !login.readonly) { if (CONFIG.features.subscriptions && !login.readonly) {
b.withFilter().authors([pubKey]).kinds([EventKind.AppData]).tag("d", ["snort"]);
b.withFilter() b.withFilter()
.relay("wss://relay.snort.social/") .relay("wss://relay.snort.social/")
.kinds([EventKind.SnortSubscriptions]) .kinds([EventKind.SnortSubscriptions])
@ -113,13 +102,6 @@ export default function useLoginFeed() {
} }
}), }),
).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap))); ).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap)));
const appData = getNewest(loginFeed.filter(a => a.kind === EventKind.AppData));
if (appData) {
publisher.decryptGeneric(appData.content, appData.pubkey).then(d => {
setAppData(login, JSON.parse(d) as SnortAppData, appData.created_at * 1000);
});
}
} }
} }
}, [loginFeed, publisher]); }, [loginFeed, publisher]);

View File

@ -30,7 +30,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
window: options.window, window: options.window,
now: options.now ?? unixNow(), now: options.now ?? unixNow(),
}); });
const pref = useLogin(s => s.appData.item.preferences); const pref = useLogin(s => s.appData.json.preferences);
const { isEventMuted } = useModeration(); const { isEventMuted } = useModeration();
const createBuilder = useCallback(() => { const createBuilder = useCallback(() => {

View File

@ -11,7 +11,7 @@ export interface ImgProxySettings {
} }
export default function useImgProxy() { export default function useImgProxy() {
const settings = useLogin(s => s.appData.item.preferences.imgProxyConfig); const settings = useLogin(s => s.appData.json.preferences.imgProxyConfig);
return { return {
proxy: (url: string, resize?: number, sha256?: string) => proxyImg(url, settings, resize, sha256), proxy: (url: string, resize?: number, sha256?: string) => proxyImg(url, settings, resize, sha256),

View File

@ -58,11 +58,11 @@ export default function useModeration() {
} }
function isMutedWord(word: string) { function isMutedWord(word: string) {
return appData.item.mutedWords.includes(word.toLowerCase()); return appData.json.mutedWords.includes(word.toLowerCase());
} }
function isEventMuted(ev: TaggedNostrEvent | NostrEvent) { function isEventMuted(ev: TaggedNostrEvent | NostrEvent) {
return isMuted(ev.pubkey) || appData.item.mutedWords.some(w => ev.content.toLowerCase().includes(w)); return isMuted(ev.pubkey) || appData.json.mutedWords.some(w => ev.content.toLowerCase().includes(w));
} }
return { return {

View File

@ -0,0 +1,16 @@
import { updatePreferences, 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 }));
const { system } = useEventPublisher();
return {
preferences: pref,
update: async (data: UserPreferences) => {
await updatePreferences(id, data, system);
},
};
}

View File

@ -3,7 +3,7 @@ import { useEffect } from "react";
import useLogin from "./useLogin"; import useLogin from "./useLogin";
export function useTheme() { export function useTheme() {
const { preferences } = useLogin(s => ({ preferences: s.appData.item.preferences })); const { preferences } = useLogin(s => ({ preferences: s.appData.json.preferences }));
function setTheme(theme: "light" | "dark") { function setTheme(theme: "light" | "dark") {
const elm = document.documentElement; const elm = document.documentElement;

View File

@ -40,7 +40,7 @@ export function SnortDeckLayout() {
const login = useLogin(s => ({ const login = useLogin(s => ({
publicKey: s.publicKey, publicKey: s.publicKey,
subscriptions: s.subscriptions, subscriptions: s.subscriptions,
telemetry: s.appData.item.preferences.telemetry, telemetry: s.appData.json.preferences.telemetry,
})); }));
const navigate = useNavigate(); const navigate = useNavigate();
const [deckState, setDeckState] = useState<DeckState>({ const [deckState, setDeckState] = useState<DeckState>({

View File

@ -27,7 +27,7 @@ export default function Index() {
const { id, stalker, telemetry } = useLogin(s => ({ const { id, stalker, telemetry } = useLogin(s => ({
id: s.id, id: s.id,
stalker: s.stalker ?? false, stalker: s.stalker ?? false,
telemetry: s.appData.item.preferences.telemetry, telemetry: s.appData.json.preferences.telemetry,
})); }));
useTheme(); useTheme();

View File

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

View File

@ -32,8 +32,8 @@ const ProfileDetails = ({
}) => { }) => {
const follows = useFollowsFeed(id); const follows = useFollowsFeed(id);
const { showStatus, showBadges } = useLogin(s => ({ const { showStatus, showBadges } = useLogin(s => ({
showStatus: s.appData.item.preferences.showStatus ?? false, showStatus: s.appData.json.preferences.showStatus ?? false,
showBadges: s.appData.item.preferences.showBadges ?? false, showBadges: s.appData.json.preferences.showBadges ?? false,
})); }));
const [showLnQr, setShowLnQr] = useState<boolean>(false); const [showLnQr, setShowLnQr] = useState<boolean>(false);
const badges = useProfileBadges(showBadges ? id : undefined); const badges = useProfileBadges(showBadges ? id : undefined);

View File

@ -3,7 +3,7 @@ import { RootTabRoutes } from "@/Pages/Root/RootTabRoutes";
export const DefaultTab = () => { export const DefaultTab = () => {
const { preferences, publicKey } = useLogin(s => ({ const { preferences, publicKey } = useLogin(s => ({
preferences: s.appData.item.preferences, preferences: s.appData.json.preferences,
publicKey: s.publicKey, publicKey: s.publicKey,
})); }));
const tab = publicKey ? preferences.defaultRootTab : `trending/notes`; const tab = publicKey ? preferences.defaultRootTab : `trending/notes`;

View File

@ -66,7 +66,7 @@ export function ZapPoolPageInner() {
values={{ values={{
number: ( number: (
<b> <b>
<FormattedNumber value={login.appData.item.preferences.defaultZapAmount} /> <FormattedNumber value={login.appData.json.preferences.defaultZapAmount} />
</b> </b>
), ),
}} }}
@ -79,13 +79,13 @@ export function ZapPoolPageInner() {
values={{ values={{
nIn: ( nIn: (
<b> <b>
<FormattedNumber value={login.appData.item.preferences.defaultZapAmount} /> <FormattedNumber value={login.appData.json.preferences.defaultZapAmount} />
</b> </b>
), ),
nOut: ( nOut: (
<b> <b>
<FormattedNumber <FormattedNumber
value={ZapPoolController?.calcAllocation(login.appData.item.preferences.defaultZapAmount) ?? 0} value={ZapPoolController?.calcAllocation(login.appData.json.preferences.defaultZapAmount) ?? 0}
/> />
</b> </b>
), ),

View File

@ -9,7 +9,7 @@ function ZapPoolTargetInner({ target }: { target: ZapPoolRecipient }) {
const login = useLogin(); const login = useLogin();
const profile = useUserProfile(target.pubkey); const profile = useUserProfile(target.pubkey);
const hasAddress = profile?.lud16 || profile?.lud06; const hasAddress = profile?.lud16 || profile?.lud06;
const defaultZapMount = Math.ceil(login.appData.item.preferences.defaultZapAmount * (target.split / 100)); const defaultZapMount = Math.ceil(login.appData.json.preferences.defaultZapAmount * (target.split / 100));
return ( return (
<ProfilePreview <ProfilePreview
pubkey={target.pubkey} pubkey={target.pubkey}

View File

@ -1,10 +1,10 @@
import { unixNowMs } from "@snort/shared";
import { useState } from "react"; import { useState } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import AsyncButton from "@/Components/Button/AsyncButton"; import AsyncButton from "@/Components/Button/AsyncButton";
import { ToggleSwitch } from "@/Components/Icons/Toggle"; import { ToggleSwitch } from "@/Components/Icons/Toggle";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { FixedModeration } from "@/Pages/onboarding/fixedModeration"; import { FixedModeration } from "@/Pages/onboarding/fixedModeration";
import { appendDedupe } from "@/Utils"; import { appendDedupe } from "@/Utils";
@ -15,6 +15,7 @@ export function Moderation() {
const [topics, setTopics] = useState<Array<string>>(Object.keys(FixedModeration)); const [topics, setTopics] = useState<Array<string>>(Object.keys(FixedModeration));
const [extraTerms, setExtraTerms] = useState(""); const [extraTerms, setExtraTerms] = useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const { system } = useEventPublisher();
return ( return (
<div className="flex flex-col g24"> <div className="flex flex-col g24">
@ -81,15 +82,10 @@ export function Moderation() {
.filter(a => a.length > 1), .filter(a => a.length > 1),
); );
if (words.length > 0) { if (words.length > 0) {
updateAppData(id, ad => { updateAppData(id, system, ad => ({
return { ...ad,
item: { mutedWords: appendDedupe(ad.mutedWords, words),
...ad, }));
mutedWords: appendDedupe(ad.mutedWords, words),
},
timestamp: unixNowMs(),
};
});
} }
navigate("/"); navigate("/");
}}> }}>

View File

@ -1,7 +1,7 @@
import { unixNowMs } from "@snort/shared";
import { useState } from "react"; import { useState } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { appendDedupe } from "@/Utils"; import { appendDedupe } from "@/Utils";
import { SnortAppData, updateAppData } from "@/Utils/Login"; import { SnortAppData, updateAppData } from "@/Utils/Login";
@ -9,36 +9,28 @@ import { SnortAppData, updateAppData } from "@/Utils/Login";
export default function ModerationSettingsPage() { export default function ModerationSettingsPage() {
const login = useLogin(); const login = useLogin();
const [muteWord, setMuteWord] = useState(""); const [muteWord, setMuteWord] = useState("");
const appData = login.appData.item; const appData = login.appData.json;
const { system } = useEventPublisher();
function addMutedWord() { function addMutedWord() {
updateAppData(login.id, ad => ({ updateAppData(login.id, system, ad => ({
item: { ...ad,
...ad, mutedWords: appendDedupe(appData.mutedWords, [muteWord]),
mutedWords: appendDedupe(appData.mutedWords, [muteWord]),
},
timestamp: unixNowMs(),
})); }));
setMuteWord(""); setMuteWord("");
} }
const handleToggle = (setting: keyof SnortAppData) => { const handleToggle = (setting: keyof SnortAppData) => {
updateAppData(login.id, ad => ({ updateAppData(login.id, system, ad => ({
item: { ...ad,
...ad, [setting]: !appData[setting],
[setting]: !appData[setting],
},
timestamp: unixNowMs(),
})); }));
}; };
function removeMutedWord(word: string) { function removeMutedWord(word: string) {
updateAppData(login.id, ad => ({ updateAppData(login.id, system, ad => ({
item: { ...ad,
...ad, mutedWords: appData.mutedWords.filter(a => a !== word),
mutedWords: appData.mutedWords.filter(a => a !== word),
},
timestamp: unixNowMs(),
})); }));
setMuteWord(""); setMuteWord("");
} }

View File

@ -1,36 +1,52 @@
/* eslint-disable max-lines */
import "./Preferences.css"; import "./Preferences.css";
import { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import { AllLanguageCodes } from "@/Components/IntlProvider/IntlProviderUtils"; import { AllLanguageCodes } from "@/Components/IntlProvider/IntlProviderUtils";
import { useLocale } from "@/Components/IntlProvider/useLocale"; import usePreferences from "@/Hooks/usePreferences";
import useLogin from "@/Hooks/useLogin";
import { unwrap } from "@/Utils"; import { unwrap } from "@/Utils";
import { DefaultImgProxy } from "@/Utils/Const"; import { DefaultImgProxy } from "@/Utils/Const";
import { updatePreferences, UserPreferences } from "@/Utils/Login"; import { UserPreferences } from "@/Utils/Login";
import messages from "./messages"; import messages from "./messages";
const PreferencesPage = () => { const PreferencesPage = () => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { id, pref } = useLogin(s => ({ id: s.id, pref: s.appData.item.preferences })); const { preferences, update: updatePerf } = usePreferences();
const { lang } = useLocale(); const [pref, setPref] = useState<UserPreferences>(preferences);
const [error, setError] = useState("");
async function update(obj: UserPreferences) {
try {
setError("");
await updatePerf(obj);
} catch (e) {
console.error(e);
setError(formatMessage({ defaultMessage: "Failed to update, please try again", id: "OoZgbB" }));
}
}
return ( return (
<div className="preferences flex flex-col g24"> <div className="preferences flex flex-col g24">
<h3> <h3>
<FormattedMessage {...messages.Preferences} /> <FormattedMessage defaultMessage="Preferences" id="PCSt5T" />
</h3> </h3>
<AsyncButton onClick={() => update(pref)}>
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
</AsyncButton>
{error && <b className="warning">{error}</b>}
<div className="flex justify-between w-max"> <div className="flex justify-between w-max">
<h4> <h4>
<FormattedMessage defaultMessage="Language" id="y1Z3or" /> <FormattedMessage defaultMessage="Language" id="y1Z3or" />
</h4> </h4>
<div> <div>
<select <select
value={lang} value={pref.language}
onChange={e => onChange={e =>
updatePreferences(id, { setPref({
...pref, ...pref,
language: e.target.value, language: e.target.value,
}) })
@ -54,7 +70,7 @@ const PreferencesPage = () => {
<select <select
value={pref.theme} value={pref.theme}
onChange={e => onChange={e =>
updatePreferences(id, { setPref({
...pref, ...pref,
theme: e.target.value, theme: e.target.value,
} as UserPreferences) } as UserPreferences)
@ -79,7 +95,7 @@ const PreferencesPage = () => {
<select <select
value={pref.defaultRootTab} value={pref.defaultRootTab}
onChange={e => onChange={e =>
updatePreferences(id, { setPref({
...pref, ...pref,
defaultRootTab: e.target.value, defaultRootTab: e.target.value,
} as UserPreferences) } as UserPreferences)
@ -112,7 +128,7 @@ const PreferencesPage = () => {
<input <input
type="checkbox" type="checkbox"
checked={pref.telemetry ?? true} checked={pref.telemetry ?? true}
onChange={e => updatePreferences(id, { ...pref, telemetry: e.target.checked })} onChange={e => setPref({ ...pref, telemetry: e.target.checked })}
/> />
</div> </div>
</div> </div>
@ -129,7 +145,7 @@ const PreferencesPage = () => {
className="w-max" className="w-max"
value={pref.autoLoadMedia} value={pref.autoLoadMedia}
onChange={e => onChange={e =>
updatePreferences(id, { setPref({
...pref, ...pref,
autoLoadMedia: e.target.value, autoLoadMedia: e.target.value,
} as UserPreferences) } as UserPreferences)
@ -160,7 +176,7 @@ const PreferencesPage = () => {
<input <input
type="checkbox" type="checkbox"
checked={pref.checkSigs} checked={pref.checkSigs}
onChange={e => updatePreferences(id, { ...pref, checkSigs: e.target.checked })} onChange={e => setPref({ ...pref, checkSigs: e.target.checked })}
/> />
</div> </div>
</div> </div>
@ -177,7 +193,7 @@ const PreferencesPage = () => {
<input <input
type="checkbox" type="checkbox"
checked={pref.autoTranslate} checked={pref.autoTranslate}
onChange={e => updatePreferences(id, { ...pref, autoTranslate: e.target.checked })} onChange={e => setPref({ ...pref, autoTranslate: e.target.checked })}
/> />
</div> </div>
</div> </div>
@ -195,7 +211,7 @@ const PreferencesPage = () => {
type="number" type="number"
defaultValue={pref.pow} defaultValue={pref.pow}
min={0} min={0}
onChange={e => updatePreferences(id, { ...pref, pow: parseInt(e.target.value || "0") })} onChange={e => setPref({ ...pref, pow: parseInt(e.target.value || "0") })}
/> />
</div> </div>
</div> </div>
@ -208,7 +224,7 @@ const PreferencesPage = () => {
type="number" type="number"
defaultValue={pref.defaultZapAmount} defaultValue={pref.defaultZapAmount}
min={1} min={1}
onChange={e => updatePreferences(id, { ...pref, defaultZapAmount: parseInt(e.target.value || "0") })} onChange={e => setPref({ ...pref, defaultZapAmount: parseInt(e.target.value || "0") })}
/> />
</div> </div>
</div> </div>
@ -225,7 +241,7 @@ const PreferencesPage = () => {
<input <input
type="checkbox" type="checkbox"
checked={pref.showBadges ?? false} checked={pref.showBadges ?? false}
onChange={e => updatePreferences(id, { ...pref, showBadges: e.target.checked })} onChange={e => setPref({ ...pref, showBadges: e.target.checked })}
/> />
</div> </div>
</div> </div>
@ -242,7 +258,7 @@ const PreferencesPage = () => {
<input <input
type="checkbox" type="checkbox"
checked={pref.showStatus ?? true} checked={pref.showStatus ?? true}
onChange={e => updatePreferences(id, { ...pref, showStatus: e.target.checked })} onChange={e => setPref({ ...pref, showStatus: e.target.checked })}
/> />
</div> </div>
</div> </div>
@ -259,7 +275,7 @@ const PreferencesPage = () => {
<input <input
type="checkbox" type="checkbox"
checked={pref.autoZap} checked={pref.autoZap}
onChange={e => updatePreferences(id, { ...pref, autoZap: e.target.checked })} onChange={e => setPref({ ...pref, autoZap: e.target.checked })}
/> />
</div> </div>
</div> </div>
@ -278,7 +294,7 @@ const PreferencesPage = () => {
type="checkbox" type="checkbox"
checked={pref.imgProxyConfig !== null} checked={pref.imgProxyConfig !== null}
onChange={e => onChange={e =>
updatePreferences(id, { setPref({
...pref, ...pref,
imgProxyConfig: e.target.checked ? DefaultImgProxy : undefined, imgProxyConfig: e.target.checked ? DefaultImgProxy : undefined,
}) })
@ -302,7 +318,7 @@ const PreferencesPage = () => {
description: "Placeholder text for imgproxy url textbox", description: "Placeholder text for imgproxy url textbox",
})} })}
onChange={e => onChange={e =>
updatePreferences(id, { setPref({
...pref, ...pref,
imgProxyConfig: { imgProxyConfig: {
...unwrap(pref.imgProxyConfig), ...unwrap(pref.imgProxyConfig),
@ -327,7 +343,7 @@ const PreferencesPage = () => {
description: "Hexidecimal 'key' input for improxy", description: "Hexidecimal 'key' input for improxy",
})} })}
onChange={e => onChange={e =>
updatePreferences(id, { setPref({
...pref, ...pref,
imgProxyConfig: { imgProxyConfig: {
...unwrap(pref.imgProxyConfig), ...unwrap(pref.imgProxyConfig),
@ -352,7 +368,7 @@ const PreferencesPage = () => {
description: "Hexidecimal 'salt' input for imgproxy", description: "Hexidecimal 'salt' input for imgproxy",
})} })}
onChange={e => onChange={e =>
updatePreferences(id, { setPref({
...pref, ...pref,
imgProxyConfig: { imgProxyConfig: {
...unwrap(pref.imgProxyConfig), ...unwrap(pref.imgProxyConfig),
@ -379,7 +395,7 @@ const PreferencesPage = () => {
<input <input
type="checkbox" type="checkbox"
checked={pref.enableReactions} checked={pref.enableReactions}
onChange={e => updatePreferences(id, { ...pref, enableReactions: e.target.checked })} onChange={e => setPref({ ...pref, enableReactions: e.target.checked })}
/> />
</div> </div>
</div> </div>
@ -395,7 +411,7 @@ const PreferencesPage = () => {
value={pref.reactionEmoji} value={pref.reactionEmoji}
onChange={e => { onChange={e => {
const split = e.target.value.match(/[\p{L}\S]{1}/u); const split = e.target.value.match(/[\p{L}\S]{1}/u);
updatePreferences(id, { setPref({
...pref, ...pref,
reactionEmoji: split?.[0] ?? "", reactionEmoji: split?.[0] ?? "",
}); });
@ -415,7 +431,7 @@ const PreferencesPage = () => {
<input <input
type="checkbox" type="checkbox"
checked={pref.confirmReposts} checked={pref.confirmReposts}
onChange={e => updatePreferences(id, { ...pref, confirmReposts: e.target.checked })} onChange={e => setPref({ ...pref, confirmReposts: e.target.checked })}
/> />
</div> </div>
</div> </div>
@ -432,7 +448,7 @@ const PreferencesPage = () => {
<input <input
type="checkbox" type="checkbox"
checked={pref.autoShowLatest} checked={pref.autoShowLatest}
onChange={e => updatePreferences(id, { ...pref, autoShowLatest: e.target.checked })} onChange={e => setPref({ ...pref, autoShowLatest: e.target.checked })}
/> />
</div> </div>
</div> </div>
@ -446,7 +462,7 @@ const PreferencesPage = () => {
<select <select
value={pref.fileUploader} value={pref.fileUploader}
onChange={e => onChange={e =>
updatePreferences(id, { setPref({
...pref, ...pref,
fileUploader: e.target.value, fileUploader: e.target.value,
} as UserPreferences) } as UserPreferences)
@ -473,7 +489,7 @@ const PreferencesPage = () => {
<input <input
type="checkbox" type="checkbox"
checked={pref.showDebugMenus} checked={pref.showDebugMenus}
onChange={e => updatePreferences(id, { ...pref, showDebugMenus: e.target.checked })} onChange={e => setPref({ ...pref, showDebugMenus: e.target.checked })}
/> />
</div> </div>
</div> </div>
@ -490,10 +506,14 @@ const PreferencesPage = () => {
<input <input
type="checkbox" type="checkbox"
checked={pref.hideMutedNotes} checked={pref.hideMutedNotes}
onChange={e => updatePreferences(id, { ...pref, hideMutedNotes: e.target.checked })} onChange={e => setPref({ ...pref, hideMutedNotes: e.target.checked })}
/> />
</div> </div>
</div> </div>
<AsyncButton onClick={() => update(pref)}>
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
</AsyncButton>
{error && <b className="error">{error}</b>}
</div> </div>
); );
}; };

View File

@ -16,7 +16,7 @@ import { GiftsCache } from "@/Cache";
import SnortApi from "@/External/SnortApi"; import SnortApi from "@/External/SnortApi";
import { bech32ToHex, dedupeById, deleteRefCode, getCountry, sanitizeRelayUrl, unwrap } from "@/Utils"; import { bech32ToHex, dedupeById, deleteRefCode, getCountry, sanitizeRelayUrl, unwrap } from "@/Utils";
import { Blasters } from "@/Utils/Const"; import { Blasters } from "@/Utils/Const";
import { LoginSession, LoginSessionType, LoginStore, Newest, SnortAppData, UserPreferences } from "@/Utils/Login/index"; import { LoginSession, LoginSessionType, LoginStore, SnortAppData, UserPreferences } from "@/Utils/Login/index";
import { entropyToPrivateKey, generateBip39Entropy } from "@/Utils/nip6"; import { entropyToPrivateKey, generateBip39Entropy } from "@/Utils/nip6";
import { SubscriptionEvent } from "@/Utils/Subscription"; import { SubscriptionEvent } from "@/Utils/Subscription";
@ -56,12 +56,9 @@ export function removeRelay(state: LoginSession, addr: string) {
LoginStore.updateSession(state); LoginStore.updateSession(state);
} }
export function updatePreferences(id: string, p: UserPreferences) { export async function updatePreferences(id: string, p: UserPreferences, system: SystemInterface) {
updateAppData(id, d => { await updateAppData(id, system, d => {
return { return { ...d, preferences: p };
item: { ...d, preferences: p },
timestamp: unixNowMs(),
};
}); });
} }
@ -206,23 +203,19 @@ export function setBookmarked(state: LoginSession, bookmarked: Array<string>, ts
LoginStore.updateSession(state); LoginStore.updateSession(state);
} }
export function setAppData(state: LoginSession, data: SnortAppData, ts: number) { export async function setAppData(state: LoginSession, data: SnortAppData, system: SystemInterface) {
if (state.appData.timestamp >= ts) { const pub = LoginStore.getPublisher(state.id);
return; if (!pub) return;
}
state.appData.item = data; await state.appData.updateJson(data, pub.signer, system);
state.appData.timestamp = ts;
LoginStore.updateSession(state); LoginStore.updateSession(state);
} }
export function updateAppData(id: string, fn: (data: SnortAppData) => Newest<SnortAppData>) { export async function updateAppData(id: string, system: SystemInterface, fn: (data: SnortAppData) => SnortAppData) {
const session = LoginStore.get(id); const session = LoginStore.get(id);
if (session) { if (session) {
const next = fn(session.appData.item); const next = fn(session.appData.json);
if (next.timestamp > session.appData.timestamp) { await setAppData(session, next, system);
session.appData = next;
LoginStore.updateSession(session);
}
} }
} }

View File

@ -1,4 +1,4 @@
import { HexKey, KeyStorage, RelaySettings, u256 } from "@snort/system"; import { HexKey, JsonEventSync, KeyStorage, RelaySettings } from "@snort/system";
import { DisplayAs } from "@/Components/Feed/DisplayAsSelector"; import { DisplayAs } from "@/Components/Feed/DisplayAsSelector";
import { UserPreferences } from "@/Utils/Login/index"; import { UserPreferences } from "@/Utils/Login/index";
@ -21,6 +21,7 @@ export const enum LoginSessionType {
} }
export interface SnortAppData { export interface SnortAppData {
id: string;
mutedWords: Array<string>; mutedWords: Array<string>;
showContentWarningPosts: boolean; showContentWarningPosts: boolean;
preferences: UserPreferences; preferences: UserPreferences;
@ -81,12 +82,12 @@ export interface LoginSession {
/** /**
* A list of event ids this user has pinned * A list of event ids this user has pinned
*/ */
pinned: Newest<Array<u256>>; pinned: Newest<Array<string>>;
/** /**
* A list of event ids this user has bookmarked * A list of event ids this user has bookmarked
*/ */
bookmarked: Newest<Array<u256>>; bookmarked: Newest<Array<string>>;
/** /**
* A list of pubkeys this user has muted * A list of pubkeys this user has muted
@ -116,7 +117,7 @@ export interface LoginSession {
/** /**
* Snort application data * Snort application data
*/ */
appData: Newest<SnortAppData>; appData: JsonEventSync<SnortAppData>;
/** /**
* A list of chats which we have joined (NIP-28/NIP-29) * A list of chats which we have joined (NIP-28/NIP-29)

View File

@ -1,12 +1,20 @@
import * as utils from "@noble/curves/abstract/utils"; import * as utils from "@noble/curves/abstract/utils";
import * as secp from "@noble/curves/secp256k1"; import * as secp from "@noble/curves/secp256k1";
import { ExternalStore, unwrap } from "@snort/shared"; import { ExternalStore, unwrap } from "@snort/shared";
import { EventPublisher, HexKey, KeyStorage, NotEncrypted, RelaySettings, socialGraphInstance } from "@snort/system"; import {
EventPublisher,
HexKey,
JsonEventSync,
KeyStorage,
NotEncrypted,
RelaySettings,
socialGraphInstance,
} from "@snort/system";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { createPublisher, LoginSession, LoginSessionType } from "@/Utils/Login/index"; import { createPublisher, LoginSession, LoginSessionType, SnortAppData } from "@/Utils/Login/index";
import { DefaultPreferences, UserPreferences } from "./Preferences"; import { DefaultPreferences } from "./Preferences";
const AccountStoreKey = "sessions"; const AccountStoreKey = "sessions";
const LoggedOut = { const LoggedOut = {
@ -44,14 +52,15 @@ const LoggedOut = {
latestNotification: 0, latestNotification: 0,
readNotifications: 0, readNotifications: 0,
subscriptions: [], subscriptions: [],
appData: { appData: new JsonEventSync<SnortAppData>(
item: { {
mutedWords: [], id: "",
preferences: DefaultPreferences, preferences: DefaultPreferences,
mutedWords: [],
showContentWarningPosts: false, showContentWarningPosts: false,
}, },
timestamp: 0, true,
}, ),
extraChats: [], extraChats: [],
stalker: false, stalker: false,
} as LoginSession; } as LoginSession;
@ -87,19 +96,14 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (v.type === LoginSessionType.PrivateKey && v.readonly) { if (v.type === LoginSessionType.PrivateKey && v.readonly) {
v.readonly = false; v.readonly = false;
} }
// fill possibly undefined (migrate up)
v.appData ??= {
item: {
mutedWords: [],
showContentWarningPosts: false,
preferences: DefaultPreferences,
},
timestamp: 0,
};
v.extraChats ??= []; v.extraChats ??= [];
if (v.privateKeyData) { if (v.privateKeyData) {
v.privateKeyData = KeyStorage.fromPayload(v.privateKeyData as object); v.privateKeyData = KeyStorage.fromPayload(v.privateKeyData as object);
} }
v.appData = new JsonEventSync<SnortAppData>(v.appData as unknown as SnortAppData, true);
v.appData.on("change", () => {
this.#save();
});
} }
this.#loadIrisKeyIfExists(); this.#loadIrisKeyIfExists();
} }
@ -163,10 +167,18 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
item: initRelays, item: initRelays,
timestamp: 1, timestamp: 1,
}, },
preferences: { appData: new JsonEventSync<SnortAppData>(
...DefaultPreferences, {
...CONFIG.defaultPreferences, id: "",
}, preferences: {
...DefaultPreferences,
...CONFIG.defaultPreferences,
},
mutedWords: [],
showContentWarningPosts: false,
},
true,
),
remoteSignerRelays, remoteSignerRelays,
privateKeyData: privateKey, privateKeyData: privateKey,
stalker: stalker ?? false, stalker: stalker ?? false,
@ -209,10 +221,18 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
item: initRelays, item: initRelays,
timestamp: 1, timestamp: 1,
}, },
preferences: { appData: new JsonEventSync<SnortAppData>(
...DefaultPreferences, {
...CONFIG.defaultPreferences, id: "",
}, preferences: {
...DefaultPreferences,
...CONFIG.defaultPreferences,
},
mutedWords: [],
showContentWarningPosts: false,
},
true,
),
} as LoginSession; } as LoginSession;
if ("nostr_os" in window && window?.nostr_os) { if ("nostr_os" in window && window?.nostr_os) {
@ -271,41 +291,10 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
#migrate() { #migrate() {
let didMigrate = false; let didMigrate = false;
// update session types for (const [, acc] of this.#accounts) {
for (const [, v] of this.#accounts) { if ("item" in acc.appData) {
if (!v.type) {
v.type = v.privateKey ? LoginSessionType.PrivateKey : LoginSessionType.Nip7;
didMigrate = true;
}
}
// add ids
for (const [, v] of this.#accounts) {
if ((v.id?.length ?? 0) === 0) {
v.id = uuid();
didMigrate = true;
}
}
// mark readonly
for (const [, v] of this.#accounts) {
if (v.type === LoginSessionType.PublicKey && !v.readonly) {
v.readonly = true;
didMigrate = true;
}
// reset readonly on load
if (v.type === LoginSessionType.PrivateKey && v.readonly) {
v.readonly = false;
didMigrate = true;
}
}
// move preferences to appdata
for (const [, v] of this.#accounts) {
if ("preferences" in v) {
v.appData.item.preferences = v.preferences as UserPreferences;
delete v["preferences"];
didMigrate = true; didMigrate = true;
acc.appData = new JsonEventSync<SnortAppData>(acc.appData.item as SnortAppData, true);
} }
} }
@ -324,10 +313,14 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (v.privateKeyData instanceof KeyStorage) { if (v.privateKeyData instanceof KeyStorage) {
toSave.push({ toSave.push({
...v, ...v,
appData: v.appData.json,
privateKeyData: v.privateKeyData.toPayload(), privateKeyData: v.privateKeyData.toPayload(),
}); });
} else { } else {
toSave.push({ ...v }); toSave.push({
...v,
appData: v.appData.json,
});
} }
} }

View File

@ -65,7 +65,7 @@ export interface UploadProgress {
export type UploadStage = "starting" | "hashing" | "uploading" | "done" | undefined; export type UploadStage = "starting" | "hashing" | "uploading" | "done" | undefined;
export default function useFileUpload(): Uploader { export default function useFileUpload(): Uploader {
const fileUploader = useLogin(s => s.appData.item.preferences.fileUploader); const fileUploader = useLogin(s => s.appData.json.preferences.fileUploader);
const { publisher } = useEventPublisher(); const { publisher } = useEventPublisher();
const [progress, setProgress] = useState<Array<UploadProgress>>([]); const [progress, setProgress] = useState<Array<UploadProgress>>([]);
const [stage, setStage] = useState<UploadStage>(); const [stage, setStage] = useState<UploadStage>();

View File

@ -537,7 +537,7 @@ export function trackEvent(
if ( if (
!import.meta.env.DEV && !import.meta.env.DEV &&
CONFIG.features.analytics && CONFIG.features.analytics &&
(LoginStore.snapshot().appData.item.preferences.telemetry ?? true) (LoginStore.snapshot().appData.json.preferences.telemetry ?? true)
) { ) {
fetch("https://pa.v0l.io/api/event", { fetch("https://pa.v0l.io/api/event", {
method: "POST", method: "POST",

View File

@ -38,7 +38,7 @@ export * from "./pow-util";
export * from "./query-optimizer"; export * from "./query-optimizer";
export * from "./encrypted"; export * from "./encrypted";
export * from "./outbox"; export * from "./outbox";
export * from "./range-sync"; export * from "./sync";
export * from "./impl/nip4"; export * from "./impl/nip4";
export * from "./impl/nip44"; export * from "./impl/nip44";

View File

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

View File

@ -0,0 +1,76 @@
import { SafeSync } from "./safe-sync";
import { HasId } from ".";
import { EventExt, EventSigner, NostrEvent, NostrLink, SystemInterface } from "..";
import debug from "debug";
import EventEmitter from "eventemitter3";
import { unixNow } from "@snort/shared";
export interface JsonSyncEvents {
change: () => void;
}
export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents> {
#log = debug("JsonEventSync");
#sync: SafeSync;
#json: T;
constructor(
initValue: T,
readonly encrypt: boolean,
) {
super();
this.#sync = new SafeSync();
this.#json = initValue;
this.#sync.on("change", () => this.emit("change"));
}
get json(): Readonly<T> {
const ret = { ...this.#json };
return Object.freeze(ret);
}
async sync(link: NostrLink, signer: EventSigner, system: SystemInterface) {
const res = await this.#sync.sync(link, system);
this.#log("Sync result %O", res);
if (res) {
if (this.encrypt) {
this.#json = JSON.parse(await signer.nip4Decrypt(res.content, await signer.getPubKey())) as T;
} else {
this.#json = JSON.parse(res.content) as T;
}
}
}
/**
* Update the json content in the event
* @param val
* @param signer
*/
async updateJson(val: T, signer: EventSigner, system: SystemInterface) {
this.#log("Updating: %O", val);
const next = this.#sync.value ? ({ ...this.#sync.value } as NostrEvent) : undefined;
if (!next) {
throw new Error("Cannot update with no previous value");
}
next.content = JSON.stringify(val);
next.created_at = unixNow();
const prevTag = next.tags.find(a => a[0] === "previous");
if (prevTag) {
prevTag[1] = next.id;
} else {
next.tags.push(["previous", next.id]);
}
if (this.encrypt) {
next.content = await signer.nip4Encrypt(next.content, await signer.getPubKey());
}
next.id = EventExt.createId(next);
const signed = await signer.sign(next);
await this.#sync.update(signed, system);
this.#json = val;
this.#json.id = next.id;
}
}

View File

@ -1,6 +1,6 @@
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { ReqFilter, RequestBuilder, SystemInterface, TaggedNostrEvent } from "."; import { ReqFilter, RequestBuilder, SystemInterface, TaggedNostrEvent } from "..";
/** /**
* When nostr was created * When nostr was created

View File

@ -0,0 +1,101 @@
import EventEmitter from "eventemitter3";
import { EventExt, EventType, NostrEvent, NostrLink, RequestBuilder, SystemInterface } from "..";
export interface SafeSyncEvents {
change: () => void;
}
/**
* Safely sync replacable events to nostr
*
* Usefule for the following critical kinds:
* 0 (Metadata)
* 3 (Contacts)
* 10002 (Relays)
* 30078 (AppData)
*/
export class SafeSync extends EventEmitter<SafeSyncEvents> {
#base: NostrEvent | undefined;
get value() {
return this.#base;
}
/**
* Fetch the latest version
* @param link A link to the kind
*/
async sync(link: NostrLink, system: SystemInterface) {
if (link.kind === undefined) {
throw new Error("Kind must be set");
}
const rb = new RequestBuilder("sync");
const f = rb.withFilter().link(link);
if (this.#base) {
f.since(this.#base.created_at);
}
const results = await system.Fetch(rb);
const res = results.find(a => link.matchesEvent(a));
if (res && res.created_at > (this.#base?.created_at ?? 0)) {
this.#base = res;
this.emit("change");
}
return this.#base;
}
/**
* Set the base value
* @param ev
*/
setBase(ev: NostrEvent) {
this.#checkForUpdate(ev, false);
this.#base = ev;
this.emit("change");
}
/**
* Publish an update for this event
* @param ev
*/
async update(ev: NostrEvent, system: SystemInterface) {
console.debug(this.#base, ev);
this.#checkForUpdate(ev, true);
const link = NostrLink.fromEvent(ev);
// always attempt to get a newer version before broadcasting
await this.sync(link, system);
this.#checkForUpdate(ev, true);
system.BroadcastEvent(ev);
this.#base = ev;
this.emit("change");
}
#checkForUpdate(ev: NostrEvent, mustExist: boolean) {
if (!this.#base) {
if (mustExist) {
throw new Error("No previous version detected");
} else {
return;
}
}
const prevTag = ev.tags.find(a => a[0] === "previous");
if (prevTag && prevTag[1] !== this.#base.id) {
throw new Error("Previous tag does not match our version");
}
if (
EventExt.getType(ev.kind) !== EventType.Replaceable &&
EventExt.getType(ev.kind) !== EventType.ParameterizedReplaceable
) {
throw new Error("Not a replacable event kind");
}
if (this.#base.created_at >= ev.created_at) {
throw new Error("Same version, cannot update");
}
const link = NostrLink.fromEvent(ev);
if (!link.matchesEvent(this.#base)) {
throw new Error("Invalid event");
}
}
}