diff --git a/src/js/nostr/EventDB.ts b/src/js/nostr/EventDB.ts index 28086e04..5ec7fd26 100644 --- a/src/js/nostr/EventDB.ts +++ b/src/js/nostr/EventDB.ts @@ -66,7 +66,7 @@ export class EventDB { const clone = this.pack(event); const flatTags = clone.tags - .filter((tag) => ['e', 'p'].includes(tag[0])) + .filter((tag) => ['e', 'p', 'd'].includes(tag[0])) .map((tag) => tag.join('_')); try { @@ -132,6 +132,8 @@ export class EventDB { query.flatTags = { $contains: 'e_' + filter['#e'].map(ID) }; } else if (filter['#p']) { query.flatTags = { $contains: 'p_' + filter['#p'].map(ID) }; + } else if (filter['#d']) { + query.flatTags = { $contains: 'd_' + filter['#d'].map(ID) }; } if (filter.since && filter.until) { query.created_at = { $between: [filter.since, filter.until] }; diff --git a/src/js/nostr/Events.ts b/src/js/nostr/Events.ts index 7444da16..7527347c 100644 --- a/src/js/nostr/Events.ts +++ b/src/js/nostr/Events.ts @@ -283,7 +283,6 @@ const Events = { async saveDMToLocalState(event: DecryptedEvent, chatNode: Node) { const latest = chatNode.get('latest'); const e = await latest.once(undefined, true); - console.log('latest', e, event); if (!e || !e.created_at || e.created_at < event.created_at) { latest.put({ id: event.id, created_at: event.created_at, text: event.text }); } @@ -336,7 +335,6 @@ const Events = { EventDB.insert(event); if (!maybeSecretChat) { - console.log('saving dm to local state', chatId); this.saveDMToLocalState(event, localState.get('chats').get(chatId)); } }, diff --git a/src/js/nostr/IndexedDB.ts b/src/js/nostr/IndexedDB.ts index ac58ee0c..bc93dcaf 100644 --- a/src/js/nostr/IndexedDB.ts +++ b/src/js/nostr/IndexedDB.ts @@ -76,6 +76,9 @@ const IndexedDB = { const eventTags = event.tags ?.filter((tag) => { + if (tag[0] === 'd') { + return true; + } if (tag[0] === 'e') { return true; } @@ -125,7 +128,7 @@ const IndexedDB = { }, 1000), subscribeToTags: throttle(async function (this: typeof IndexedDB) { - const tagPairs = [...this.subscribedTags].map((tag) => tag.split('|')); // assuming you used '|' as delimiter + const tagPairs = [...this.subscribedTags].map((tag) => tag.split('|')); this.subscribedTags.clear(); await db.tags .where('[type+value]') @@ -164,6 +167,15 @@ const IndexedDB = { return; } + if (filter['#d'] && Array.isArray(filter['#d'])) { + for (const eventId of filter['#d']) { + this.subscribedTags.add('d|' + eventId); + } + + await this.subscribeToTags(); + return; + } + if (filter.ids?.length) { filter.ids.forEach((id) => this.subscribedEventIds.add(id)); await this.subscribeToEventIds(); diff --git a/src/js/nostr/Session.ts b/src/js/nostr/Session.ts index a32fedc5..add5e82c 100644 --- a/src/js/nostr/Session.ts +++ b/src/js/nostr/Session.ts @@ -1,7 +1,7 @@ import localForage from 'localforage'; -import { Event, Filter } from 'nostr-tools'; import { route } from 'preact-router'; +import publicState from '@/state/PublicState.ts'; import Helpers from '@/utils/Helpers.tsx'; import localState from '../state/LocalState.ts'; @@ -11,7 +11,6 @@ import { ID } from '../utils/UniqueIds'; import Events from './Events'; import IndexedDB from './IndexedDB'; import Key from './Key'; -import { Path } from './path'; import PubSub from './PubSub'; import Relays from './Relays'; import SocialNetwork from './SocialNetwork'; @@ -25,9 +24,6 @@ try { let loggedIn = false; const Session = { - public: undefined as Path | undefined, - private: undefined as Path | undefined, - async logOut() { route('/'); /* @@ -81,57 +77,36 @@ const Session = { SocialNetwork.followersByUser.set(myId, new Set()); SocialNetwork.usersByFollowDistance.set(0, new Set([myId])); this.loadMyFollowList(); - const subscribe = (filters: Filter[], callback: (event: Event) => void): string => { - const filter = filters[0]; - const key = filter['#d']?.[0]; - if (key) { - const event = Events.keyValueEvents.get(key); - if (event) { - callback(event); - } - } - PubSub.subscribe(filters[0], callback, true); - return '0'; - }; localState.get('globalFilter').once((globalFilter) => { if (!globalFilter) { localState.get('globalFilter').put(Events.DEFAULT_GLOBAL_FILTER); } }); - // TODO move private and public to State.ts - this.private = new Path( - (...args) => Events.publish(...args), - subscribe, - () => this.unsubscribe(), - { authors: [myPub] }, - (...args) => Key.encrypt(...args), - (...args) => Key.decrypt(...args), - ); - this.public = new Path( - (...args) => Events.publish(...args), - subscribe, - () => this.unsubscribe(), - { authors: [myPub] }, - ); - this.public.get('notifications/lastOpened', (time) => { - if (time !== Events.notificationsSeenTime) { - Events.notificationsSeenTime = time; - Events.updateUnseenNotificationCount(); - } - }); - this.public.get('settings/colorScheme', (colorScheme) => { - if (colorScheme === 'light') { - document.documentElement.setAttribute('data-theme', 'light'); - return; - } else if (colorScheme === 'default') { - if (window.matchMedia('(prefers-color-scheme: light)').matches) { - //OS theme setting detected as dark + publicState + .get('notifications') + .get('lastOpened') + .on((time) => { + if (time !== Events.notificationsSeenTime) { + Events.notificationsSeenTime = time; + Events.updateUnseenNotificationCount(); + } + }); + publicState + .get('settings') + .get('colorScheme') + .on((colorScheme) => { + if (colorScheme === 'light') { document.documentElement.setAttribute('data-theme', 'light'); return; + } else if (colorScheme === 'default') { + if (window.matchMedia('(prefers-color-scheme: light)').matches) { + //OS theme setting detected as dark + document.documentElement.setAttribute('data-theme', 'light'); + return; + } } - } - document.documentElement.setAttribute('data-theme', 'dark'); - }); + document.documentElement.setAttribute('data-theme', 'dark'); + }); if (window.location.pathname === '/') { Relays.init(); } diff --git a/src/js/nostr/path.ts b/src/js/nostr/path.ts deleted file mode 100644 index 948c9bf4..00000000 --- a/src/js/nostr/path.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** -Path API for Nostr, built on NIP33 replaceable-by-tag events of kind 30000. - -``` -const path = new Path(myPublishFn, mySubscribeFn, myUnsubscribeFn, { authors: [myPubKey} }) -path.set('reactions/[noteID]', '😎') -path.get('reactions/[noteID]', (value, path, event) => console.log(event.pubkey, 'reacted with', value)) -``` - -TODO: -``` -path.list('reactions', (value, path, event) => { - console.log( - event.pubkey, 'reacted to', path.slice('/')[1], 'with', value - ) - }, { authors: myFollows } -) -``` - -In-memory caches the most recent event for the subscribed path, and only calls back with the most recent value. - -This API allows us to build all kinds of applications on top of Nostr (github replacement for example) without having to -specify new event kinds all the time and implement them in all applications and relays. - -NIP33: https://github.com/nostr-protocol/nips/blob/master/33.md - */ -import { Event, Filter, matchFilter } from 'nostr-tools'; - -const EVENT_KIND = 30000; - -type CompleteEvent = Event & { id: string }; -type PathCallback = (value: any, path: string, event: Event) => void; -type Listener = { - filter: Filter; - callback: PathCallback; - subscription?: string; - off: () => void; -}; -type Publish = (event: Partial) => Promise; -type Subscribe = (filters: Filter[], callback: (event: Event) => void) => string; -type Unsubscribe = (id: string) => void; -type Encrypt = (content: string) => Promise; -type Decrypt = (content: string) => Promise; - -export function getEventPath(event: Event): string | undefined { - return event.tags?.find(([t]) => t === 'd')?.[1]; -} - -export function getFilterPath(filter: Filter): string | undefined { - return filter['#d']?.[0]; -} - -// We can later add other storages like IndexedDB or localStorage -class MemoryStorage { - eventsByPathAndAuthor = new Map>(); - - // returns a boolean indicating whether the event was added (newer than existing) - set(event: Event): boolean { - const path = getEventPath(event); - if (!path) { - //throw new Error(`event has no d tag: ${JSON.stringify(event)}`) - return false; - } - if (!this.eventsByPathAndAuthor.has(path)) { - this.eventsByPathAndAuthor.set(path, new Map()); - } - let valuesByAuthor = this.eventsByPathAndAuthor.get(path); - if (!valuesByAuthor) { - valuesByAuthor = new Map(); - this.eventsByPathAndAuthor.set(path, valuesByAuthor); - } - const existing = valuesByAuthor?.get(event.pubkey); - if (existing && existing.created_at > event.created_at) { - return false; - } - valuesByAuthor.set(event.pubkey, event); - return true; - } - - get(filter: Filter, callback: (event: Event) => void) { - const path = getFilterPath(filter); - if (!path) { - throw new Error(`filter has no #d tag: ${JSON.stringify(filter)}`); - } - const valuesByAuthor = this.eventsByPathAndAuthor.get(path); - if (!valuesByAuthor) { - return; - } - for (const [author, event] of valuesByAuthor) { - if (!filter.authors || filter.authors.indexOf(author) !== -1) { - callback(event); - } - } - } -} - -export class Path { - store: MemoryStorage; - listeners: Map; - publish: Publish; - subscribe: Subscribe; - unsubscribe: Unsubscribe; - filter: Filter; - encrypt?: Encrypt; - decrypt?: Decrypt; - - constructor( - publish: Publish, - subscribe: Subscribe, - unsubscribe: Unsubscribe, - filter: Filter, - encrypt?: Encrypt, - decrypt?: Decrypt, - ) { - this.publish = publish; - this.subscribe = subscribe; - this.unsubscribe = unsubscribe; - this.filter = filter; - this.encrypt = encrypt; - this.decrypt = decrypt; - this.store = new MemoryStorage(); - this.listeners = new Map(); - } - - async publishSetEvent(path: string, value: any): Promise { - let content: string; - if (this.encrypt) { - // TODO: path should be deterministically encrypted hash(path + secret) but NIP07 provides no way for that - const contentStr = JSON.stringify(value); - content = await this.encrypt(contentStr); - if (contentStr === content) { - throw new Error(`Encryption failed: ${contentStr} === ${content}`); - } - } else { - content = JSON.stringify(value); - } - return this.publish({ - // kind does not accept 30000... - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - kind: EVENT_KIND, - tags: [['d', path]], - content, - created_at: Math.floor(Date.now() / 1000), - }); - } - - async set(path: string, value: any): Promise { - try { - const event = await this.publishSetEvent(path, value); - if (event) { - if (this.store.set(event)) { - this.notifyListeners(event as CompleteEvent); - } - return true; - } - } catch (e) { - console.error(e); - } - return false; - } - - async getEventValue(event: Event): Promise { - let value = this.decrypt ? await this.decrypt(event.content) : event.content; - try { - value = JSON.parse(value); - } catch (e) { - throw new Error(`Failed to parse event content: ${value}`); - } - return value; - } - - get(path: string, callback: PathCallback, filter = {}): Listener { - filter = Object.assign({}, filter, this.filter, { - '#d': [path], - kinds: [EVENT_KIND], - }); - const listener = this.addListener(filter, callback); - this.store.get(filter, (event) => this.callbackFromEvent(event, callback)); - this.subscribe([filter], async (event) => { - if (this.store.set(event)) { - this.notifyListeners(event as CompleteEvent); - } - }); - return listener; - } - - addListener(filter: Filter, callback: PathCallback): Listener { - const id = Math.random().toString(36).substr(2, 9); - const listener: Listener = { - filter, - callback, - off: () => { - this.listeners.delete(id); - if (listener.subscription) { - this.unsubscribe(listener.subscription); - } - }, - }; - this.listeners.set(id, listener); - return listener; - } - - removeListener(id: string) { - const listener = this.listeners.get(id); - if (listener) { - listener.off(); - } - } - - callbackFromEvent(event: Event, callback: PathCallback) { - const path = getEventPath(event); - if (!path) { - throw new Error(`event has no d tag: ${JSON.stringify(event)}`); - } - this.getEventValue(event).then((value) => { - callback(value, path, event); - }); - } - - async notifyListeners(event: CompleteEvent) { - for (const listener of this.listeners.values()) { - if (matchFilter(listener.filter, event)) { - this.callbackFromEvent(event, listener.callback); - } - } - } -} diff --git a/src/js/state/IrisNostrAdapter.ts b/src/js/state/IrisNostrAdapter.ts new file mode 100644 index 00000000..fb05e943 --- /dev/null +++ b/src/js/state/IrisNostrAdapter.ts @@ -0,0 +1,78 @@ +import Events from '@/nostr/Events'; +import Key from '@/nostr/Key'; +import PubSub from '@/nostr/PubSub'; +import { Adapter, Callback, NodeValue, Unsubscribe } from '@/state/types.ts'; + +export default class IrisNostrAdapter extends Adapter { + seenValues = new Map(); + + get(path: string, callback: Callback): Unsubscribe { + const unsubObj = { fn: null as any }; + + unsubObj.fn = PubSub.subscribe( + // @ts-ignore + { authors: [Key.getPubKey()], kinds: [30000], '#d': [path] }, + (event) => { + callback(JSON.parse(event.content), path, event.created_at * 1000, () => unsubObj.fn()); + }, + ); + return () => unsubObj.fn(); + } + + async set(path: string, value: NodeValue) { + if (value && value.updatedAt === undefined) { + throw new Error(`Invalid value: ${JSON.stringify(value)}`); + } + + const seen = this.seenValues.get(path); + if (seen && seen.updatedAt <= value.updatedAt) { + return; + } + this.seenValues.set(path, value); + + console.log('set state', path, value); + + const directory = path.split('/').slice(0, -1).join('/'); + const e = await Events.publish({ + // @ts-ignore + kind: 30000, + content: JSON.stringify(value.value), + created_at: Math.ceil(value.updatedAt / 1000), + tags: [ + ['d', path], + ['f', directory], + ], + }); + console.log('published state event', e); + } + + list(path: string, callback: Callback): Unsubscribe { + const unsubObj = { fn: null as any }; + + unsubObj.fn = PubSub.subscribe( + // @ts-ignore + { authors: [Key.getPubKey()], kinds: [30000] }, + (event) => { + const childPath = event.tags.find((tag) => { + if (tag[0] === 'd') { + const remainingPath = tag[1].replace(`${path}/`, ''); + if ( + remainingPath.length && + tag[1].startsWith(`${path}/`) && + !remainingPath.includes('/') + ) { + return true; + } + } + })?.[1]; + + if (childPath) { + callback(JSON.parse(event.content), childPath, event.created_at * 1000, () => + unsubObj.fn(), + ); + } + }, + ); + return () => unsubObj.fn(); + } +} diff --git a/src/js/state/LocalForageAdapter.ts b/src/js/state/LocalForageAdapter.ts index da4633a0..4a10aa37 100644 --- a/src/js/state/LocalForageAdapter.ts +++ b/src/js/state/LocalForageAdapter.ts @@ -37,7 +37,7 @@ export default class LocalForageAdapter extends Adapter { .then((keys) => { keys.forEach((key) => { const remainingPath = key.replace(`${path}/`, ''); - if (key.startsWith(`${path}/`) && !remainingPath.includes('/')) { + if (key.startsWith(`${path}/`) && remainingPath.length && !remainingPath.includes('/')) { localForage .getItem(key) .then((result) => { diff --git a/src/js/state/MemoryAdapter.ts b/src/js/state/MemoryAdapter.ts index 0ec8daed..85a70b0b 100644 --- a/src/js/state/MemoryAdapter.ts +++ b/src/js/state/MemoryAdapter.ts @@ -23,7 +23,11 @@ export default class MemoryAdapter extends Adapter { list(path: string, callback: Callback): Unsubscribe { for (const [storedPath, storedValue] of this.storage) { const remainingPath = storedPath.replace(`${path}/`, ''); - if (storedPath.startsWith(`${path}/`) && !remainingPath.includes('/')) { + if ( + storedPath.startsWith(`${path}/`) && + remainingPath.length && + !remainingPath.includes('/') + ) { callback(storedValue.value, storedPath, storedValue.updatedAt, () => {}); } } diff --git a/src/js/state/Node.ts b/src/js/state/Node.ts index 55f6476a..8db67809 100644 --- a/src/js/state/Node.ts +++ b/src/js/state/Node.ts @@ -98,7 +98,6 @@ export default class Node { this.parent.children.set(childName, this); } for (const [id, callback] of this.parent.map_subscriptions) { - console.log('calling map callback of ', this.parent.id, ' with ', this.id, value); callback(value, this.id, updatedAt, () => { this.parent?.map_subscriptions.delete(id); }); @@ -130,7 +129,7 @@ export default class Node { * @param callback */ on(callback: Callback, returnIfUndefined: boolean = false): Unsubscribe { - let latest: NodeValue | null = null; + let latest: NodeValue | null = null; // replace with this.value? const cb = (value, path, updatedAt, unsubscribe) => { if (value === undefined) { if (returnIfUndefined) { @@ -168,10 +167,15 @@ export default class Node { map(callback: Callback): Unsubscribe { const id = this.counter++; this.map_subscriptions.set(id, callback); + const latestMap = new Map(); // replace with this.value? const cb = (value, path, updatedAt) => { + const latest = latestMap.get(path); + if (latest && latest.updatedAt >= updatedAt) { + return; + } + latestMap.set(path, { value, updatedAt }); const childName = path.split('/').pop()!; - console.log('map callback', this.id, childName, value, updatedAt); this.get(childName).put(value, updatedAt); }; diff --git a/src/js/state/PublicState.ts b/src/js/state/PublicState.ts new file mode 100644 index 00000000..32cca675 --- /dev/null +++ b/src/js/state/PublicState.ts @@ -0,0 +1,10 @@ +import IrisNostrAdapter from '@/state/IrisNostrAdapter.ts'; +import MemoryAdapter from '@/state/MemoryAdapter.ts'; + +import Node from './Node'; + +const publicState = new Node({ + adapters: [new MemoryAdapter(), new IrisNostrAdapter()], +}); + +export default publicState; diff --git a/src/js/views/explorer/Explorer.tsx b/src/js/views/explorer/Explorer.tsx index ffd43b24..3f3994db 100644 --- a/src/js/views/explorer/Explorer.tsx +++ b/src/js/views/explorer/Explorer.tsx @@ -1,4 +1,5 @@ import localState from '@/state/LocalState.ts'; +import publicState from '@/state/PublicState.ts'; import ExplorerNode from '@/views/explorer/ExplorerNode.tsx'; import View from '@/views/View.tsx'; @@ -14,6 +15,9 @@ const Explorer = ({ p }: Props) => {
+
+ +
); }; diff --git a/src/js/views/feeds/Notifications.tsx b/src/js/views/feeds/Notifications.tsx index a51216bd..8881d2ff 100644 --- a/src/js/views/feeds/Notifications.tsx +++ b/src/js/views/feeds/Notifications.tsx @@ -5,11 +5,11 @@ import { Event } from 'nostr-tools'; import EventDB from '@/nostr/EventDB'; import Events from '@/nostr/Events'; import Key from '@/nostr/Key'; +import publicState from '@/state/PublicState.ts'; import { RouteProps } from '@/views/types.ts'; import View from '@/views/View.tsx'; import Feed from '../../components/feed/Feed'; -import Session from '../../nostr/Session'; import localState from '../../state/LocalState.ts'; import { translate as t } from '../../translations/Translation.mjs'; @@ -43,16 +43,14 @@ const Notifications: React.FC = () => { const node = localState.get('settings').get('notifications').get('saveLastOpened'); node.once((saveLastOpened) => { if (saveLastOpened !== false) { + // TODO this gets triggered only once per Iris session? const time = Math.floor(Date.now() / 1000); - const success = Session.public?.set('notifications/lastOpened', time); - if (!success) { - console.log('user rejected'); - // stop pestering if user rejects signature request - node.put(false); - } + console.log('set state'); + publicState.get('notifications').get('lastOpened').put(time); + // TODO if user rejected, stop pestering them with sign prompt localState.get('unseenNotificationCount').put(0); } - }); + }, true); }, 1000), [], ); diff --git a/src/js/views/settings/Appearance.tsx b/src/js/views/settings/Appearance.tsx index f94fb55f..65847eba 100644 --- a/src/js/views/settings/Appearance.tsx +++ b/src/js/views/settings/Appearance.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'preact/hooks'; -import Session from '../../nostr/Session'; import localState from '../../state/LocalState.ts'; import { translate as t } from '../../translations/Translation.mjs'; @@ -11,10 +10,12 @@ const Appearance = () => { const [showConnectedRelays, setShowConnectedRelays] = useState(false); useEffect(() => { - // TODO use Nostr.private - Session.public?.get('settings/colorScheme', (/*entry*/) => { - //setColorScheme(entry.value); + // TODO use privateState. or localState? + /* + publicState.get('settings/colorScheme', (value) => { + //setColorScheme(value); }); + */ localState.get('showConnectedRelays').on(setShowConnectedRelays); }, []);