From 5a7657a95da5f190d0abc482934e39b4c4187f1b Mon Sep 17 00:00:00 2001 From: kieran Date: Mon, 15 Apr 2024 22:31:51 +0100 Subject: [PATCH] feat: diff-sync follows --- .../app/src/Components/Event/RevealMedia.tsx | 7 +- .../src/Components/Feed/TimelineFollows.tsx | 10 +- .../src/Components/LiveStream/LiveStreams.tsx | 10 +- .../app/src/Components/SuggestedProfiles.tsx | 11 +- .../app/src/Components/User/Following.tsx | 6 +- packages/app/src/Feed/LoginFeed.ts | 19 ++-- packages/app/src/Hooks/useFollowControls.ts | 44 ++++---- packages/app/src/Hooks/usePreferences.ts | 6 +- packages/app/src/Pages/ListFeedPage.tsx | 17 +-- .../app/src/Pages/Messages/NewChatWindow.tsx | 6 +- packages/app/src/Pages/Root/ForYouTab.tsx | 26 ++--- .../app/src/Pages/settings/Preferences.tsx | 4 +- .../settings/tools/follows-relay-health.tsx | 6 +- .../Pages/settings/tools/prune-follows.tsx | 9 +- packages/app/src/Utils/Login/Functions.ts | 17 +-- packages/app/src/Utils/Login/LoginSession.ts | 2 +- .../app/src/Utils/Login/MultiAccountStore.ts | 25 +++-- packages/app/src/index.tsx | 3 +- packages/system/src/event-builder.ts | 17 ++- packages/system/src/nostr-link.ts | 5 +- packages/system/src/request-builder.ts | 7 +- packages/system/src/sync/diff-sync.ts | 100 ++++++++++++++++++ packages/system/src/sync/index.ts | 1 + .../system/src/sync/json-in-event-sync.ts | 34 +++--- packages/system/src/sync/safe-sync.ts | 82 +++++++++----- 25 files changed, 320 insertions(+), 154 deletions(-) create mode 100644 packages/system/src/sync/diff-sync.ts diff --git a/packages/app/src/Components/Event/RevealMedia.tsx b/packages/app/src/Components/Event/RevealMedia.tsx index 4bdfd8dd..73fd85c2 100644 --- a/packages/app/src/Components/Event/RevealMedia.tsx +++ b/packages/app/src/Components/Event/RevealMedia.tsx @@ -4,6 +4,7 @@ import { Link } from "react-router-dom"; import { MediaElement } from "@/Components/Embed/MediaElement"; import Reveal from "@/Components/Event/Reveal"; +import useFollowsControls from "@/Hooks/useFollowControls"; import useLogin from "@/Hooks/useLogin"; import { FileExtensionRegex } from "@/Utils/Const"; @@ -16,13 +17,13 @@ interface RevealMediaProps { } export default function RevealMedia(props: RevealMediaProps) { - const { preferences, follows, publicKey } = useLogin(s => ({ + const { preferences, publicKey } = useLogin(s => ({ preferences: s.appData.json.preferences, - follows: s.follows.item, publicKey: s.publicKey, })); + const { isFollowing } = useFollowsControls(); - const hideNonFollows = preferences.autoLoadMedia === "follows-only" && !follows.includes(props.creator); + const hideNonFollows = preferences.autoLoadMedia === "follows-only" && !isFollowing(props.creator); const isMine = props.creator === publicKey; const hideMedia = preferences.autoLoadMedia === "none" || (!isMine && hideNonFollows); const hostname = new URL(props.link).hostname; diff --git a/packages/app/src/Components/Feed/TimelineFollows.tsx b/packages/app/src/Components/Feed/TimelineFollows.tsx index d336fd02..72c3dfb0 100644 --- a/packages/app/src/Components/Feed/TimelineFollows.tsx +++ b/packages/app/src/Components/Feed/TimelineFollows.tsx @@ -7,6 +7,7 @@ import { Link } from "react-router-dom"; import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector"; import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer"; import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed"; +import useFollowsControls from "@/Hooks/useFollowControls"; import useHistoryState from "@/Hooks/useHistoryState"; import useLogin from "@/Hooks/useLogin"; import { dedupeByPubkey } from "@/Utils"; @@ -29,11 +30,12 @@ const TimelineFollows = (props: TimelineFollowsProps) => { const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list"; const [displayAs, setDisplayAs] = useState(displayAsInitial); const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt"); + const { isFollowing, followList } = useFollowsControls(); const subject = useMemo( () => ({ type: "pubkey", - items: login.follows.item, + items: followList, discriminator: login.publicKey?.slice(0, 12), extra: rb => { if (login.tags.item.length > 0) { @@ -41,7 +43,7 @@ const TimelineFollows = (props: TimelineFollowsProps) => { } }, }) as TimelineSubject, - [login.follows.item, login.tags.item], + [followList, login.tags.item], ); const feed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions); @@ -57,9 +59,9 @@ const TimelineFollows = (props: TimelineFollowsProps) => { return a ?.filter(postsOnly) .filter(a => props.noteFilter?.(a) ?? true) - .filter(a => login.follows.item.includes(a.pubkey) || a.tags.filter(a => a[0] === "t").length < 5); + .filter(a => isFollowing(a.pubkey) || a.tags.filter(a => a[0] === "t").length < 5); }, - [postsOnly, props.noteFilter, login.follows.timestamp], + [postsOnly, props.noteFilter, isFollowing], ); const mainFeed = useMemo(() => { diff --git a/packages/app/src/Components/LiveStream/LiveStreams.tsx b/packages/app/src/Components/LiveStream/LiveStreams.tsx index bfe307ce..11eb5e0c 100644 --- a/packages/app/src/Components/LiveStream/LiveStreams.tsx +++ b/packages/app/src/Components/LiveStream/LiveStreams.tsx @@ -7,19 +7,19 @@ import { CSSProperties, useMemo } from "react"; import { Link } from "react-router-dom"; import Icon from "@/Components/Icons/Icon"; +import useFollowsControls from "@/Hooks/useFollowControls"; import useImgProxy from "@/Hooks/useImgProxy"; -import useLogin from "@/Hooks/useLogin"; import { findTag } from "@/Utils"; export function LiveStreams() { - const follows = useLogin(s => s.follows.item); + const { followList } = useFollowsControls(); const sub = useMemo(() => { const since = unixNow() - 60 * 60 * 24; const rb = new RequestBuilder("follows:streams"); - rb.withFilter().kinds([EventKind.LiveEvent]).authors(follows).since(since); - rb.withFilter().kinds([EventKind.LiveEvent]).tag("p", follows).since(since); + rb.withFilter().kinds([EventKind.LiveEvent]).authors(followList).since(since); + rb.withFilter().kinds([EventKind.LiveEvent]).tag("p", followList).since(since); return rb; - }, [follows]); + }, [followList]); const streams = useRequestBuilder(sub); if (streams.length === 0) return null; diff --git a/packages/app/src/Components/SuggestedProfiles.tsx b/packages/app/src/Components/SuggestedProfiles.tsx index 3a506c39..a311d52f 100644 --- a/packages/app/src/Components/SuggestedProfiles.tsx +++ b/packages/app/src/Components/SuggestedProfiles.tsx @@ -6,7 +6,6 @@ import PageSpinner from "@/Components/PageSpinner"; import TrendingUsers from "@/Components/Trending/TrendingUsers"; import FollowListBase from "@/Components/User/FollowListBase"; import NostrBandApi from "@/External/NostrBand"; -import SemisolDevApi from "@/External/SemisolDev"; import useCachedFetch from "@/Hooks/useCachedFetch"; import useLogin from "@/Hooks/useLogin"; import { hexToBech32 } from "@/Utils"; @@ -15,11 +14,10 @@ import { ErrorOrOffline } from "./ErrorOrOffline"; enum Provider { NostrBand = 1, - SemisolDev = 2, } export default function SuggestedProfiles() { - const login = useLogin(s => ({ publicKey: s.publicKey, follows: s.follows.item })); + const login = useLogin(s => ({ publicKey: s.publicKey, follows: s.contacts })); const [provider, setProvider] = useState(Provider.NostrBand); const getUrlAndKey = () => { @@ -30,11 +28,6 @@ export default function SuggestedProfiles() { const url = api.suggestedFollowsUrl(hexToBech32(NostrPrefix.PublicKey, login.publicKey)); return { url, key: `nostr-band-${url}` }; } - case Provider.SemisolDev: { - const api = new SemisolDevApi(); - const url = api.suggestedFollowsUrl(login.publicKey, login.follows); - return { url, key: `semisol-dev-${url}` }; - } default: return { url: null, key: null }; } @@ -49,8 +42,6 @@ export default function SuggestedProfiles() { switch (provider) { case Provider.NostrBand: return data.profiles.map(a => a.pubkey); - case Provider.SemisolDev: - return data.recommendations.sort(a => a[1]).map(a => a[0]); default: return []; } diff --git a/packages/app/src/Components/User/Following.tsx b/packages/app/src/Components/User/Following.tsx index c6d3fe0d..68a17606 100644 --- a/packages/app/src/Components/User/Following.tsx +++ b/packages/app/src/Components/User/Following.tsx @@ -3,11 +3,11 @@ import "./Following.css"; import { FormattedMessage } from "react-intl"; import Icon from "@/Components/Icons/Icon"; -import useLogin from "@/Hooks/useLogin"; +import useFollowsControls from "@/Hooks/useFollowControls"; export function FollowingMark({ pubkey }: { pubkey: string }) { - const { follows } = useLogin(s => ({ follows: s.follows })); - const doesFollow = follows.item.includes(pubkey); + const { isFollowing } = useFollowsControls(); + const doesFollow = isFollowing(pubkey); if (!doesFollow) return; return ( diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index 5fa97baf..fa771ab9 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -1,4 +1,4 @@ -import { EventKind, NostrLink, NostrPrefix, parseRelayTags, RequestBuilder, TaggedNostrEvent } from "@snort/system"; +import { EventKind, NostrLink, parseRelayTags, RequestBuilder, TaggedNostrEvent } from "@snort/system"; import { useRequestBuilder } from "@snort/system-react"; import { useEffect, useMemo } from "react"; @@ -12,11 +12,11 @@ import { LoginStore, setBlocked, setBookmarked, - setFollows, setMuted, setPinned, setRelays, setTags, + updateSession, } from "@/Utils/Login"; import { SubscriptionEvent } from "@/Utils/Subscription"; /** @@ -24,7 +24,7 @@ import { SubscriptionEvent } from "@/Utils/Subscription"; */ export default function useLoginFeed() { const login = useLogin(); - const { publicKey: pubKey, follows } = login; + const { publicKey: pubKey, contacts } = login; const { publisher, system } = useEventPublisher(); useEffect(() => { @@ -32,8 +32,7 @@ export default function useLoginFeed() { system.checkSigs = login.appData.json.preferences.checkSigs; if (publisher) { - const link = new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, pubKey); - login.appData.sync(link, publisher.signer, system); + login.appData.sync(publisher.signer, system); } } }, [login, publisher]); @@ -76,8 +75,9 @@ export default function useLoginFeed() { if (loginFeed) { const contactList = getNewest(loginFeed.filter(a => a.kind === EventKind.ContactList)); if (contactList) { - const pTags = contactList.tags.filter(a => a[0] === "p").map(a => a[1]); - setFollows(login.id, pTags, contactList.created_at * 1000); + updateSession(login.id, s => { + s.contacts = contactList.tags; + }); } const relays = getNewest(loginFeed.filter(a => a.kind === EventKind.Relays)); @@ -180,6 +180,7 @@ export default function useLoginFeed() { }, [loginFeed]); useEffect(() => { - system.profileLoader.TrackKeys(follows.item); // always track follows profiles - }, [follows.item]); + const pTags = contacts.filter(a => a[0] === "p").map(a => a[1]); + system.profileLoader.TrackKeys(pTags); // always track follows profiles + }, [contacts]); } diff --git a/packages/app/src/Hooks/useFollowControls.ts b/packages/app/src/Hooks/useFollowControls.ts index 1adbf59f..ac67a08c 100644 --- a/packages/app/src/Hooks/useFollowControls.ts +++ b/packages/app/src/Hooks/useFollowControls.ts @@ -1,4 +1,4 @@ -import { dedupe } from "@snort/shared"; +import { DiffSyncTags, EventKind, NostrLink, NostrPrefix } from "@snort/system"; import { useMemo } from "react"; import useEventPublisher from "./useEventPublisher"; @@ -9,34 +9,40 @@ import useLogin from "./useLogin"; */ export default function useFollowsControls() { const { publisher, system } = useEventPublisher(); - const { follows, relays } = useLogin(s => ({ follows: s.follows.item, readonly: s.readonly, relays: s.relays.item })); + const { pubkey, contacts, relays } = useLogin(s => ({ + pubkey: s.publicKey, + contacts: s.contacts, + readonly: s.readonly, + relays: s.relays.item, + })); return useMemo(() => { - const publishList = async (newList: Array) => { - if (publisher) { - const ev = await publisher.contactList( - newList.map(a => ["p", a]), - relays, - ); - system.BroadcastEvent(ev); - } - }; - + const link = new NostrLink(NostrPrefix.Event, "", EventKind.ContactList, pubkey); + const sync = new DiffSyncTags(link); + const content = JSON.stringify(relays); return { isFollowing: (pk: string) => { - return follows.includes(pk); + return contacts.some(a => a[0] === "p" && a[1] === pk); }, addFollow: async (pk: Array) => { - const newList = dedupe([...follows, ...pk]); - await publishList(newList); + sync.add(pk.map(a => ["p", a])); + if (publisher) { + await sync.persist(publisher.signer, system, content); + } }, removeFollow: async (pk: Array) => { - const newList = follows.filter(a => !pk.includes(a)); - await publishList(newList); + sync.remove(pk.map(a => ["p", a])); + if (publisher) { + await sync.persist(publisher.signer, system, content); + } }, setFollows: async (pk: Array) => { - await publishList(dedupe(pk)); + 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]), }; - }, [follows, relays, publisher, system]); + }, [contacts, relays, publisher, system]); } diff --git a/packages/app/src/Hooks/usePreferences.ts b/packages/app/src/Hooks/usePreferences.ts index cfa4274e..d654a340 100644 --- a/packages/app/src/Hooks/usePreferences.ts +++ b/packages/app/src/Hooks/usePreferences.ts @@ -1,4 +1,4 @@ -import { updatePreferences, UserPreferences } from "@/Utils/Login"; +import { updateAppData, UserPreferences } from "@/Utils/Login"; import useEventPublisher from "./useEventPublisher"; import useLogin from "./useLogin"; @@ -10,7 +10,9 @@ export default function usePreferences() { return { preferences: pref, update: async (data: UserPreferences) => { - await updatePreferences(id, data, system); + await updateAppData(id, system, d => { + return { ...d, preferences: data }; + }); }, }; } diff --git a/packages/app/src/Pages/ListFeedPage.tsx b/packages/app/src/Pages/ListFeedPage.tsx index 6ebe8995..1f50ab93 100644 --- a/packages/app/src/Pages/ListFeedPage.tsx +++ b/packages/app/src/Pages/ListFeedPage.tsx @@ -7,18 +7,22 @@ import { useParams } from "react-router-dom"; import Timeline from "@/Components/Feed/Timeline"; import PageSpinner from "@/Components/PageSpinner"; +import { TimelineSubject } from "@/Feed/TimelineFeed"; import { Hour } from "@/Utils/Const"; export function ListFeedPage() { const { id } = useParams(); const link = parseNostrLink(unwrap(id)); - const { data } = useEventFeed(link); + const data = useEventFeed(link); + + const pubkeys = dedupe(data?.tags.filter(a => a[0] === "p").map(a => a[1]) ?? []); const subject = useMemo( - () => ({ - type: "pubkey", - items: pubkeys, - discriminator: "list-feed", - }), + () => + ({ + type: "pubkey", + items: pubkeys, + discriminator: "list-feed", + }) as TimelineSubject, [pubkeys], ); @@ -30,6 +34,5 @@ export function ListFeedPage() { ); } - const pubkeys = dedupe(data.tags.filter(a => a[0] === "p").map(a => a[1])); return ; } diff --git a/packages/app/src/Pages/Messages/NewChatWindow.tsx b/packages/app/src/Pages/Messages/NewChatWindow.tsx index d65f9dc5..710e2d46 100644 --- a/packages/app/src/Pages/Messages/NewChatWindow.tsx +++ b/packages/app/src/Pages/Messages/NewChatWindow.tsx @@ -11,6 +11,7 @@ import Modal from "@/Components/Modal/Modal"; import ProfileImage from "@/Components/User/ProfileImage"; import ProfilePreview from "@/Components/User/ProfilePreview"; import useEventPublisher from "@/Hooks/useEventPublisher"; +import useFollowsControls from "@/Hooks/useFollowControls"; import useLogin from "@/Hooks/useLogin"; import Nip28ChatProfile from "@/Pages/Messages/Nip28ChatProfile"; import { appendDedupe, debounce } from "@/Utils"; @@ -25,11 +26,12 @@ export default function NewChatWindow() { const search = useUserSearch(); const login = useLogin(); const { system, publisher } = useEventPublisher(); + const { followList } = useFollowsControls(); useEffect(() => { setNewChat([]); setSearchTerm(""); - setResults(login.follows.item); + setResults(followList); }, [show]); useEffect(() => { @@ -37,7 +39,7 @@ export default function NewChatWindow() { if (term) { search(term).then(setResults); } else { - setResults(login.follows.item); + setResults(followList); } }); }, [term]); diff --git a/packages/app/src/Pages/Root/ForYouTab.tsx b/packages/app/src/Pages/Root/ForYouTab.tsx index 2c9ba7c5..3aa4d00d 100644 --- a/packages/app/src/Pages/Root/ForYouTab.tsx +++ b/packages/app/src/Pages/Root/ForYouTab.tsx @@ -8,14 +8,15 @@ import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelecto import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer"; import { TaskList } from "@/Components/Tasks/TaskList"; import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed"; +import useFollowsControls from "@/Hooks/useFollowControls"; import useHistoryState from "@/Hooks/useHistoryState"; import useLogin from "@/Hooks/useLogin"; import messages from "@/Pages/messages"; import { System } from "@/system"; const FollowsHint = () => { - const { publicKey: pubKey, follows } = useLogin(); - if (follows.item?.length === 0 && pubKey) { + const { publicKey, contacts } = useLogin(); + if (contacts.length === 0 && publicKey) { return ( { export const ForYouTab = memo(function ForYouTab() { const [notes, setNotes] = useState(forYouFeed.events); - const { feedDisplayAs, follows } = useLogin(); - const displayAsInitial = feedDisplayAs ?? "list"; + const login = useLogin(); + const displayAsInitial = login.feedDisplayAs ?? "list"; const [displayAs, setDisplayAs] = useState(displayAsInitial); - const { publicKey } = useLogin(); const navigationType = useNavigationType(); const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt"); + const { followList } = useFollowsControls(); - if (!reactionsRequested && publicKey) { + if (!reactionsRequested && login.publicKey) { reactionsRequested = true; // on first load, ask relays for reactions to events by follows - getReactedByFollows(follows.item); + getReactedByFollows(followList); } - const login = useLogin(); const subject = useMemo( () => ({ type: "pubkey", - items: login.follows.item, + items: followList, discriminator: login.publicKey?.slice(0, 12), extra: rb => { if (login.tags.item.length > 0) { @@ -87,7 +87,7 @@ export const ForYouTab = memo(function ForYouTab() { } }, }) as TimelineSubject, - [login.follows.item, login.tags.item], + [followList, login.tags.item], ); // 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); @@ -101,17 +101,17 @@ export const ForYouTab = memo(function ForYouTab() { }, [latestFeed.main, subject]); const getFeed = () => { - if (!publicKey) { + if (!login.publicKey) { return []; } if (!getForYouFeedPromise) { - getForYouFeedPromise = Relay.forYouFeed(publicKey); + getForYouFeedPromise = Relay.forYouFeed(login.publicKey); } getForYouFeedPromise!.then(notes => { getForYouFeedPromise = null; if (notes.length < 10) { setTimeout(() => { - getForYouFeedPromise = Relay.forYouFeed(publicKey); + getForYouFeedPromise = Relay.forYouFeed(login.publicKey!); }, 1000); } forYouFeed = { diff --git a/packages/app/src/Pages/settings/Preferences.tsx b/packages/app/src/Pages/settings/Preferences.tsx index db271325..5cf87e4f 100644 --- a/packages/app/src/Pages/settings/Preferences.tsx +++ b/packages/app/src/Pages/settings/Preferences.tsx @@ -6,6 +6,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 { unwrap } from "@/Utils"; import { DefaultImgProxy } from "@/Utils/Const"; @@ -18,6 +19,7 @@ const PreferencesPage = () => { const { preferences, update: updatePerf } = usePreferences(); const [pref, setPref] = useState(preferences); const [error, setError] = useState(""); + const { lang } = useLocale(); async function update(obj: UserPreferences) { try { @@ -44,7 +46,7 @@ const PreferencesPage = () => {