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 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] };

View File

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

View File

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

View File

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

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) => {
keys.forEach((key) => {
const remainingPath = key.replace(`${path}/`, '');
if (key.startsWith(`${path}/`) && !remainingPath.includes('/')) {
if (key.startsWith(`${path}/`) && remainingPath.length && !remainingPath.includes('/')) {
localForage
.getItem<NodeValue | null>(key)
.then((result) => {

View File

@ -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, () => {});
}
}

View File

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

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 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) => {
<div className="m-2 md:mx-4">
<ExplorerNode expanded={true} name="Local state" node={localState} />
</div>
<div className="m-2 md:mx-4">
<ExplorerNode expanded={true} name="Public state" node={publicState} />
</div>
</View>
);
};

View File

@ -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<RouteProps> = () => {
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),
[],
);

View File

@ -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);
}, []);