feat: diff-sync follows
This commit is contained in:
parent
edf64e4125
commit
5a7657a95d
@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
|
|||||||
|
|
||||||
import { MediaElement } from "@/Components/Embed/MediaElement";
|
import { MediaElement } from "@/Components/Embed/MediaElement";
|
||||||
import Reveal from "@/Components/Event/Reveal";
|
import Reveal from "@/Components/Event/Reveal";
|
||||||
|
import useFollowsControls from "@/Hooks/useFollowControls";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
import useLogin from "@/Hooks/useLogin";
|
||||||
import { FileExtensionRegex } from "@/Utils/Const";
|
import { FileExtensionRegex } from "@/Utils/Const";
|
||||||
|
|
||||||
@ -16,13 +17,13 @@ interface RevealMediaProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RevealMedia(props: RevealMediaProps) {
|
export default function RevealMedia(props: RevealMediaProps) {
|
||||||
const { preferences, follows, publicKey } = useLogin(s => ({
|
const { preferences, publicKey } = useLogin(s => ({
|
||||||
preferences: s.appData.json.preferences,
|
preferences: s.appData.json.preferences,
|
||||||
follows: s.follows.item,
|
|
||||||
publicKey: s.publicKey,
|
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 isMine = props.creator === publicKey;
|
||||||
const hideMedia = preferences.autoLoadMedia === "none" || (!isMine && hideNonFollows);
|
const hideMedia = preferences.autoLoadMedia === "none" || (!isMine && hideNonFollows);
|
||||||
const hostname = new URL(props.link).hostname;
|
const hostname = new URL(props.link).hostname;
|
||||||
|
@ -7,6 +7,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
|
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
|
||||||
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
|
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
|
||||||
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
|
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
|
||||||
|
import useFollowsControls from "@/Hooks/useFollowControls";
|
||||||
import useHistoryState from "@/Hooks/useHistoryState";
|
import useHistoryState from "@/Hooks/useHistoryState";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
import useLogin from "@/Hooks/useLogin";
|
||||||
import { dedupeByPubkey } from "@/Utils";
|
import { dedupeByPubkey } from "@/Utils";
|
||||||
@ -29,11 +30,12 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
|
|||||||
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
|
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
|
||||||
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
||||||
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
|
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
|
||||||
|
const { isFollowing, followList } = useFollowsControls();
|
||||||
const subject = useMemo(
|
const subject = useMemo(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
type: "pubkey",
|
type: "pubkey",
|
||||||
items: login.follows.item,
|
items: followList,
|
||||||
discriminator: login.publicKey?.slice(0, 12),
|
discriminator: login.publicKey?.slice(0, 12),
|
||||||
extra: rb => {
|
extra: rb => {
|
||||||
if (login.tags.item.length > 0) {
|
if (login.tags.item.length > 0) {
|
||||||
@ -41,7 +43,7 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}) as TimelineSubject,
|
}) as TimelineSubject,
|
||||||
[login.follows.item, login.tags.item],
|
[followList, login.tags.item],
|
||||||
);
|
);
|
||||||
const feed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);
|
const feed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);
|
||||||
|
|
||||||
@ -57,9 +59,9 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
|
|||||||
return a
|
return a
|
||||||
?.filter(postsOnly)
|
?.filter(postsOnly)
|
||||||
.filter(a => props.noteFilter?.(a) ?? true)
|
.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(() => {
|
const mainFeed = useMemo(() => {
|
||||||
|
@ -7,19 +7,19 @@ import { CSSProperties, useMemo } from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import Icon from "@/Components/Icons/Icon";
|
import Icon from "@/Components/Icons/Icon";
|
||||||
|
import useFollowsControls from "@/Hooks/useFollowControls";
|
||||||
import useImgProxy from "@/Hooks/useImgProxy";
|
import useImgProxy from "@/Hooks/useImgProxy";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
|
||||||
import { findTag } from "@/Utils";
|
import { findTag } from "@/Utils";
|
||||||
|
|
||||||
export function LiveStreams() {
|
export function LiveStreams() {
|
||||||
const follows = useLogin(s => s.follows.item);
|
const { followList } = useFollowsControls();
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
const since = unixNow() - 60 * 60 * 24;
|
const since = unixNow() - 60 * 60 * 24;
|
||||||
const rb = new RequestBuilder("follows:streams");
|
const rb = new RequestBuilder("follows:streams");
|
||||||
rb.withFilter().kinds([EventKind.LiveEvent]).authors(follows).since(since);
|
rb.withFilter().kinds([EventKind.LiveEvent]).authors(followList).since(since);
|
||||||
rb.withFilter().kinds([EventKind.LiveEvent]).tag("p", follows).since(since);
|
rb.withFilter().kinds([EventKind.LiveEvent]).tag("p", followList).since(since);
|
||||||
return rb;
|
return rb;
|
||||||
}, [follows]);
|
}, [followList]);
|
||||||
|
|
||||||
const streams = useRequestBuilder(sub);
|
const streams = useRequestBuilder(sub);
|
||||||
if (streams.length === 0) return null;
|
if (streams.length === 0) return null;
|
||||||
|
@ -6,7 +6,6 @@ import PageSpinner from "@/Components/PageSpinner";
|
|||||||
import TrendingUsers from "@/Components/Trending/TrendingUsers";
|
import TrendingUsers from "@/Components/Trending/TrendingUsers";
|
||||||
import FollowListBase from "@/Components/User/FollowListBase";
|
import FollowListBase from "@/Components/User/FollowListBase";
|
||||||
import NostrBandApi from "@/External/NostrBand";
|
import NostrBandApi from "@/External/NostrBand";
|
||||||
import SemisolDevApi from "@/External/SemisolDev";
|
|
||||||
import useCachedFetch from "@/Hooks/useCachedFetch";
|
import useCachedFetch from "@/Hooks/useCachedFetch";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
import useLogin from "@/Hooks/useLogin";
|
||||||
import { hexToBech32 } from "@/Utils";
|
import { hexToBech32 } from "@/Utils";
|
||||||
@ -15,11 +14,10 @@ import { ErrorOrOffline } from "./ErrorOrOffline";
|
|||||||
|
|
||||||
enum Provider {
|
enum Provider {
|
||||||
NostrBand = 1,
|
NostrBand = 1,
|
||||||
SemisolDev = 2,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SuggestedProfiles() {
|
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 [provider, setProvider] = useState(Provider.NostrBand);
|
||||||
|
|
||||||
const getUrlAndKey = () => {
|
const getUrlAndKey = () => {
|
||||||
@ -30,11 +28,6 @@ export default function SuggestedProfiles() {
|
|||||||
const url = api.suggestedFollowsUrl(hexToBech32(NostrPrefix.PublicKey, login.publicKey));
|
const url = api.suggestedFollowsUrl(hexToBech32(NostrPrefix.PublicKey, login.publicKey));
|
||||||
return { url, key: `nostr-band-${url}` };
|
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:
|
default:
|
||||||
return { url: null, key: null };
|
return { url: null, key: null };
|
||||||
}
|
}
|
||||||
@ -49,8 +42,6 @@ export default function SuggestedProfiles() {
|
|||||||
switch (provider) {
|
switch (provider) {
|
||||||
case Provider.NostrBand:
|
case Provider.NostrBand:
|
||||||
return data.profiles.map(a => a.pubkey);
|
return data.profiles.map(a => a.pubkey);
|
||||||
case Provider.SemisolDev:
|
|
||||||
return data.recommendations.sort(a => a[1]).map(a => a[0]);
|
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
@ -3,11 +3,11 @@ import "./Following.css";
|
|||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
import Icon from "@/Components/Icons/Icon";
|
import Icon from "@/Components/Icons/Icon";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
import useFollowsControls from "@/Hooks/useFollowControls";
|
||||||
|
|
||||||
export function FollowingMark({ pubkey }: { pubkey: string }) {
|
export function FollowingMark({ pubkey }: { pubkey: string }) {
|
||||||
const { follows } = useLogin(s => ({ follows: s.follows }));
|
const { isFollowing } = useFollowsControls();
|
||||||
const doesFollow = follows.item.includes(pubkey);
|
const doesFollow = isFollowing(pubkey);
|
||||||
if (!doesFollow) return;
|
if (!doesFollow) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -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 { useRequestBuilder } from "@snort/system-react";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
|
||||||
@ -12,11 +12,11 @@ import {
|
|||||||
LoginStore,
|
LoginStore,
|
||||||
setBlocked,
|
setBlocked,
|
||||||
setBookmarked,
|
setBookmarked,
|
||||||
setFollows,
|
|
||||||
setMuted,
|
setMuted,
|
||||||
setPinned,
|
setPinned,
|
||||||
setRelays,
|
setRelays,
|
||||||
setTags,
|
setTags,
|
||||||
|
updateSession,
|
||||||
} from "@/Utils/Login";
|
} from "@/Utils/Login";
|
||||||
import { SubscriptionEvent } from "@/Utils/Subscription";
|
import { SubscriptionEvent } from "@/Utils/Subscription";
|
||||||
/**
|
/**
|
||||||
@ -24,7 +24,7 @@ import { SubscriptionEvent } from "@/Utils/Subscription";
|
|||||||
*/
|
*/
|
||||||
export default function useLoginFeed() {
|
export default function useLoginFeed() {
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const { publicKey: pubKey, follows } = login;
|
const { publicKey: pubKey, contacts } = login;
|
||||||
const { publisher, system } = useEventPublisher();
|
const { publisher, system } = useEventPublisher();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -32,8 +32,7 @@ export default function useLoginFeed() {
|
|||||||
system.checkSigs = login.appData.json.preferences.checkSigs;
|
system.checkSigs = login.appData.json.preferences.checkSigs;
|
||||||
|
|
||||||
if (publisher) {
|
if (publisher) {
|
||||||
const link = new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, pubKey);
|
login.appData.sync(publisher.signer, system);
|
||||||
login.appData.sync(link, publisher.signer, system);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [login, publisher]);
|
}, [login, publisher]);
|
||||||
@ -76,8 +75,9 @@ export default function useLoginFeed() {
|
|||||||
if (loginFeed) {
|
if (loginFeed) {
|
||||||
const contactList = getNewest(loginFeed.filter(a => a.kind === EventKind.ContactList));
|
const contactList = getNewest(loginFeed.filter(a => a.kind === EventKind.ContactList));
|
||||||
if (contactList) {
|
if (contactList) {
|
||||||
const pTags = contactList.tags.filter(a => a[0] === "p").map(a => a[1]);
|
updateSession(login.id, s => {
|
||||||
setFollows(login.id, pTags, contactList.created_at * 1000);
|
s.contacts = contactList.tags;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const relays = getNewest(loginFeed.filter(a => a.kind === EventKind.Relays));
|
const relays = getNewest(loginFeed.filter(a => a.kind === EventKind.Relays));
|
||||||
@ -180,6 +180,7 @@ export default function useLoginFeed() {
|
|||||||
}, [loginFeed]);
|
}, [loginFeed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
system.profileLoader.TrackKeys(follows.item); // always track follows profiles
|
const pTags = contacts.filter(a => a[0] === "p").map(a => a[1]);
|
||||||
}, [follows.item]);
|
system.profileLoader.TrackKeys(pTags); // always track follows profiles
|
||||||
|
}, [contacts]);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { dedupe } from "@snort/shared";
|
import { DiffSyncTags, EventKind, NostrLink, NostrPrefix } from "@snort/system";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import useEventPublisher from "./useEventPublisher";
|
import useEventPublisher from "./useEventPublisher";
|
||||||
@ -9,34 +9,40 @@ import useLogin from "./useLogin";
|
|||||||
*/
|
*/
|
||||||
export default function useFollowsControls() {
|
export default function useFollowsControls() {
|
||||||
const { publisher, system } = useEventPublisher();
|
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(() => {
|
return useMemo(() => {
|
||||||
const publishList = async (newList: Array<string>) => {
|
const link = new NostrLink(NostrPrefix.Event, "", EventKind.ContactList, pubkey);
|
||||||
if (publisher) {
|
const sync = new DiffSyncTags(link);
|
||||||
const ev = await publisher.contactList(
|
const content = JSON.stringify(relays);
|
||||||
newList.map(a => ["p", a]),
|
|
||||||
relays,
|
|
||||||
);
|
|
||||||
system.BroadcastEvent(ev);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isFollowing: (pk: string) => {
|
isFollowing: (pk: string) => {
|
||||||
return follows.includes(pk);
|
return contacts.some(a => a[0] === "p" && a[1] === pk);
|
||||||
},
|
},
|
||||||
addFollow: async (pk: Array<string>) => {
|
addFollow: async (pk: Array<string>) => {
|
||||||
const newList = dedupe([...follows, ...pk]);
|
sync.add(pk.map(a => ["p", a]));
|
||||||
await publishList(newList);
|
if (publisher) {
|
||||||
|
await sync.persist(publisher.signer, system, content);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
removeFollow: async (pk: Array<string>) => {
|
removeFollow: async (pk: Array<string>) => {
|
||||||
const newList = follows.filter(a => !pk.includes(a));
|
sync.remove(pk.map(a => ["p", a]));
|
||||||
await publishList(newList);
|
if (publisher) {
|
||||||
|
await sync.persist(publisher.signer, system, content);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setFollows: async (pk: Array<string>) => {
|
setFollows: async (pk: Array<string>) => {
|
||||||
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]);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { updatePreferences, UserPreferences } from "@/Utils/Login";
|
import { updateAppData, UserPreferences } from "@/Utils/Login";
|
||||||
|
|
||||||
import useEventPublisher from "./useEventPublisher";
|
import useEventPublisher from "./useEventPublisher";
|
||||||
import useLogin from "./useLogin";
|
import useLogin from "./useLogin";
|
||||||
@ -10,7 +10,9 @@ export default function usePreferences() {
|
|||||||
return {
|
return {
|
||||||
preferences: pref,
|
preferences: pref,
|
||||||
update: async (data: UserPreferences) => {
|
update: async (data: UserPreferences) => {
|
||||||
await updatePreferences(id, data, system);
|
await updateAppData(id, system, d => {
|
||||||
|
return { ...d, preferences: data };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -7,18 +7,22 @@ import { useParams } from "react-router-dom";
|
|||||||
|
|
||||||
import Timeline from "@/Components/Feed/Timeline";
|
import Timeline from "@/Components/Feed/Timeline";
|
||||||
import PageSpinner from "@/Components/PageSpinner";
|
import PageSpinner from "@/Components/PageSpinner";
|
||||||
|
import { TimelineSubject } from "@/Feed/TimelineFeed";
|
||||||
import { Hour } from "@/Utils/Const";
|
import { Hour } from "@/Utils/Const";
|
||||||
|
|
||||||
export function ListFeedPage() {
|
export function ListFeedPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const link = parseNostrLink(unwrap(id));
|
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(
|
const subject = useMemo(
|
||||||
() => ({
|
() =>
|
||||||
type: "pubkey",
|
({
|
||||||
items: pubkeys,
|
type: "pubkey",
|
||||||
discriminator: "list-feed",
|
items: pubkeys,
|
||||||
}),
|
discriminator: "list-feed",
|
||||||
|
}) as TimelineSubject,
|
||||||
[pubkeys],
|
[pubkeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -30,6 +34,5 @@ export function ListFeedPage() {
|
|||||||
</b>
|
</b>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const pubkeys = dedupe(data.tags.filter(a => a[0] === "p").map(a => a[1]));
|
|
||||||
return <Timeline subject={subject} postsOnly={true} method="TIME_RANGE" window={Hour * 12} />;
|
return <Timeline subject={subject} postsOnly={true} method="TIME_RANGE" window={Hour * 12} />;
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ import Modal from "@/Components/Modal/Modal";
|
|||||||
import ProfileImage from "@/Components/User/ProfileImage";
|
import ProfileImage from "@/Components/User/ProfileImage";
|
||||||
import ProfilePreview from "@/Components/User/ProfilePreview";
|
import ProfilePreview from "@/Components/User/ProfilePreview";
|
||||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||||
|
import useFollowsControls from "@/Hooks/useFollowControls";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
import useLogin from "@/Hooks/useLogin";
|
||||||
import Nip28ChatProfile from "@/Pages/Messages/Nip28ChatProfile";
|
import Nip28ChatProfile from "@/Pages/Messages/Nip28ChatProfile";
|
||||||
import { appendDedupe, debounce } from "@/Utils";
|
import { appendDedupe, debounce } from "@/Utils";
|
||||||
@ -25,11 +26,12 @@ export default function NewChatWindow() {
|
|||||||
const search = useUserSearch();
|
const search = useUserSearch();
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const { system, publisher } = useEventPublisher();
|
const { system, publisher } = useEventPublisher();
|
||||||
|
const { followList } = useFollowsControls();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNewChat([]);
|
setNewChat([]);
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
setResults(login.follows.item);
|
setResults(followList);
|
||||||
}, [show]);
|
}, [show]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -37,7 +39,7 @@ export default function NewChatWindow() {
|
|||||||
if (term) {
|
if (term) {
|
||||||
search(term).then(setResults);
|
search(term).then(setResults);
|
||||||
} else {
|
} else {
|
||||||
setResults(login.follows.item);
|
setResults(followList);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [term]);
|
}, [term]);
|
||||||
|
@ -8,14 +8,15 @@ import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelecto
|
|||||||
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
|
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
|
||||||
import { TaskList } from "@/Components/Tasks/TaskList";
|
import { TaskList } from "@/Components/Tasks/TaskList";
|
||||||
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
|
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
|
||||||
|
import useFollowsControls from "@/Hooks/useFollowControls";
|
||||||
import useHistoryState from "@/Hooks/useHistoryState";
|
import useHistoryState from "@/Hooks/useHistoryState";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
import useLogin from "@/Hooks/useLogin";
|
||||||
import messages from "@/Pages/messages";
|
import messages from "@/Pages/messages";
|
||||||
import { System } from "@/system";
|
import { System } from "@/system";
|
||||||
|
|
||||||
const FollowsHint = () => {
|
const FollowsHint = () => {
|
||||||
const { publicKey: pubKey, follows } = useLogin();
|
const { publicKey, contacts } = useLogin();
|
||||||
if (follows.item?.length === 0 && pubKey) {
|
if (contacts.length === 0 && publicKey) {
|
||||||
return (
|
return (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
{...messages.NoFollows}
|
{...messages.NoFollows}
|
||||||
@ -61,25 +62,24 @@ const getReactedByFollows = (follows: string[]) => {
|
|||||||
|
|
||||||
export const ForYouTab = memo(function ForYouTab() {
|
export const ForYouTab = memo(function ForYouTab() {
|
||||||
const [notes, setNotes] = useState<NostrEvent[]>(forYouFeed.events);
|
const [notes, setNotes] = useState<NostrEvent[]>(forYouFeed.events);
|
||||||
const { feedDisplayAs, follows } = useLogin();
|
const login = useLogin();
|
||||||
const displayAsInitial = feedDisplayAs ?? "list";
|
const displayAsInitial = login.feedDisplayAs ?? "list";
|
||||||
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
||||||
const { publicKey } = useLogin();
|
|
||||||
const navigationType = useNavigationType();
|
const navigationType = useNavigationType();
|
||||||
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
|
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
|
||||||
|
const { followList } = useFollowsControls();
|
||||||
|
|
||||||
if (!reactionsRequested && publicKey) {
|
if (!reactionsRequested && login.publicKey) {
|
||||||
reactionsRequested = true;
|
reactionsRequested = true;
|
||||||
// on first load, ask relays for reactions to events by follows
|
// on first load, ask relays for reactions to events by follows
|
||||||
getReactedByFollows(follows.item);
|
getReactedByFollows(followList);
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = useLogin();
|
|
||||||
const subject = useMemo(
|
const subject = useMemo(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
type: "pubkey",
|
type: "pubkey",
|
||||||
items: login.follows.item,
|
items: followList,
|
||||||
discriminator: login.publicKey?.slice(0, 12),
|
discriminator: login.publicKey?.slice(0, 12),
|
||||||
extra: rb => {
|
extra: rb => {
|
||||||
if (login.tags.item.length > 0) {
|
if (login.tags.item.length > 0) {
|
||||||
@ -87,7 +87,7 @@ export const ForYouTab = memo(function ForYouTab() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}) as TimelineSubject,
|
}) 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
|
// 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);
|
const latestFeed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);
|
||||||
@ -101,17 +101,17 @@ export const ForYouTab = memo(function ForYouTab() {
|
|||||||
}, [latestFeed.main, subject]);
|
}, [latestFeed.main, subject]);
|
||||||
|
|
||||||
const getFeed = () => {
|
const getFeed = () => {
|
||||||
if (!publicKey) {
|
if (!login.publicKey) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (!getForYouFeedPromise) {
|
if (!getForYouFeedPromise) {
|
||||||
getForYouFeedPromise = Relay.forYouFeed(publicKey);
|
getForYouFeedPromise = Relay.forYouFeed(login.publicKey);
|
||||||
}
|
}
|
||||||
getForYouFeedPromise!.then(notes => {
|
getForYouFeedPromise!.then(notes => {
|
||||||
getForYouFeedPromise = null;
|
getForYouFeedPromise = null;
|
||||||
if (notes.length < 10) {
|
if (notes.length < 10) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
getForYouFeedPromise = Relay.forYouFeed(publicKey);
|
getForYouFeedPromise = Relay.forYouFeed(login.publicKey!);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
forYouFeed = {
|
forYouFeed = {
|
||||||
|
@ -6,6 +6,7 @@ import { FormattedMessage, useIntl } from "react-intl";
|
|||||||
|
|
||||||
import AsyncButton from "@/Components/Button/AsyncButton";
|
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 usePreferences from "@/Hooks/usePreferences";
|
||||||
import { unwrap } from "@/Utils";
|
import { unwrap } from "@/Utils";
|
||||||
import { DefaultImgProxy } from "@/Utils/Const";
|
import { DefaultImgProxy } from "@/Utils/Const";
|
||||||
@ -18,6 +19,7 @@ const PreferencesPage = () => {
|
|||||||
const { preferences, update: updatePerf } = usePreferences();
|
const { preferences, update: updatePerf } = usePreferences();
|
||||||
const [pref, setPref] = useState<UserPreferences>(preferences);
|
const [pref, setPref] = useState<UserPreferences>(preferences);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const { lang } = useLocale();
|
||||||
|
|
||||||
async function update(obj: UserPreferences) {
|
async function update(obj: UserPreferences) {
|
||||||
try {
|
try {
|
||||||
@ -44,7 +46,7 @@ const PreferencesPage = () => {
|
|||||||
</h4>
|
</h4>
|
||||||
<div>
|
<div>
|
||||||
<select
|
<select
|
||||||
value={pref.language}
|
value={pref.language ?? lang}
|
||||||
onChange={e =>
|
onChange={e =>
|
||||||
setPref({
|
setPref({
|
||||||
...pref,
|
...pref,
|
||||||
|
@ -6,7 +6,7 @@ import { FormattedMessage, FormattedNumber } from "react-intl";
|
|||||||
|
|
||||||
import { CollapsedSection } from "@/Components/Collapsed";
|
import { CollapsedSection } from "@/Components/Collapsed";
|
||||||
import ProfilePreview from "@/Components/User/ProfilePreview";
|
import ProfilePreview from "@/Components/User/ProfilePreview";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
import useFollowsControls from "@/Hooks/useFollowControls";
|
||||||
import { getRelayName } from "@/Utils";
|
import { getRelayName } from "@/Utils";
|
||||||
|
|
||||||
export function FollowsRelayHealth({
|
export function FollowsRelayHealth({
|
||||||
@ -19,8 +19,8 @@ export function FollowsRelayHealth({
|
|||||||
missingRelaysActions?: (k: string) => ReactNode;
|
missingRelaysActions?: (k: string) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const system = useContext(SnortContext);
|
const system = useContext(SnortContext);
|
||||||
const follows = useLogin(s => s.follows);
|
const { followList: follows } = useFollowsControls();
|
||||||
const uniqueFollows = dedupe(follows.item);
|
const uniqueFollows = dedupe(follows);
|
||||||
|
|
||||||
const hasRelays = useMemo(() => {
|
const hasRelays = useMemo(() => {
|
||||||
return uniqueFollows.filter(a => (system.relayCache.getFromCache(a)?.relays.length ?? 0) > 0);
|
return uniqueFollows.filter(a => (system.relayCache.getFromCache(a)?.relays.length ?? 0) > 0);
|
||||||
|
@ -7,7 +7,6 @@ import AsyncButton from "@/Components/Button/AsyncButton";
|
|||||||
import ProfileImage from "@/Components/User/ProfileImage";
|
import ProfileImage from "@/Components/User/ProfileImage";
|
||||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||||
import useFollowsControls from "@/Hooks/useFollowControls";
|
import useFollowsControls from "@/Hooks/useFollowControls";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
|
||||||
import { Day } from "@/Utils/Const";
|
import { Day } from "@/Utils/Const";
|
||||||
|
|
||||||
import { FollowsRelayHealth } from "./follows-relay-health";
|
import { FollowsRelayHealth } from "./follows-relay-health";
|
||||||
@ -18,9 +17,9 @@ const enum PruneStage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PruneFollowList() {
|
export function PruneFollowList() {
|
||||||
const { follows } = useLogin(s => ({ id: s.id, follows: s.follows }));
|
const { followList: follows } = useFollowsControls();
|
||||||
const { system } = useEventPublisher();
|
const { system } = useEventPublisher();
|
||||||
const uniqueFollows = dedupe(follows.item);
|
const uniqueFollows = dedupe(follows);
|
||||||
const [status, setStatus] = useState<PruneStage>();
|
const [status, setStatus] = useState<PruneStage>();
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [lastPost, setLastPosts] = useState<Record<string, number>>();
|
const [lastPost, setLastPosts] = useState<Record<string, number>>();
|
||||||
@ -122,8 +121,8 @@ export function PruneFollowList() {
|
|||||||
defaultMessage="{x} follows ({y} duplicates)"
|
defaultMessage="{x} follows ({y} duplicates)"
|
||||||
id="iICVoL"
|
id="iICVoL"
|
||||||
values={{
|
values={{
|
||||||
x: follows.item.length,
|
x: follows.length,
|
||||||
y: follows.item.length - uniqueFollows.length,
|
y: follows.length - uniqueFollows.length,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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, SnortAppData, UserPreferences } from "@/Utils/Login/index";
|
import { LoginSession, LoginSessionType, LoginStore, SnortAppData } 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,6 @@ export function removeRelay(state: LoginSession, addr: string) {
|
|||||||
LoginStore.updateSession(state);
|
LoginStore.updateSession(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePreferences(id: string, p: UserPreferences, system: SystemInterface) {
|
|
||||||
await updateAppData(id, system, d => {
|
|
||||||
return { ...d, preferences: p };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logout(id: string) {
|
export function logout(id: string) {
|
||||||
LoginStore.removeSession(id);
|
LoginStore.removeSession(id);
|
||||||
GiftsCache.clear();
|
GiftsCache.clear();
|
||||||
@ -174,14 +168,11 @@ export function setBlocked(state: LoginSession, blocked: Array<string>, ts: numb
|
|||||||
LoginStore.updateSession(state);
|
LoginStore.updateSession(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setFollows(id: string, follows: Array<string>, ts: number) {
|
export function updateSession(id: string, fn: (state: LoginSession) => void) {
|
||||||
const session = LoginStore.get(id);
|
const session = LoginStore.get(id);
|
||||||
if (session) {
|
if (session) {
|
||||||
if (ts > session.follows.timestamp) {
|
fn(session);
|
||||||
session.follows.item = follows;
|
LoginStore.updateSession(session);
|
||||||
session.follows.timestamp = ts;
|
|
||||||
LoginStore.updateSession(session);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,7 +72,7 @@ export interface LoginSession {
|
|||||||
/**
|
/**
|
||||||
* A list of pubkeys this user follows
|
* A list of pubkeys this user follows
|
||||||
*/
|
*/
|
||||||
follows: Newest<Array<HexKey>>;
|
contacts: Array<Array<string>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of tags this user follows
|
* A list of tags this user follows
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
|
/* eslint-disable max-lines */
|
||||||
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 {
|
import {
|
||||||
|
EventKind,
|
||||||
EventPublisher,
|
EventPublisher,
|
||||||
HexKey,
|
HexKey,
|
||||||
JsonEventSync,
|
JsonEventSync,
|
||||||
KeyStorage,
|
KeyStorage,
|
||||||
|
NostrLink,
|
||||||
|
NostrPrefix,
|
||||||
NotEncrypted,
|
NotEncrypted,
|
||||||
RelaySettings,
|
RelaySettings,
|
||||||
socialGraphInstance,
|
socialGraphInstance,
|
||||||
@ -25,10 +29,8 @@ const LoggedOut = {
|
|||||||
item: [],
|
item: [],
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
},
|
},
|
||||||
follows: {
|
contacts: [],
|
||||||
item: [],
|
follows: [],
|
||||||
timestamp: 0,
|
|
||||||
},
|
|
||||||
muted: {
|
muted: {
|
||||||
item: [],
|
item: [],
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
@ -59,6 +61,7 @@ const LoggedOut = {
|
|||||||
mutedWords: [],
|
mutedWords: [],
|
||||||
showContentWarningPosts: false,
|
showContentWarningPosts: false,
|
||||||
},
|
},
|
||||||
|
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData),
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
extraChats: [],
|
extraChats: [],
|
||||||
@ -100,7 +103,11 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
|||||||
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 = new JsonEventSync<SnortAppData>(
|
||||||
|
v.appData as unknown as SnortAppData,
|
||||||
|
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, v.publicKey),
|
||||||
|
true,
|
||||||
|
);
|
||||||
v.appData.on("change", () => {
|
v.appData.on("change", () => {
|
||||||
this.#save();
|
this.#save();
|
||||||
});
|
});
|
||||||
@ -177,6 +184,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
|||||||
mutedWords: [],
|
mutedWords: [],
|
||||||
showContentWarningPosts: false,
|
showContentWarningPosts: false,
|
||||||
},
|
},
|
||||||
|
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, key),
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
remoteSignerRelays,
|
remoteSignerRelays,
|
||||||
@ -231,6 +239,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
|||||||
mutedWords: [],
|
mutedWords: [],
|
||||||
showContentWarningPosts: false,
|
showContentWarningPosts: false,
|
||||||
},
|
},
|
||||||
|
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, pubKey),
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
} as LoginSession;
|
} as LoginSession;
|
||||||
@ -294,7 +303,11 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
|||||||
for (const [, acc] of this.#accounts) {
|
for (const [, acc] of this.#accounts) {
|
||||||
if ("item" in acc.appData) {
|
if ("item" in acc.appData) {
|
||||||
didMigrate = true;
|
didMigrate = true;
|
||||||
acc.appData = new JsonEventSync<SnortAppData>(acc.appData.item as SnortAppData, true);
|
acc.appData = new JsonEventSync<SnortAppData>(
|
||||||
|
acc.appData.item as SnortAppData,
|
||||||
|
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, acc.publicKey),
|
||||||
|
true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,7 +56,8 @@ async function initSite() {
|
|||||||
|
|
||||||
db.ready = await db.isAvailable();
|
db.ready = await db.isAvailable();
|
||||||
if (db.ready) {
|
if (db.ready) {
|
||||||
await preload(login.follows.item);
|
const pTags = login.contacts.filter(a => a[0] === "p").map(a => a[1]);
|
||||||
|
await preload(pTags);
|
||||||
await System.PreloadSocialGraph();
|
await System.PreloadSocialGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner, PowMiner } fro
|
|||||||
import { HashtagRegex, MentionNostrEntityRegex } from "./const";
|
import { HashtagRegex, MentionNostrEntityRegex } from "./const";
|
||||||
import { getPublicKey, jitter, unixNow } from "@snort/shared";
|
import { getPublicKey, jitter, unixNow } from "@snort/shared";
|
||||||
import { EventExt } from "./event-ext";
|
import { EventExt } from "./event-ext";
|
||||||
import { tryParseNostrLink } from "./nostr-link";
|
import { NostrLink, tryParseNostrLink } from "./nostr-link";
|
||||||
|
|
||||||
export class EventBuilder {
|
export class EventBuilder {
|
||||||
#kind?: EventKind;
|
#kind?: EventKind;
|
||||||
@ -14,6 +14,21 @@ export class EventBuilder {
|
|||||||
#powMiner?: PowMiner;
|
#powMiner?: PowMiner;
|
||||||
#jitter?: number;
|
#jitter?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate builder with values from link
|
||||||
|
*/
|
||||||
|
fromLink(link: NostrLink) {
|
||||||
|
if (link.kind) {
|
||||||
|
this.#kind = link.kind;
|
||||||
|
}
|
||||||
|
if (link.author) {
|
||||||
|
this.#pubkey = link.author;
|
||||||
|
}
|
||||||
|
if (link.type === NostrPrefix.Address && link.id) {
|
||||||
|
this.tag(["d", link.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
jitter(n: number) {
|
jitter(n: number) {
|
||||||
this.#jitter = n;
|
this.#jitter = n;
|
||||||
return this;
|
return this;
|
||||||
|
@ -78,7 +78,10 @@ export class NostrLink implements ToNostrEventTag {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (this.type === NostrPrefix.Event || this.type === NostrPrefix.Note) {
|
} else if (this.type === NostrPrefix.Event || this.type === NostrPrefix.Note) {
|
||||||
return this.id === ev.id;
|
const ifSetCheck = <T>(a: T | undefined, b: T) => {
|
||||||
|
return !Boolean(a) || a === b;
|
||||||
|
};
|
||||||
|
return ifSetCheck(this.id, ev.id) && ifSetCheck(this.author, ev.pubkey) && ifSetCheck(this.kind, ev.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
@ -245,10 +245,15 @@ export class RequestFilterBuilder {
|
|||||||
.kinds([unwrap(link.kind)])
|
.kinds([unwrap(link.kind)])
|
||||||
.authors([unwrap(link.author)]);
|
.authors([unwrap(link.author)]);
|
||||||
} else {
|
} else {
|
||||||
this.ids([link.id]);
|
if (link.id) {
|
||||||
|
this.ids([link.id]);
|
||||||
|
}
|
||||||
if (link.author) {
|
if (link.author) {
|
||||||
this.authors([link.author]);
|
this.authors([link.author]);
|
||||||
}
|
}
|
||||||
|
if (link.kind !== undefined) {
|
||||||
|
this.kinds([link.kind]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
link.relays?.forEach(v => this.relay(v));
|
link.relays?.forEach(v => this.relay(v));
|
||||||
return this;
|
return this;
|
||||||
|
100
packages/system/src/sync/diff-sync.ts
Normal file
100
packages/system/src/sync/diff-sync.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { EventBuilder, EventSigner, NostrLink, SystemInterface } from "..";
|
||||||
|
import { SafeSync } from "./safe-sync";
|
||||||
|
import debug from "debug";
|
||||||
|
|
||||||
|
interface TagDiff {
|
||||||
|
type: "add" | "remove" | "replace";
|
||||||
|
tag: Array<string> | Array<Array<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add/Remove tags from event
|
||||||
|
*/
|
||||||
|
export class DiffSyncTags {
|
||||||
|
#log = debug("DiffSyncTags");
|
||||||
|
#sync = new SafeSync();
|
||||||
|
#changes: Array<TagDiff> = [];
|
||||||
|
|
||||||
|
constructor(readonly link: NostrLink) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a tag
|
||||||
|
*/
|
||||||
|
add(tag: Array<string> | Array<Array<string>>) {
|
||||||
|
this.#changes.push({
|
||||||
|
type: "add",
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a tag
|
||||||
|
*/
|
||||||
|
remove(tag: Array<string> | Array<Array<string>>) {
|
||||||
|
this.#changes.push({
|
||||||
|
type: "remove",
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace all the tags
|
||||||
|
*/
|
||||||
|
replace(tag: Array<Array<string>>) {
|
||||||
|
this.#changes.push({
|
||||||
|
type: "replace",
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply changes and save
|
||||||
|
*/
|
||||||
|
async persist(signer: EventSigner, system: SystemInterface, content?: string) {
|
||||||
|
const cloneChanges = [...this.#changes];
|
||||||
|
this.#changes = [];
|
||||||
|
|
||||||
|
// always start with sync
|
||||||
|
const res = await this.#sync.sync(this.link, system);
|
||||||
|
|
||||||
|
let isNew = false;
|
||||||
|
let next = res ? { ...res } : undefined;
|
||||||
|
if (!next) {
|
||||||
|
const eb = new EventBuilder();
|
||||||
|
eb.fromLink(this.link);
|
||||||
|
next = eb.build();
|
||||||
|
isNew = true;
|
||||||
|
}
|
||||||
|
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": {
|
||||||
|
if (existing === -1) {
|
||||||
|
next.tags.push(changeTag);
|
||||||
|
} else {
|
||||||
|
this.#log("Tag already exists: %O", changeTag);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove": {
|
||||||
|
if (existing !== -1) {
|
||||||
|
next.tags.splice(existing, 1);
|
||||||
|
} else {
|
||||||
|
this.#log("Could not find tag to remove: %O", changeTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#sync.update(next, signer, system, !isNew);
|
||||||
|
}
|
||||||
|
}
|
@ -5,3 +5,4 @@ export interface HasId {
|
|||||||
export * from "./safe-sync";
|
export * from "./safe-sync";
|
||||||
export * from "./range-sync";
|
export * from "./range-sync";
|
||||||
export * from "./json-in-event-sync";
|
export * from "./json-in-event-sync";
|
||||||
|
export * from "./diff-sync";
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { SafeSync } from "./safe-sync";
|
import { SafeSync } from "./safe-sync";
|
||||||
import { HasId } from ".";
|
import { HasId } from ".";
|
||||||
import { EventExt, EventSigner, NostrEvent, NostrLink, SystemInterface } from "..";
|
import { EventBuilder, EventSigner, NostrEvent, NostrLink, NostrPrefix, SystemInterface } from "..";
|
||||||
import debug from "debug";
|
import debug from "debug";
|
||||||
import EventEmitter from "eventemitter3";
|
import EventEmitter from "eventemitter3";
|
||||||
import { unixNow } from "@snort/shared";
|
|
||||||
|
|
||||||
export interface JsonSyncEvents {
|
export interface JsonSyncEvents {
|
||||||
change: () => void;
|
change: () => void;
|
||||||
@ -16,6 +15,7 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
initValue: T,
|
initValue: T,
|
||||||
|
readonly link: NostrLink,
|
||||||
readonly encrypt: boolean,
|
readonly encrypt: boolean,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@ -30,8 +30,8 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
|
|||||||
return Object.freeze(ret);
|
return Object.freeze(ret);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sync(link: NostrLink, signer: EventSigner, system: SystemInterface) {
|
async sync(signer: EventSigner, system: SystemInterface) {
|
||||||
const res = await this.#sync.sync(link, system);
|
const res = await this.#sync.sync(this.link, system);
|
||||||
this.#log("Sync result %O", res);
|
this.#log("Sync result %O", res);
|
||||||
if (res) {
|
if (res) {
|
||||||
if (this.encrypt) {
|
if (this.encrypt) {
|
||||||
@ -40,6 +40,7 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
|
|||||||
this.#json = JSON.parse(res.content) as T;
|
this.#json = JSON.parse(res.content) as T;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,27 +50,26 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
|
|||||||
*/
|
*/
|
||||||
async updateJson(val: T, signer: EventSigner, system: SystemInterface) {
|
async updateJson(val: T, signer: EventSigner, system: SystemInterface) {
|
||||||
this.#log("Updating: %O", val);
|
this.#log("Updating: %O", val);
|
||||||
const next = this.#sync.value ? ({ ...this.#sync.value } as NostrEvent) : undefined;
|
let next = this.#sync.value ? ({ ...this.#sync.value } as NostrEvent) : undefined;
|
||||||
|
let isNew = false;
|
||||||
if (!next) {
|
if (!next) {
|
||||||
throw new Error("Cannot update with no previous value");
|
// create a new event if we already did sync and still undefined
|
||||||
|
if (this.#sync.didSync) {
|
||||||
|
const eb = new EventBuilder();
|
||||||
|
eb.fromLink(this.link);
|
||||||
|
next = eb.build();
|
||||||
|
isNew = true;
|
||||||
|
} else {
|
||||||
|
throw new Error("Cannot update with no previous value");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
next.content = JSON.stringify(val);
|
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) {
|
if (this.encrypt) {
|
||||||
next.content = await signer.nip4Encrypt(next.content, await signer.getPubKey());
|
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);
|
await this.#sync.update(next, signer, system, !isNew);
|
||||||
this.#json = val;
|
this.#json = val;
|
||||||
this.#json.id = next.id;
|
this.#json.id = next.id;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import EventEmitter from "eventemitter3";
|
import EventEmitter from "eventemitter3";
|
||||||
import { EventExt, EventType, NostrEvent, NostrLink, RequestBuilder, SystemInterface } from "..";
|
import { EventExt, EventSigner, EventType, NostrEvent, NostrLink, RequestBuilder, SystemInterface } from "..";
|
||||||
|
import { unixNow } from "@snort/shared";
|
||||||
|
import debug from "debug";
|
||||||
|
|
||||||
export interface SafeSyncEvents {
|
export interface SafeSyncEvents {
|
||||||
change: () => void;
|
change: () => void;
|
||||||
@ -15,10 +17,16 @@ export interface SafeSyncEvents {
|
|||||||
* 30078 (AppData)
|
* 30078 (AppData)
|
||||||
*/
|
*/
|
||||||
export class SafeSync extends EventEmitter<SafeSyncEvents> {
|
export class SafeSync extends EventEmitter<SafeSyncEvents> {
|
||||||
|
#log = debug("SafeSync");
|
||||||
#base: NostrEvent | undefined;
|
#base: NostrEvent | undefined;
|
||||||
|
#didSync = false;
|
||||||
|
|
||||||
get value() {
|
get value() {
|
||||||
return this.#base;
|
return this.#base ? Object.freeze({ ...this.#base }) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get didSync() {
|
||||||
|
return this.#didSync;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,22 +34,11 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
|
|||||||
* @param link A link to the kind
|
* @param link A link to the kind
|
||||||
*/
|
*/
|
||||||
async sync(link: NostrLink, system: SystemInterface) {
|
async sync(link: NostrLink, system: SystemInterface) {
|
||||||
if (link.kind === undefined) {
|
if (link.kind === undefined || link.author === undefined) {
|
||||||
throw new Error("Kind must be set");
|
throw new Error("Kind must be set");
|
||||||
}
|
}
|
||||||
|
|
||||||
const rb = new RequestBuilder("sync");
|
return await this.#sync(link, system);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -56,22 +53,57 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish an update for this event
|
* Publish an update for this event
|
||||||
|
*
|
||||||
|
* Event will be signed again inside
|
||||||
* @param ev
|
* @param ev
|
||||||
*/
|
*/
|
||||||
async update(ev: NostrEvent, system: SystemInterface) {
|
async update(next: NostrEvent, signer: EventSigner, system: SystemInterface, mustExist?: boolean) {
|
||||||
console.debug(this.#base, ev);
|
next.id = "";
|
||||||
this.#checkForUpdate(ev, true);
|
next.sig = "";
|
||||||
|
console.debug(this.#base, next);
|
||||||
|
|
||||||
const link = NostrLink.fromEvent(ev);
|
const signed = await this.#signEvent(next, signer);
|
||||||
|
const link = NostrLink.fromEvent(signed);
|
||||||
// always attempt to get a newer version before broadcasting
|
// always attempt to get a newer version before broadcasting
|
||||||
await this.sync(link, system);
|
await this.#sync(link, system);
|
||||||
this.#checkForUpdate(ev, true);
|
this.#checkForUpdate(signed, mustExist ?? true);
|
||||||
|
|
||||||
system.BroadcastEvent(ev);
|
system.BroadcastEvent(signed);
|
||||||
this.#base = ev;
|
this.#base = signed;
|
||||||
this.emit("change");
|
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 #sync(link: NostrLink, system: SystemInterface) {
|
||||||
|
const rb = new RequestBuilder(`sync:${link.encode()}`);
|
||||||
|
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));
|
||||||
|
this.#log("Got result %O", res);
|
||||||
|
if (res && res.created_at > (this.#base?.created_at ?? 0)) {
|
||||||
|
this.#base = res;
|
||||||
|
this.emit("change");
|
||||||
|
}
|
||||||
|
this.#didSync = true;
|
||||||
|
return this.#base;
|
||||||
|
}
|
||||||
|
|
||||||
#checkForUpdate(ev: NostrEvent, mustExist: boolean) {
|
#checkForUpdate(ev: NostrEvent, mustExist: boolean) {
|
||||||
if (!this.#base) {
|
if (!this.#base) {
|
||||||
if (mustExist) {
|
if (mustExist) {
|
||||||
@ -93,9 +125,5 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
|
|||||||
if (this.#base.created_at >= ev.created_at) {
|
if (this.#base.created_at >= ev.created_at) {
|
||||||
throw new Error("Same version, cannot update");
|
throw new Error("Same version, cannot update");
|
||||||
}
|
}
|
||||||
const link = NostrLink.fromEvent(ev);
|
|
||||||
if (!link.matchesEvent(this.#base)) {
|
|
||||||
throw new Error("Invalid event");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user