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 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;
|
||||
}
|
||||
}
|
||||
import Node from './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