diff --git a/src/Db/index.ts b/src/Db/index.ts index 8834c96df..46ec27cfd 100644 --- a/src/Db/index.ts +++ b/src/Db/index.ts @@ -1,23 +1,35 @@ import Dexie, { Table } from "dexie"; +import { TaggedRawEvent, u256 } from "Nostr"; import { MetadataCache } from "State/Users"; import { hexToBech32 } from "Util"; export const NAME = 'snortDB' -export const VERSION = 2 +export const VERSION = 3 + +export interface SubCache { + id: string, + ids: u256[], + until?: number, + since?: number, +} const STORES = { - users: '++pubkey, name, display_name, picture, nip05, npub' + users: '++pubkey, name, display_name, picture, nip05, npub', + events: '++id, pubkey, created_at', + feeds: '++id' } export class SnortDB extends Dexie { users!: Table; + events!: Table; + feeds!: Table; constructor() { super(NAME); - this.version(VERSION).stores(STORES).upgrade(tx => { - return tx.table("users").toCollection().modify(user => { + this.version(VERSION).stores(STORES).upgrade(async tx => { + await tx.table("users").toCollection().modify(user => { user.npub = hexToBech32("npub", user.pubkey) - }) + }); }); } } diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index 9d46ae4bd..2f3b52851 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -78,8 +78,8 @@ export default function useEventPublisher() { } return { - nip42Auth: async (challenge: string, relay:string) => { - if(pubKey) { + nip42Auth: async (challenge: string, relay: string) => { + if (pubKey) { const ev = NEvent.ForPubKey(pubKey); ev.Kind = EventKind.Auth; ev.Content = ""; @@ -112,17 +112,17 @@ export default function useEventPublisher() { ev.Kind = EventKind.Lists; ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length)) keys.forEach(p => { - ev.Tags.push(new Tag(["p", p], ev.Tags.length)) + ev.Tags.push(new Tag(["p", p], ev.Tags.length)) }) let content = "" if (priv.length > 0) { - const ps = priv.map(p => ["p", p]) - const plaintext = JSON.stringify(ps) - if (hasNip07 && !privKey) { - content = await barierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext)); - } else if (privKey) { - content = await ev.EncryptData(plaintext, pubKey, privKey) - } + const ps = priv.map(p => ["p", p]) + const plaintext = JSON.stringify(ps) + if (hasNip07 && !privKey) { + content = await barierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext)); + } else if (privKey) { + content = await ev.EncryptData(plaintext, pubKey, privKey) + } } ev.Content = content; return await signEvent(ev); diff --git a/src/Feed/FollowersFeed.ts b/src/Feed/FollowersFeed.ts index 7e0f65eb8..5c97e4872 100644 --- a/src/Feed/FollowersFeed.ts +++ b/src/Feed/FollowersFeed.ts @@ -7,7 +7,7 @@ import useSubscription from "Feed/Subscription"; export default function useFollowersFeed(pubkey: HexKey) { const sub = useMemo(() => { let x = new Subscriptions(); - x.Id = "followers"; + x.Id = `followers:${pubkey.slice(0, 12)}`; x.Kinds = new Set([EventKind.ContactList]); x.PTags = new Set([pubkey]); diff --git a/src/Feed/FollowsFeed.ts b/src/Feed/FollowsFeed.ts index b1f4c883e..280040004 100644 --- a/src/Feed/FollowsFeed.ts +++ b/src/Feed/FollowsFeed.ts @@ -7,7 +7,7 @@ import useSubscription, { NoteStore } from "Feed/Subscription"; export default function useFollowsFeed(pubkey: HexKey) { const sub = useMemo(() => { let x = new Subscriptions(); - x.Id = "follows"; + x.Id = `follows:${pubkey.slice(0, 12)}`; x.Kinds = new Set([EventKind.ContactList]); x.Authors = new Set([pubkey]); diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 847dbc61f..f3b67792c 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -77,10 +77,10 @@ export default function useLoginFeed() { return dms; }, [pubKey]); - const metadataFeed = useSubscription(subMetadata, { leaveOpen: true }); - const notificationFeed = useSubscription(subNotification, { leaveOpen: true }); - const dmsFeed = useSubscription(subDms, { leaveOpen: true }); - const mutedFeed = useSubscription(subMuted, { leaveOpen: true }); + const metadataFeed = useSubscription(subMetadata, { leaveOpen: true, cache: true }); + const notificationFeed = useSubscription(subNotification, { leaveOpen: true, cache: true }); + const dmsFeed = useSubscription(subDms, { leaveOpen: true, cache: true }); + const mutedFeed = useSubscription(subMuted, { leaveOpen: true, cache: true }); useEffect(() => { let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList); diff --git a/src/Feed/ProfileFeed.ts b/src/Feed/ProfileFeed.ts index c86694101..4c1d5a013 100644 --- a/src/Feed/ProfileFeed.ts +++ b/src/Feed/ProfileFeed.ts @@ -1,6 +1,4 @@ -import { useLiveQuery } from "dexie-react-hooks"; -import { useEffect, useMemo } from "react"; -import { RootState } from "State/Store"; +import { useEffect } from "react"; import { MetadataCache } from "State/Users"; import { useKey, useKeys } from "State/Users/Hooks"; import { HexKey } from "Nostr"; diff --git a/src/Feed/Subscription.ts b/src/Feed/Subscription.ts index 54797dad9..73a967cda 100644 --- a/src/Feed/Subscription.ts +++ b/src/Feed/Subscription.ts @@ -3,6 +3,7 @@ import { System } from "Nostr/System"; import { TaggedRawEvent } from "Nostr"; import { Subscriptions } from "Nostr/Subscriptions"; import { debounce } from "Util"; +import { db } from "Db"; export type NoteStore = { notes: Array, @@ -10,7 +11,8 @@ export type NoteStore = { }; export type UseSubscriptionOptions = { - leaveOpen: boolean + leaveOpen: boolean, + cache: boolean } interface ReducerArg { @@ -77,33 +79,49 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use const [state, dispatch] = useReducer(notesReducer, initStore); const [debounceOutput, setDebounceOutput] = useState(0); const [subDebounce, setSubDebounced] = useState(); + const useCache = useMemo(() => options?.cache === true, [options]); useEffect(() => { if (sub) { return debounce(DebounceMs, () => { - dispatch({ - type: "END", - end: false - }); setSubDebounced(sub); }); } }, [sub, options]); useEffect(() => { - if (sub) { - sub.OnEvent = (e) => { + if (subDebounce) { + dispatch({ + type: "END", + end: false + }); + + if (useCache) { + // preload notes from db + PreloadNotes(subDebounce.Id) + .then(ev => { + dispatch({ + type: "EVENT", + ev: ev + }); + }) + .catch(console.warn); + } + subDebounce.OnEvent = (e) => { dispatch({ type: "EVENT", ev: e }); + if (useCache) { + db.events.put(e); + } }; - sub.OnEnd = (c) => { + subDebounce.OnEnd = (c) => { if (!(options?.leaveOpen ?? false)) { - c.RemoveSubscription(sub.Id); - if (sub.IsFinished()) { - System.RemoveSubscription(sub.Id); + c.RemoveSubscription(subDebounce.Id); + if (subDebounce.IsFinished()) { + System.RemoveSubscription(subDebounce.Id); } } dispatch({ @@ -112,14 +130,23 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use }); }; - console.debug("Adding sub: ", sub.ToObject()); - System.AddSubscription(sub); + console.debug("Adding sub: ", subDebounce.ToObject()); + System.AddSubscription(subDebounce); return () => { - console.debug("Removing sub: ", sub.ToObject()); - System.RemoveSubscription(sub.Id); + console.debug("Removing sub: ", subDebounce.ToObject()); + System.RemoveSubscription(subDebounce.Id); }; } - }, [subDebounce]); + }, [subDebounce, useCache]); + + useEffect(() => { + if (subDebounce && useCache) { + return debounce(500, () => { + TrackNotesInFeed(subDebounce.Id, state.notes) + .catch(console.warn); + }); + } + }, [state, useCache]); useEffect(() => { return debounce(DebounceMs, () => { @@ -140,4 +167,24 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use }); } } +} + +/** + * Lookup cached copy of feed + */ +const PreloadNotes = async (id: string): Promise => { + const feed = await db.feeds.get(id); + if (feed) { + const events = await db.events.bulkGet(feed.ids); + return events.filter(a => a !== undefined).map(a => a!); + } + return []; +} + +const TrackNotesInFeed = async (id: string, notes: TaggedRawEvent[]) => { + const existing = await db.feeds.get(id); + const ids = Array.from(new Set([...(existing?.ids || []), ...notes.map(a => a.id)])); + const since = notes.reduce((acc, v) => acc > v.created_at ? v.created_at : acc, +Infinity); + const until = notes.reduce((acc, v) => acc < v.created_at ? v.created_at : acc, -Infinity); + await db.feeds.put({ id, ids, since, until }); } \ No newline at end of file diff --git a/src/Feed/ThreadFeed.ts b/src/Feed/ThreadFeed.ts index aef2c024b..25d52d1ad 100644 --- a/src/Feed/ThreadFeed.ts +++ b/src/Feed/ThreadFeed.ts @@ -38,7 +38,7 @@ export default function useThreadFeed(id: u256) { return thisSub; }, [trackingEvents, pref, id]); - const main = useSubscription(sub, { leaveOpen: true }); + const main = useSubscription(sub, { leaveOpen: true, cache: true }); useEffect(() => { if (main.store) { diff --git a/src/Feed/TimelineFeed.ts b/src/Feed/TimelineFeed.ts index 0fc9c67a1..0e3d4b5a4 100644 --- a/src/Feed/TimelineFeed.ts +++ b/src/Feed/TimelineFeed.ts @@ -14,6 +14,7 @@ export interface TimelineFeedOptions { export interface TimelineSubject { type: "pubkey" | "hashtag" | "global" | "ptag" | "keyword", + discriminator: string, items: string[] } @@ -32,7 +33,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel } let sub = new Subscriptions(); - sub.Id = `timeline:${subject.type}`; + sub.Id = `timeline:${subject.type}:${subject.discriminator}`; sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]); switch (subject.type) { case "pubkey": { @@ -54,7 +55,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel } } return sub; - }, [subject.type, subject.items]); + }, [subject.type, subject.items, subject.discriminator]); const sub = useMemo(() => { let sub = createSub(); @@ -86,7 +87,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel return sub; }, [until, since, options.method, pref, createSub]); - const main = useSubscription(sub, { leaveOpen: true }); + const main = useSubscription(sub, { leaveOpen: true, cache: true }); const subRealtime = useMemo(() => { let subLatest = createSub(); @@ -98,7 +99,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel return subLatest; }, [pref, createSub]); - const latest = useSubscription(subRealtime, { leaveOpen: true }); + const latest = useSubscription(subRealtime, { leaveOpen: true, cache: false }); const subNext = useMemo(() => { let sub: Subscriptions | undefined; @@ -111,7 +112,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel return sub ?? null; }, [trackingEvents, pref, subject.type]); - const others = useSubscription(subNext, { leaveOpen: true }); + const others = useSubscription(subNext, { leaveOpen: true, cache: true }); const subParents = useMemo(() => { if (trackingParentEvents.length > 0) { diff --git a/src/Pages/HashTagsPage.tsx b/src/Pages/HashTagsPage.tsx index 4390c60c8..7f5400a3a 100644 --- a/src/Pages/HashTagsPage.tsx +++ b/src/Pages/HashTagsPage.tsx @@ -8,7 +8,7 @@ const HashTagsPage = () => { return ( <>

#{tag}

- + ) } diff --git a/src/Pages/Notifications.tsx b/src/Pages/Notifications.tsx index 0b0634927..e9019bf8e 100644 --- a/src/Pages/Notifications.tsx +++ b/src/Pages/Notifications.tsx @@ -17,7 +17,7 @@ export default function NotificationsPage() { return ( <> {pubkey ? - + : null} ) diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index d6d6b5649..5496e1e2a 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -105,7 +105,7 @@ export default function ProfilePage() { function tabContent() { switch (tab) { case ProfileTab.Notes: - return ; + return ; case ProfileTab.Follows: { if (isMe) { return ( @@ -195,7 +195,7 @@ export default function ProfilePage() { return ( <>
- {user?.banner && } + {user?.banner && }
{avatar()} {userDetails()} diff --git a/src/Pages/Root.tsx b/src/Pages/Root.tsx index 9e89694c8..aeef0a03a 100644 --- a/src/Pages/Root.tsx +++ b/src/Pages/Root.tsx @@ -29,7 +29,7 @@ export default function RootPage() { } const isGlobal = loggedOut || tab === RootTab.Global; - const timelineSubect: TimelineSubject = isGlobal ? { type: "global", items: [] } : { type: "pubkey", items: follows }; + const timelineSubect: TimelineSubject = isGlobal ? { type: "global", items: [], discriminator: "all" } : { type: "pubkey", items: follows, discriminator: "follows" }; return ( <> {pubKey ? <> diff --git a/src/Pages/SearchPage.tsx b/src/Pages/SearchPage.tsx index 7e51e1f54..28ba25b12 100644 --- a/src/Pages/SearchPage.tsx +++ b/src/Pages/SearchPage.tsx @@ -43,7 +43,7 @@ const SearchPage = () => {
setSearch(e.target.value)} />
- {keyword && } + {keyword && } ) } diff --git a/src/State/Users.ts b/src/State/Users.ts index 9f92183a1..b8ff079b0 100644 --- a/src/State/Users.ts +++ b/src/State/Users.ts @@ -35,20 +35,20 @@ export function mapEventToProfile(ev: TaggedRawEvent) { ...data } as MetadataCache; } catch (e) { - console.error("Failed to parse JSON", ev, e); + console.error("Failed to parse JSON", ev, e); } } export interface UsersDb { - isAvailable(): Promise - query(str: string): Promise - find(key: HexKey): Promise - add(user: MetadataCache): Promise - put(user: MetadataCache): Promise - bulkAdd(users: MetadataCache[]): Promise - bulkGet(keys: HexKey[]): Promise - bulkPut(users: MetadataCache[]): Promise - update(key: HexKey, fields: Record): Promise + isAvailable(): Promise + query(str: string): Promise + find(key: HexKey): Promise + add(user: MetadataCache): Promise + put(user: MetadataCache): Promise + bulkAdd(users: MetadataCache[]): Promise + bulkGet(keys: HexKey[]): Promise + bulkPut(users: MetadataCache[]): Promise + update(key: HexKey, fields: Record): Promise } export interface UsersStore { @@ -65,7 +65,7 @@ const UsersSlice = createSlice({ initialState: InitState, reducers: { setUsers(state, action: PayloadAction>) { - state.users = action.payload + state.users = action.payload } } }); diff --git a/src/State/Users/Db.ts b/src/State/Users/Db.ts index a2e5bf574..8983a414b 100644 --- a/src/State/Users/Db.ts +++ b/src/State/Users/Db.ts @@ -5,6 +5,8 @@ import { UsersDb, MetadataCache, setUsers } from "State/Users"; import store from "State/Store"; class IndexedDb implements UsersDb { + ready: boolean = false; + isAvailable() { if ("indexedDB" in window) { return new Promise((resolve) => { @@ -26,12 +28,12 @@ class IndexedDb implements UsersDb { query(q: string) { return idb.users - .where("npub").startsWithIgnoreCase(q) - .or("name").startsWithIgnoreCase(q) - .or("display_name").startsWithIgnoreCase(q) - .or("nip05").startsWithIgnoreCase(q) - .limit(5) - .toArray() + .where("npub").startsWithIgnoreCase(q) + .or("name").startsWithIgnoreCase(q) + .or("display_name").startsWithIgnoreCase(q) + .or("nip05").startsWithIgnoreCase(q) + .limit(5) + .toArray() } bulkGet(keys: HexKey[]) { @@ -41,7 +43,7 @@ class IndexedDb implements UsersDb { add(user: MetadataCache) { return idb.users.add(user) } - + put(user: MetadataCache) { return idb.users.put(user) } @@ -88,21 +90,21 @@ class ReduxUsersDb implements UsersDb { async add(user: MetadataCache) { const state = store.getState() const { users } = state.users - store.dispatch(setUsers({...users, [user.pubkey]: user })) + store.dispatch(setUsers({ ...users, [user.pubkey]: user })) } async put(user: MetadataCache) { const state = store.getState() const { users } = state.users - store.dispatch(setUsers({...users, [user.pubkey]: user })) + store.dispatch(setUsers({ ...users, [user.pubkey]: user })) } async bulkAdd(newUserProfiles: MetadataCache[]) { const state = store.getState() const { users } = state.users const newUsers = newUserProfiles.reduce(groupByPubkey, {}) - store.dispatch(setUsers({...users, ...newUsers })) + store.dispatch(setUsers({ ...users, ...newUsers })) } async bulkGet(keys: HexKey[]) { @@ -118,8 +120,8 @@ class ReduxUsersDb implements UsersDb { const state = store.getState() const { users } = state.users const current = users[key] - const updated = {...current, ...fields } - store.dispatch(setUsers({...users, [key]: updated })) + const updated = { ...current, ...fields } + store.dispatch(setUsers({ ...users, [key]: updated })) } async bulkPut(newUsers: MetadataCache[]) { @@ -138,6 +140,7 @@ let db: UsersDb = inMemoryDb indexedDb.isAvailable().then((available) => { if (available) { console.debug('Using Indexed DB') + indexedDb.ready = true; db = indexedDb; } else { console.debug('Using in-memory DB') diff --git a/src/State/Users/Hooks.ts b/src/State/Users/Hooks.ts index 95d94e63a..345daff19 100644 --- a/src/State/Users/Hooks.ts +++ b/src/State/Users/Hooks.ts @@ -10,10 +10,10 @@ export function useQuery(query: string, limit: number = 5) { const allUsers = useLiveQuery( () => db.query(query) - .catch((err) => { - console.error(err) - return inMemoryDb.query(query) - }), + .catch((err) => { + console.error(err) + return inMemoryDb.query(query) + }), [query], ) @@ -26,14 +26,14 @@ export function useKey(pubKey: HexKey) { const defaultUser = users[pubKey] const user = useLiveQuery(async () => { - if (pubKey) { - try { - return await db.find(pubKey); - } catch (error) { - console.error(error) - return defaultUser - } - } + if (pubKey) { + try { + return await db.find(pubKey); + } catch (error) { + console.error(error) + return defaultUser + } + } }, [pubKey, defaultUser]); return user @@ -42,17 +42,17 @@ export function useKey(pubKey: HexKey) { export function useKeys(pubKeys: HexKey[]): Map { const db = getDb() const dbUsers = useLiveQuery(async () => { - if (pubKeys) { - try { - const ret = await db.bulkGet(pubKeys); - return new Map(ret.map(a => [a.pubkey, a])) - } catch (error) { - console.error(error) - const ret = await inMemoryDb.bulkGet(pubKeys); - return new Map(ret.map(a => [a.pubkey, a])) - } + if (pubKeys) { + try { + const ret = await db.bulkGet(pubKeys); + return new Map(ret.map(a => [a.pubkey, a])) + } catch (error) { + console.error(error) + const ret = await inMemoryDb.bulkGet(pubKeys); + return new Map(ret.map(a => [a.pubkey, a])) } - return new Map() + } + return new Map() }, [pubKeys]); return dbUsers!