mirror of
https://github.com/irislib/iris-messenger.git
synced 2024-10-18 06:03:22 +00:00
localState refactoring
This commit is contained in:
parent
53b3a47623
commit
ad9a3158c4
@ -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);
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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);
|
||||
|
24
src/js/state/MemoryAdapter.ts
Normal file
24
src/js/state/MemoryAdapter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user