mirror of
https://github.com/irislib/iris-messenger.git
synced 2024-10-18 06:03:22 +00:00
wip LocalState refactoring
This commit is contained in:
parent
583c8863fa
commit
53b3a47623
37
src/js/state/LocalForageAdapter.ts
Normal file
37
src/js/state/LocalForageAdapter.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
localForage.config({
|
||||||
|
driver: [localForage.LOCALSTORAGE, localForage.INDEXEDDB, localForage.WEBSQL],
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(path: string, data: any) {
|
||||||
|
if (data === null) {
|
||||||
|
localForage.setItem(path, LOCALFORAGE_NULL);
|
||||||
|
} else if (data === undefined) {
|
||||||
|
localForage.removeItem(path);
|
||||||
|
} else {
|
||||||
|
localForage.setItem(path, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,216 +1,4 @@
|
|||||||
import localForage from 'localforage';
|
import Node from './Node';
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import throttle from 'lodash/throttle';
|
|
||||||
|
|
||||||
export type Unsubscribe = () => void;
|
|
||||||
|
|
||||||
export type Callback = (data: any, path: string, unsubscribe: Unsubscribe) => void;
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
const notInLocalForage = new Set();
|
|
||||||
|
|
||||||
localForage.config({
|
|
||||||
driver: [localForage.LOCALSTORAGE, localForage.INDEXEDDB, localForage.WEBSQL],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
Our very own implementation of the Gun (https://github.com/amark/gun) API. Used for local state management.
|
|
||||||
*/
|
|
||||||
export class Node {
|
|
||||||
id: string;
|
|
||||||
parent: Node | null;
|
|
||||||
children = new Map<string, Node>();
|
|
||||||
on_subscriptions = new Map();
|
|
||||||
map_subscriptions = new Map();
|
|
||||||
value: any = undefined;
|
|
||||||
counter = 0;
|
|
||||||
loaded = false;
|
|
||||||
|
|
||||||
/** */
|
|
||||||
constructor(id = '', parent: Node | null = null) {
|
|
||||||
this.id = id;
|
|
||||||
this.parent = parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveLocalForage = throttle(async () => {
|
|
||||||
if (!this.loaded) {
|
|
||||||
await this.loadLocalForage();
|
|
||||||
}
|
|
||||||
if (this.children.size) {
|
|
||||||
const children = Array.from(this.children.keys());
|
|
||||||
await localForage.setItem(this.id, children);
|
|
||||||
} else if (this.value === undefined) {
|
|
||||||
await localForage.removeItem(this.id);
|
|
||||||
} else {
|
|
||||||
await localForage.setItem(this.id, this.value === null ? LOCALFORAGE_NULL : this.value);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
loadLocalForage = throttle(async () => {
|
|
||||||
if (notInLocalForage.has(this.id)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// try to get the value from localforage
|
|
||||||
let result = await localForage.getItem(this.id);
|
|
||||||
// getItem returns null if not found
|
|
||||||
if (result === null) {
|
|
||||||
result = undefined;
|
|
||||||
notInLocalForage.add(this.id);
|
|
||||||
} else if (result === LOCALFORAGE_NULL) {
|
|
||||||
result = null;
|
|
||||||
} else if (Array.isArray(result)) {
|
|
||||||
// result is a list of children
|
|
||||||
const newResult = {};
|
|
||||||
await Promise.all(
|
|
||||||
result.map(async (key) => {
|
|
||||||
newResult[key] = await this.get(key).once();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
result = newResult;
|
|
||||||
} else {
|
|
||||||
// result is a value
|
|
||||||
this.value = result;
|
|
||||||
}
|
|
||||||
this.loaded = true;
|
|
||||||
return result;
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
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 },
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param key
|
|
||||||
* @returns {Node}
|
|
||||||
* @example node.get('users').get('alice').put({name: 'Alice'})
|
|
||||||
*/
|
|
||||||
get(key) {
|
|
||||||
const existing = this.children.get(key);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
const new_node = new Node(`${this.id}/${key}`, this);
|
|
||||||
this.children.set(key, new_node);
|
|
||||||
this.saveLocalForage();
|
|
||||||
return new_node;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a value to the node. If the value is an object, it will be converted to child nodes.
|
|
||||||
* @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");
|
|
||||||
}
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
this.value = undefined;
|
|
||||||
await Promise.all(Object.entries(value).map(([key, val]) => this.get(key).put(val)));
|
|
||||||
} else {
|
|
||||||
this.children = new Map();
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
this.doCallbacks();
|
|
||||||
return this.saveLocalForage();
|
|
||||||
}
|
|
||||||
|
|
||||||
// protip: the code would be a lot cleaner if you separated the Node API from storage adapters.
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
} else {
|
|
||||||
result = await this.loadLocalForage();
|
|
||||||
}
|
|
||||||
if (result !== undefined || returnIfUndefined) {
|
|
||||||
callback &&
|
|
||||||
callback(
|
|
||||||
result,
|
|
||||||
this.id.slice(this.id.lastIndexOf('/') + 1),
|
|
||||||
unsubscribe ||
|
|
||||||
(() => {
|
|
||||||
/* do nothing */
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to a value
|
|
||||||
* @param callback
|
|
||||||
*/
|
|
||||||
on(callback: Callback): Unsubscribe {
|
|
||||||
const id = this.counter++;
|
|
||||||
this.on_subscriptions.set(id, callback);
|
|
||||||
const unsubscribe = () => this.on_subscriptions.delete(id);
|
|
||||||
this.once(callback, unsubscribe, false);
|
|
||||||
return unsubscribe;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to the children of a node. Callback is called separately for each child.
|
|
||||||
* @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);
|
|
||||||
const go = () => {
|
|
||||||
for (const child of this.children.values()) {
|
|
||||||
child.once(callback, unsubscribe, false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (this.loaded) {
|
|
||||||
go();
|
|
||||||
} else {
|
|
||||||
// ensure that the list of children is loaded
|
|
||||||
this.loadLocalForage()?.then(go);
|
|
||||||
}
|
|
||||||
return unsubscribe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const localState = new Node();
|
const localState = new Node();
|
||||||
|
|
||||||
|
50
src/js/state/Node.test.ts
Normal file
50
src/js/state/Node.test.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { Callback, Unsubscribe } from '@/state/types.ts';
|
||||||
|
|
||||||
|
import Node from './Node';
|
||||||
|
|
||||||
|
describe('Node', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with defaults', () => {
|
||||||
|
const node = new Node({ adapters: [] });
|
||||||
|
expect(node.id).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should subscribe and unsubscribe with on()', () => {
|
||||||
|
const node = new Node({ id: 'test', adapters: [] });
|
||||||
|
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));
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
node.put('someValue2');
|
||||||
|
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should callback when subscribed after put()', () => {
|
||||||
|
const node = new Node({ id: 'test', adapters: [] });
|
||||||
|
const mockCallback: Callback = vi.fn();
|
||||||
|
node.put('someValue');
|
||||||
|
|
||||||
|
node.on(mockCallback);
|
||||||
|
expect(mockCallback).toHaveBeenCalledWith('someValue', 'test', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger callback once when calling once()', async () => {
|
||||||
|
const node = new Node({ id: 'test', adapters: [] });
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
158
src/js/state/Node.ts
Normal file
158
src/js/state/Node.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
|
import LocalForageAdapter from '@/state/LocalForageAdapter.ts';
|
||||||
|
import { Adapter, Callback, Unsubscribe } from '@/state/types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
Inspired by https://github.com/amark/gun
|
||||||
|
*/
|
||||||
|
|
||||||
|
type NodeProps = {
|
||||||
|
id?: string;
|
||||||
|
adapters?: Adapter[];
|
||||||
|
parent?: Node | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Node {
|
||||||
|
id: string;
|
||||||
|
parent: Node | null;
|
||||||
|
children = new Map<string, Node>();
|
||||||
|
on_subscriptions = new Map();
|
||||||
|
map_subscriptions = new Map();
|
||||||
|
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()];
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
* @returns {Node}
|
||||||
|
* @example node.get('users').get('alice').put({name: 'Alice'})
|
||||||
|
*/
|
||||||
|
get(key) {
|
||||||
|
const existing = this.children.get(key);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const new_node = new Node({ id: `${this.id}/${key}`, parent: this });
|
||||||
|
this.children.set(key, new_node);
|
||||||
|
return new_node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value to the node. If the value is an object, it will be converted to child nodes.
|
||||||
|
* @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");
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
this.value = undefined;
|
||||||
|
await Promise.all(Object.entries(value).map(([key, val]) => this.get(key).put(val)));
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a value
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
on(callback: Callback): Unsubscribe {
|
||||||
|
const subId = this.counter++;
|
||||||
|
this.on_subscriptions.set(subId, callback);
|
||||||
|
const adapterSubs = this.adapters.map((adapter) => adapter.get(this.id, callback));
|
||||||
|
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.
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
return unsubscribe;
|
||||||
|
}
|
||||||
|
}
|
6
src/js/state/types.ts
Normal file
6
src/js/state/types.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export type Unsubscribe = () => void;
|
||||||
|
export type Callback = (data: any, path: string, unsubscribe: Unsubscribe) => void;
|
||||||
|
export abstract class Adapter {
|
||||||
|
abstract get(path: string, callback: Callback): Unsubscribe;
|
||||||
|
abstract set(path: string, data: any): void;
|
||||||
|
}
|
19
src/js/views/explorer/Explorer.tsx
Normal file
19
src/js/views/explorer/Explorer.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import View from '@/views/View.tsx';
|
||||||
|
import useSubscribe from "@/nostr/hooks/useSubscribe.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
p?: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Explorer = ({ p }: Props) => {
|
||||||
|
const all = useSubscribe({ kinds: [30000], authors: [Key.getPubKey()] });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View hideSideBar={true}>
|
||||||
|
<div>{p}</div>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Explorer;
|
Loading…
Reference in New Issue
Block a user