localState refactoring

This commit is contained in:
Martti Malmi 2023-08-29 13:40:43 +03:00
parent 53b3a47623
commit ad9a3158c4
9 changed files with 162 additions and 114 deletions

View File

@ -3,6 +3,7 @@ import { Helmet } from 'react-helmet';
import { Router, RouterOnChangeArgs } from 'preact-router';
import useLocalState from '@/state/useLocalState.ts';
import Explorer from '@/views/explorer/Explorer.tsx';
import Footer from './components/Footer';
import Show from './components/helpers/Show';
@ -31,7 +32,6 @@ import Subscribe from './views/Subscribe';
import '@fontsource/lato/400.css';
import '@fontsource/lato/700.css';
import '../css/cropper.min.css';
import Explorer from "@/views/explorer/Explorer.tsx";
const Main = () => {
const [loggedIn] = useLocalState('loggedIn', false);

View File

@ -22,7 +22,7 @@ import {
import { ID, STR, UniqueIds } from '@/utils/UniqueIds';
import localState from '../state/LocalState.ts';
import { Node } from '../state/LocalState.ts';
import Node from '../state/Node.ts';
import { DecryptedEvent } from '../views/chat/ChatMessages';
import { addGroup, setGroupNameByInvite } from '../views/chat/NewChat';

View File

@ -1,10 +1,6 @@
import localForage from 'localforage';
import { Adapter, Callback } from '@/state/types.ts';
// Localforage returns null if an item is not found, so we represent null with this uuid instead.
// not foolproof, but good enough for now.
const LOCALFORAGE_NULL = 'c2fc1ad0-f76f-11ec-b939-0242ac120002';
import { Adapter, Callback, NodeValue } from '@/state/types.ts';
localForage.config({
driver: [localForage.LOCALSTORAGE, localForage.INDEXEDDB, localForage.WEBSQL],
@ -14,21 +10,19 @@ const unsub = () => {};
export default class LocalForageAdapter extends Adapter {
get(path: string, callback: Callback) {
localForage.getItem(path).then((result) => {
if (result === null) {
result = undefined;
} else if (result === LOCALFORAGE_NULL) {
result = null;
}
callback(result, path, unsub);
});
localForage
.getItem<NodeValue | null>(path)
.then((result) => {
if (result) {
callback(result.value, path, result.updatedAt, unsub);
}
})
.catch((err) => console.error(err));
return unsub;
}
set(path: string, data: any) {
if (data === null) {
localForage.setItem(path, LOCALFORAGE_NULL);
} else if (data === undefined) {
set(path: string, data: NodeValue) {
if (data === undefined) {
localForage.removeItem(path);
} else {
localForage.setItem(path, data);

View File

@ -0,0 +1,24 @@
import { Adapter, Callback, NodeValue, Unsubscribe } from '@/state/types.ts';
export default class MemoryAdapter extends Adapter {
private storage = new Map<string, NodeValue>();
get(path: string, callback: Callback): Unsubscribe {
const storedValue = this.storage.get(path);
if (storedValue) {
callback(storedValue.value, path, storedValue.updatedAt, () => {});
}
return () => {};
}
set(path: string, value: NodeValue): void {
if (!value.updatedAt || !value.value) {
throw new Error(`Invalid value: ${JSON.stringify(value)}`);
}
if (value === undefined) {
this.storage.delete(path);
} else {
this.storage.set(path, value);
}
}
}

View File

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import MemoryAdapter from '@/state/MemoryAdapter.ts';
import { Callback, Unsubscribe } from '@/state/types.ts';
import Node from './Node';
@ -15,14 +16,19 @@ describe('Node', () => {
});
it('should subscribe and unsubscribe with on()', () => {
const node = new Node({ id: 'test', adapters: [] });
const node = new Node({ id: 'test', adapters: [new MemoryAdapter()] });
const mockCallback: Callback = vi.fn();
const unsubscribe: Unsubscribe = node.on(mockCallback);
expect(typeof unsubscribe).toBe('function');
node.put('someValue');
expect(mockCallback).toHaveBeenCalledWith('someValue', 'test', expect.any(Function));
expect(mockCallback).toHaveBeenCalledWith(
'someValue',
'test',
expect.any(Number),
expect.any(Function),
);
unsubscribe();
node.put('someValue2');
@ -30,21 +36,67 @@ describe('Node', () => {
});
it('should callback when subscribed after put()', () => {
const node = new Node({ id: 'test', adapters: [] });
const node = new Node({ id: 'test', adapters: [new MemoryAdapter()] });
const mockCallback: Callback = vi.fn();
node.put('someValue');
node.on(mockCallback);
expect(mockCallback).toHaveBeenCalledWith('someValue', 'test', expect.any(Function));
expect(mockCallback).toHaveBeenCalledWith(
'someValue',
'test',
expect.any(Number),
expect.any(Function),
);
});
it('should trigger callback once when calling once()', async () => {
const node = new Node({ id: 'test', adapters: [] });
const node = new Node({ id: 'test', adapters: [new MemoryAdapter()] });
const mockCallback: Callback = vi.fn();
node.put('someValue');
const result = await node.once(mockCallback);
expect(result).toBe('someValue');
expect(mockCallback).toHaveBeenCalledWith('someValue', 'test', expect.any(Function));
expect(mockCallback).toHaveBeenCalledWith(
'someValue',
'test',
expect.any(Number),
expect.any(Function),
);
node.put('someValue2');
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it('should trigger list callbacks when children are present', async () => {
const node = new Node({ id: 'test', adapters: [new MemoryAdapter()] });
const mockCallback: Callback = vi.fn();
// Adding children to the node
await node.get('child1').put('value1');
await node.get('child2').put('value2');
const unsubscribe: Unsubscribe = node.map(mockCallback);
// Should trigger for both child nodes
expect(mockCallback).toHaveBeenCalledWith(
'value1',
'test/child1',
expect.any(Number),
expect.any(Function),
);
expect(mockCallback).toHaveBeenCalledWith(
'value2',
'test/child2',
expect.any(Number),
expect.any(Function),
);
unsubscribe();
// Add another child to ensure the callback is not called after unsubscribe
await node.get('child3').put('value3');
// Should still have been called only twice
expect(mockCallback).toHaveBeenCalledTimes(2);
});
});

View File

@ -1,7 +1,6 @@
import debounce from 'lodash/debounce';
import LocalForageAdapter from '@/state/LocalForageAdapter.ts';
import { Adapter, Callback, Unsubscribe } from '@/state/types.ts';
import MemoryAdapter from '@/state/MemoryAdapter.ts';
import { Adapter, Callback, NodeValue, Unsubscribe } from '@/state/types.ts';
/**
Inspired by https://github.com/amark/gun
@ -17,38 +16,17 @@ export default class Node {
id: string;
parent: Node | null;
children = new Map<string, Node>();
on_subscriptions = new Map();
map_subscriptions = new Map();
on_subscriptions = new Map<number, Callback>();
map_subscriptions = new Map<number, Callback>();
adapters: Adapter[];
value: any = undefined;
counter = 0;
constructor({ id = '', adapters, parent = null }: NodeProps = {}) {
this.id = id;
this.parent = parent;
this.adapters = adapters ?? parent?.adapters ?? [new LocalForageAdapter()];
this.adapters = adapters ?? parent?.adapters ?? [new MemoryAdapter(), new LocalForageAdapter()];
}
doCallbacks = debounce(
() => {
for (const [id, callback] of this.on_subscriptions) {
const unsubscribe = () => this.on_subscriptions.delete(id);
this.once(callback, unsubscribe, false);
}
if (this.parent) {
this.parent.doCallbacks(); // maybe this shouldn't be recursive after all? in a file tree analogy, you wouldn't want
// a change in a subdirectory to trigger a callback in all parent directories.
// there could be a separate open() fn for recursive subscriptions.
for (const [id, callback] of this.parent.map_subscriptions) {
const unsubscribe = () => this.parent?.map_subscriptions.delete(id);
this.once(callback, unsubscribe, false);
}
}
},
20,
{ maxWait: 40, leading: true },
);
/**
*
* @param key
@ -70,58 +48,21 @@ export default class Node {
* @param value
* @example node.get('users').get('alice').put({name: 'Alice'})
*/
async put(value) {
// console.log('put', this.id, value);
if (Array.isArray(value)) {
throw new Error("Sorry, we don't deal with arrays");
}
async put(value, updatedAt = Date.now()) {
if (typeof value === 'object' && value !== null) {
this.value = undefined;
await Promise.all(Object.entries(value).map(([key, val]) => this.get(key).put(val)));
this.adapters.forEach((adapter) => adapter.set(this.id, { value: '__DIR__', updatedAt }));
const children = Object.keys(value);
children.map((key) => this.get(key).put(value[key], updatedAt));
} else {
this.children = new Map();
this.value = value;
}
this.doCallbacks();
this.adapters.forEach((adapter) => adapter.set(this.id, this.value));
}
/**
* Return a value without subscribing to it
* @param callback
* @param event
* @param returnIfUndefined
* @returns {Promise<*>}
*/
async once(
callback?: Callback,
unsubscribe?: Unsubscribe,
returnIfUndefined = true,
): Promise<any> {
let result: any;
if (this.children.size) {
// return an object containing all children
result = {};
await Promise.all(
Array.from(this.children.keys()).map(async (key) => {
result[key] = await this.get(key).once(undefined, unsubscribe);
}),
);
} else if (this.value !== undefined) {
result = this.value;
}
if (result !== undefined || returnIfUndefined) {
callback &&
callback(
result,
this.id.slice(this.id.lastIndexOf('/') + 1),
unsubscribe ||
(() => {
/* do nothing */
}),
);
return result;
const nodeValue: NodeValue = {
updatedAt,
value,
};
this.adapters.forEach((adapter) => adapter.set(this.id, nodeValue));
this.on_subscriptions.forEach((callback) => {
callback(value, this.id, updatedAt, () => {});
});
}
}
@ -130,29 +71,54 @@ export default class Node {
* @param callback
*/
on(callback: Callback): Unsubscribe {
let latest: NodeValue | null = null;
// TODO handle case where it's a branch node
const cb = (value, path, updatedAt, unsubscribe) => {
if (latest === null || latest.updatedAt < value.updatedAt) {
latest = { value, updatedAt };
callback(value, path, updatedAt, unsubscribe);
}
};
const subId = this.counter++;
this.on_subscriptions.set(subId, callback);
const adapterSubs = this.adapters.map((adapter) => adapter.get(this.id, callback));
this.on_subscriptions.set(subId, cb);
const adapterSubs = this.adapters.map((adapter) => adapter.get(this.id, cb));
const unsubscribe = () => {
this.on_subscriptions.delete(subId);
adapterSubs.forEach((unsub) => unsub());
};
this.once(callback, unsubscribe, false);
return unsubscribe;
}
/**
* Subscribe to the children of a node. Callback is called separately for each child.
* Callback for each child node
* @param callback
* @returns {Promise<void>}
*/
map(callback: Callback): Unsubscribe {
const id = this.counter++;
this.map_subscriptions.set(id, callback);
const unsubscribe = () => this.map_subscriptions.delete(id);
for (const child of this.children.values()) {
child.once(callback, unsubscribe, false);
child.once(callback, unsubscribe);
}
return unsubscribe;
}
/**
* Same as on(), but will unsubscribe after the first callback
* @param callback
* @param unsubscribe
*/
once(callback?: Callback, unsubscribe?: Unsubscribe): Promise<any> {
return new Promise((resolve) => {
const cb = (value, updatedAt, path, unsub) => {
if (unsubscribe) {
unsubscribe();
}
resolve(value);
callback?.(value, updatedAt, path, unsub);
unsub();
};
this.on(cb);
});
}
}

View File

@ -1,6 +1,15 @@
export type Unsubscribe = () => void;
export type Callback = (data: any, path: string, unsubscribe: Unsubscribe) => void;
export type NodeValue = {
updatedAt: number;
value: any;
};
export type Callback = (
value: any,
path: string,
updatedAt: number,
unsubscribe: Unsubscribe,
) => void;
export abstract class Adapter {
abstract get(path: string, callback: Callback): Unsubscribe;
abstract set(path: string, data: any): void;
abstract set(path: string, data: NodeValue): void;
}

View File

@ -3,9 +3,15 @@ import { useCallback, useEffect, useState } from 'react';
import localState from '@/state/LocalState.ts';
export default function useLocalState(key: string, initialValue: any = undefined, once = false) {
const [value, setValue] = useState(initialValue || localState.get(key).value);
if (!initialValue) {
localState.get(key).once((val) => {
// TODO some way to get memory value
initialValue = val;
});
}
const [value, setValue] = useState(initialValue);
useEffect(() => {
const unsub = localState.get(key).on((new_value, _key, unsubscribe) => {
const unsub = localState.get(key).on((new_value, _key, _updatedAt, unsubscribe) => {
setValue(new_value);
if (once) {
unsubscribe();

View File

@ -1,5 +1,4 @@
import View from '@/views/View.tsx';
import useSubscribe from "@/nostr/hooks/useSubscribe.ts";
type Props = {
p?: string;
@ -7,8 +6,6 @@ type Props = {
};
const Explorer = ({ p }: Props) => {
const all = useSubscribe({ kinds: [30000], authors: [Key.getPubKey()] });
return (
<View hideSideBar={true}>
<div>{p}</div>