wip PublicState

This commit is contained in:
Martti Malmi 2023-08-31 10:30:38 +03:00
parent 51ff0e6d7e
commit 739870a454
13 changed files with 155 additions and 297 deletions

View File

@ -66,7 +66,7 @@ export class EventDB {
const clone = this.pack(event); const clone = this.pack(event);
const flatTags = clone.tags const flatTags = clone.tags
.filter((tag) => ['e', 'p'].includes(tag[0])) .filter((tag) => ['e', 'p', 'd'].includes(tag[0]))
.map((tag) => tag.join('_')); .map((tag) => tag.join('_'));
try { try {
@ -132,6 +132,8 @@ export class EventDB {
query.flatTags = { $contains: 'e_' + filter['#e'].map(ID) }; query.flatTags = { $contains: 'e_' + filter['#e'].map(ID) };
} else if (filter['#p']) { } else if (filter['#p']) {
query.flatTags = { $contains: 'p_' + filter['#p'].map(ID) }; 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) { if (filter.since && filter.until) {
query.created_at = { $between: [filter.since, filter.until] }; query.created_at = { $between: [filter.since, filter.until] };

View File

@ -283,7 +283,6 @@ const Events = {
async saveDMToLocalState(event: DecryptedEvent, chatNode: Node) { async saveDMToLocalState(event: DecryptedEvent, chatNode: Node) {
const latest = chatNode.get('latest'); const latest = chatNode.get('latest');
const e = await latest.once(undefined, true); const e = await latest.once(undefined, true);
console.log('latest', e, event);
if (!e || !e.created_at || e.created_at < event.created_at) { if (!e || !e.created_at || e.created_at < event.created_at) {
latest.put({ id: event.id, created_at: event.created_at, text: event.text }); latest.put({ id: event.id, created_at: event.created_at, text: event.text });
} }
@ -336,7 +335,6 @@ const Events = {
EventDB.insert(event); EventDB.insert(event);
if (!maybeSecretChat) { if (!maybeSecretChat) {
console.log('saving dm to local state', chatId);
this.saveDMToLocalState(event, localState.get('chats').get(chatId)); this.saveDMToLocalState(event, localState.get('chats').get(chatId));
} }
}, },

View File

@ -76,6 +76,9 @@ const IndexedDB = {
const eventTags = const eventTags =
event.tags event.tags
?.filter((tag) => { ?.filter((tag) => {
if (tag[0] === 'd') {
return true;
}
if (tag[0] === 'e') { if (tag[0] === 'e') {
return true; return true;
} }
@ -125,7 +128,7 @@ const IndexedDB = {
}, 1000), }, 1000),
subscribeToTags: throttle(async function (this: typeof IndexedDB) { 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(); this.subscribedTags.clear();
await db.tags await db.tags
.where('[type+value]') .where('[type+value]')
@ -164,6 +167,15 @@ const IndexedDB = {
return; 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) { if (filter.ids?.length) {
filter.ids.forEach((id) => this.subscribedEventIds.add(id)); filter.ids.forEach((id) => this.subscribedEventIds.add(id));
await this.subscribeToEventIds(); await this.subscribeToEventIds();

View File

@ -1,7 +1,7 @@
import localForage from 'localforage'; import localForage from 'localforage';
import { Event, Filter } from 'nostr-tools';
import { route } from 'preact-router'; import { route } from 'preact-router';
import publicState from '@/state/PublicState.ts';
import Helpers from '@/utils/Helpers.tsx'; import Helpers from '@/utils/Helpers.tsx';
import localState from '../state/LocalState.ts'; import localState from '../state/LocalState.ts';
@ -11,7 +11,6 @@ import { ID } from '../utils/UniqueIds';
import Events from './Events'; import Events from './Events';
import IndexedDB from './IndexedDB'; import IndexedDB from './IndexedDB';
import Key from './Key'; import Key from './Key';
import { Path } from './path';
import PubSub from './PubSub'; import PubSub from './PubSub';
import Relays from './Relays'; import Relays from './Relays';
import SocialNetwork from './SocialNetwork'; import SocialNetwork from './SocialNetwork';
@ -25,9 +24,6 @@ try {
let loggedIn = false; let loggedIn = false;
const Session = { const Session = {
public: undefined as Path | undefined,
private: undefined as Path | undefined,
async logOut() { async logOut() {
route('/'); route('/');
/* /*
@ -81,45 +77,24 @@ const Session = {
SocialNetwork.followersByUser.set(myId, new Set()); SocialNetwork.followersByUser.set(myId, new Set());
SocialNetwork.usersByFollowDistance.set(0, new Set([myId])); SocialNetwork.usersByFollowDistance.set(0, new Set([myId]));
this.loadMyFollowList(); 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) => { localState.get('globalFilter').once((globalFilter) => {
if (!globalFilter) { if (!globalFilter) {
localState.get('globalFilter').put(Events.DEFAULT_GLOBAL_FILTER); localState.get('globalFilter').put(Events.DEFAULT_GLOBAL_FILTER);
} }
}); });
// TODO move private and public to State.ts publicState
this.private = new Path( .get('notifications')
(...args) => Events.publish(...args), .get('lastOpened')
subscribe, .on((time) => {
() => 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) { if (time !== Events.notificationsSeenTime) {
Events.notificationsSeenTime = time; Events.notificationsSeenTime = time;
Events.updateUnseenNotificationCount(); Events.updateUnseenNotificationCount();
} }
}); });
this.public.get('settings/colorScheme', (colorScheme) => { publicState
.get('settings')
.get('colorScheme')
.on((colorScheme) => {
if (colorScheme === 'light') { if (colorScheme === 'light') {
document.documentElement.setAttribute('data-theme', 'light'); document.documentElement.setAttribute('data-theme', 'light');
return; return;

View File

@ -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<Event>) => Promise<Event>;
type Subscribe = (filters: Filter[], callback: (event: Event) => void) => string;
type Unsubscribe = (id: string) => void;
type Encrypt = (content: string) => Promise<string>;
type Decrypt = (content: string) => Promise<string>;
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<string, Map<string, Event>>();
// 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<string, Listener>;
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<string, Listener>();
}
async publishSetEvent(path: string, value: any): Promise<Event> {
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<boolean> {
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<any> {
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);
}
}
}
}

View File

@ -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<string, NodeValue>();
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();
}
}

View File

@ -37,7 +37,7 @@ export default class LocalForageAdapter extends Adapter {
.then((keys) => { .then((keys) => {
keys.forEach((key) => { keys.forEach((key) => {
const remainingPath = key.replace(`${path}/`, ''); const remainingPath = key.replace(`${path}/`, '');
if (key.startsWith(`${path}/`) && !remainingPath.includes('/')) { if (key.startsWith(`${path}/`) && remainingPath.length && !remainingPath.includes('/')) {
localForage localForage
.getItem<NodeValue | null>(key) .getItem<NodeValue | null>(key)
.then((result) => { .then((result) => {

View File

@ -23,7 +23,11 @@ export default class MemoryAdapter extends Adapter {
list(path: string, callback: Callback): Unsubscribe { list(path: string, callback: Callback): Unsubscribe {
for (const [storedPath, storedValue] of this.storage) { for (const [storedPath, storedValue] of this.storage) {
const remainingPath = storedPath.replace(`${path}/`, ''); 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, () => {}); callback(storedValue.value, storedPath, storedValue.updatedAt, () => {});
} }
} }

View File

@ -98,7 +98,6 @@ export default class Node {
this.parent.children.set(childName, this); this.parent.children.set(childName, this);
} }
for (const [id, callback] of this.parent.map_subscriptions) { 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, () => { callback(value, this.id, updatedAt, () => {
this.parent?.map_subscriptions.delete(id); this.parent?.map_subscriptions.delete(id);
}); });
@ -130,7 +129,7 @@ export default class Node {
* @param callback * @param callback
*/ */
on(callback: Callback, returnIfUndefined: boolean = false): Unsubscribe { 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) => { const cb = (value, path, updatedAt, unsubscribe) => {
if (value === undefined) { if (value === undefined) {
if (returnIfUndefined) { if (returnIfUndefined) {
@ -168,10 +167,15 @@ export default class Node {
map(callback: Callback): Unsubscribe { map(callback: Callback): Unsubscribe {
const id = this.counter++; const id = this.counter++;
this.map_subscriptions.set(id, callback); this.map_subscriptions.set(id, callback);
const latestMap = new Map<string, NodeValue>(); // replace with this.value?
const cb = (value, path, updatedAt) => { 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()!; const childName = path.split('/').pop()!;
console.log('map callback', this.id, childName, value, updatedAt);
this.get(childName).put(value, updatedAt); this.get(childName).put(value, updatedAt);
}; };

View File

@ -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;

View File

@ -1,4 +1,5 @@
import localState from '@/state/LocalState.ts'; import localState from '@/state/LocalState.ts';
import publicState from '@/state/PublicState.ts';
import ExplorerNode from '@/views/explorer/ExplorerNode.tsx'; import ExplorerNode from '@/views/explorer/ExplorerNode.tsx';
import View from '@/views/View.tsx'; import View from '@/views/View.tsx';
@ -14,6 +15,9 @@ const Explorer = ({ p }: Props) => {
<div className="m-2 md:mx-4"> <div className="m-2 md:mx-4">
<ExplorerNode expanded={true} name="Local state" node={localState} /> <ExplorerNode expanded={true} name="Local state" node={localState} />
</div> </div>
<div className="m-2 md:mx-4">
<ExplorerNode expanded={true} name="Public state" node={publicState} />
</div>
</View> </View>
); );
}; };

View File

@ -5,11 +5,11 @@ import { Event } from 'nostr-tools';
import EventDB from '@/nostr/EventDB'; import EventDB from '@/nostr/EventDB';
import Events from '@/nostr/Events'; import Events from '@/nostr/Events';
import Key from '@/nostr/Key'; import Key from '@/nostr/Key';
import publicState from '@/state/PublicState.ts';
import { RouteProps } from '@/views/types.ts'; import { RouteProps } from '@/views/types.ts';
import View from '@/views/View.tsx'; import View from '@/views/View.tsx';
import Feed from '../../components/feed/Feed'; import Feed from '../../components/feed/Feed';
import Session from '../../nostr/Session';
import localState from '../../state/LocalState.ts'; import localState from '../../state/LocalState.ts';
import { translate as t } from '../../translations/Translation.mjs'; import { translate as t } from '../../translations/Translation.mjs';
@ -43,16 +43,14 @@ const Notifications: React.FC<RouteProps> = () => {
const node = localState.get('settings').get('notifications').get('saveLastOpened'); const node = localState.get('settings').get('notifications').get('saveLastOpened');
node.once((saveLastOpened) => { node.once((saveLastOpened) => {
if (saveLastOpened !== false) { if (saveLastOpened !== false) {
// TODO this gets triggered only once per Iris session?
const time = Math.floor(Date.now() / 1000); const time = Math.floor(Date.now() / 1000);
const success = Session.public?.set('notifications/lastOpened', time); console.log('set state');
if (!success) { publicState.get('notifications').get('lastOpened').put(time);
console.log('user rejected'); // TODO if user rejected, stop pestering them with sign prompt
// stop pestering if user rejects signature request
node.put(false);
}
localState.get('unseenNotificationCount').put(0); localState.get('unseenNotificationCount').put(0);
} }
}); }, true);
}, 1000), }, 1000),
[], [],
); );

View File

@ -1,6 +1,5 @@
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import Session from '../../nostr/Session';
import localState from '../../state/LocalState.ts'; import localState from '../../state/LocalState.ts';
import { translate as t } from '../../translations/Translation.mjs'; import { translate as t } from '../../translations/Translation.mjs';
@ -11,10 +10,12 @@ const Appearance = () => {
const [showConnectedRelays, setShowConnectedRelays] = useState(false); const [showConnectedRelays, setShowConnectedRelays] = useState(false);
useEffect(() => { useEffect(() => {
// TODO use Nostr.private // TODO use privateState. or localState?
Session.public?.get('settings/colorScheme', (/*entry*/) => { /*
//setColorScheme(entry.value); publicState.get('settings/colorScheme', (value) => {
//setColorScheme(value);
}); });
*/
localState.get('showConnectedRelays').on(setShowConnectedRelays); localState.get('showConnectedRelays').on(setShowConnectedRelays);
}, []); }, []);