Merge branch 'explorer'

This commit is contained in:
Martti Malmi 2023-08-30 19:39:46 +03:00
commit 51ec3c89d4
18 changed files with 659 additions and 233 deletions

View File

@ -3,6 +3,7 @@ import { Helmet } from 'react-helmet';
import { Router, RouterOnChangeArgs } from 'preact-router'; import { Router, RouterOnChangeArgs } from 'preact-router';
import useLocalState from '@/state/useLocalState.ts'; import useLocalState from '@/state/useLocalState.ts';
import Explorer from '@/views/explorer/Explorer.tsx';
import Footer from './components/Footer'; import Footer from './components/Footer';
import Show from './components/helpers/Show'; import Show from './components/helpers/Show';
@ -113,6 +114,7 @@ const Main = () => {
<Profile path="/:id/likes" tab="likes" /> <Profile path="/:id/likes" tab="likes" />
<Follows path="/follows/:id" /> <Follows path="/follows/:id" />
<Follows followers={true} path="/followers/:id" /> <Follows followers={true} path="/followers/:id" />
<Explorer path="/explorer/:p?" />
<NoteOrProfile path="/:id" /> <NoteOrProfile path="/:id" />
</Router> </Router>
</div> </div>

View File

@ -8,6 +8,8 @@ import {
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import { Link } from 'preact-router'; import { Link } from 'preact-router';
import useLocalState from '@/state/useLocalState.ts';
import Key from '../nostr/Key'; import Key from '../nostr/Key';
import localState from '../state/LocalState.ts'; import localState from '../state/LocalState.ts';
import Icons from '../utils/Icons'; import Icons from '../utils/Icons';
@ -23,12 +25,11 @@ const MENU_ITEMS = [
]; ];
const Footer = () => { const Footer = () => {
const [isMyProfile, setIsMyProfile] = useState(false); const [isMyProfile] = useLocalState('isMyProfile', false);
const [activeRoute, setActiveRoute] = useState('/'); const [activeRoute, setActiveRoute] = useState('/');
const [chatId, setChatId] = useState(null); const [chatId, setChatId] = useState(null);
useEffect(() => { useEffect(() => {
localState.get('isMyProfile').on((value) => setIsMyProfile(value));
localState.get('activeRoute').on((activeRoute) => { localState.get('activeRoute').on((activeRoute) => {
const replaced = activeRoute.replace('/chat/new', '').replace('/chat/', ''); const replaced = activeRoute.replace('/chat/new', '').replace('/chat/', '');
const chatId = replaced.length < activeRoute.length ? replaced : null; const chatId = replaced.length < activeRoute.length ? replaced : null;

View File

@ -102,7 +102,7 @@ const Content = ({ standalone, isQuote, fullWidth, asInlineQuote, event, isPrevi
</Show> </Show>
<Show when={text?.length > 0}> <Show when={text?.length > 0}>
<div <div
className={`preformatted-wrap pb-1 ${emojiOnly && 'text-2xl'} ${ className={`preformatted-wrap pb-1 ${emojiOnly && 'text-3xl'} ${
fullWidth ? 'full-width-note' : '' fullWidth ? 'full-width-note' : ''
} ${asInlineQuote ? 'inline-quote' : ''}`} } ${asInlineQuote ? 'inline-quote' : ''}`}
> >

View File

@ -22,7 +22,7 @@ import {
import { ID, STR, UniqueIds } from '@/utils/UniqueIds'; import { ID, STR, UniqueIds } from '@/utils/UniqueIds';
import localState from '../state/LocalState.ts'; 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 { DecryptedEvent } from '../views/chat/ChatMessages';
import { addGroup, setGroupNameByInvite } from '../views/chat/NewChat'; import { addGroup, setGroupNameByInvite } from '../views/chat/NewChat';
@ -282,7 +282,8 @@ const Events = {
}, },
async saveDMToLocalState(event: DecryptedEvent, chatNode: Node) { async saveDMToLocalState(event: DecryptedEvent, chatNode: Node) {
const latest = chatNode.get('latest'); const latest = chatNode.get('latest');
const e = await latest.once(); const e = await latest.once(undefined, true);
console.log('latest', e, event);
if (!e || !e.created_at || e.created_at < event.created_at) { if (!e || !e.created_at || e.created_at < event.created_at) {
latest.put({ id: event.id, created_at: event.created_at, text: event.text }); latest.put({ id: event.id, created_at: event.created_at, text: event.text });
} }
@ -335,6 +336,7 @@ const Events = {
EventDB.insert(event); EventDB.insert(event);
if (!maybeSecretChat) { if (!maybeSecretChat) {
console.log('saving dm to local state', chatId);
this.saveDMToLocalState(event, localState.get('chats').get(chatId)); this.saveDMToLocalState(event, localState.get('chats').get(chatId));
} }
}, },

View File

@ -7,7 +7,6 @@ import {
signEvent, signEvent,
UnsignedEvent, UnsignedEvent,
} from 'nostr-tools'; } from 'nostr-tools';
import { route } from 'preact-router';
import { PublicKey } from '@/utils/Hex/Hex.ts'; import { PublicKey } from '@/utils/Hex/Hex.ts';
@ -32,10 +31,10 @@ export default {
windowNostrQueue: [] as any[], windowNostrQueue: [] as any[],
isProcessingQueue: false, isProcessingQueue: false,
getPublicKey, // TODO confusing similarity to getPubKey getPublicKey, // TODO confusing similarity to getPubKey
loginAsNewUser(redirect = false) { loginAsNewUser() {
this.login(this.generateKey(), redirect); this.login(this.generateKey());
}, },
login(key: any, redirect = false) { login(key: any) {
const shouldRefresh = !!this.key; const shouldRefresh = !!this.key;
this.key = key; this.key = key;
localStorage.setItem('iris.myKey', JSON.stringify(key)); localStorage.setItem('iris.myKey', JSON.stringify(key));
@ -43,11 +42,6 @@ export default {
location.reload(); location.reload();
} }
localState.get('loggedIn').put(true); localState.get('loggedIn').put(true);
if (redirect) {
setTimeout(() => {
route('/following');
});
}
localState.get('showLoginModal').put(false); localState.get('showLoginModal').put(false);
}, },
generateKey(): Key { generateKey(): Key {

View File

@ -32,7 +32,7 @@ localState.get('dev').on((d) => {
localState.get('lastOpened').once((lo) => { localState.get('lastOpened').once((lo) => {
lastOpened = lo; lastOpened = lo;
localState.get('lastOpened').put(Math.floor(Date.now() / 1000)); localState.get('lastOpened').put(Math.floor(Date.now() / 1000));
}); }, true);
const PubSub = { const PubSub = {
subscriptions: new Map<number, Subscription>(), subscriptions: new Map<number, Subscription>(),

View File

@ -0,0 +1,56 @@
import localForage from 'localforage';
import { Adapter, Callback, NodeValue } from '@/state/types.ts';
localForage.config({
driver: [localForage.LOCALSTORAGE, localForage.INDEXEDDB, localForage.WEBSQL],
});
const unsub = () => {};
export default class LocalForageAdapter extends Adapter {
get(path: string, callback: Callback) {
localForage
.getItem<NodeValue | null>(path)
.then((result) => {
if (result) {
callback(result.value, path, result.updatedAt, unsub);
} else {
callback(undefined, path, undefined, unsub);
}
})
.catch((err) => console.error(err));
return unsub;
}
async set(path: string, data: NodeValue) {
if (data === undefined) {
await localForage.removeItem(path);
} else {
await localForage.setItem(path, data);
}
}
list(path: string, callback: Callback) {
localForage
.keys()
.then((keys) => {
keys.forEach((key) => {
const remainingPath = key.replace(`${path}/`, '');
if (key.startsWith(`${path}/`) && !remainingPath.includes('/')) {
localForage
.getItem<NodeValue | null>(key)
.then((result) => {
if (result) {
callback(result.value, key, result.updatedAt, unsub);
}
})
.catch((err) => console.error(err));
}
});
})
.catch((err) => console.error(err));
return unsub;
}
}

View File

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

View File

@ -0,0 +1,32 @@
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) || { value: undefined, updatedAt: undefined };
callback(storedValue.value, path, storedValue.updatedAt, () => {});
return () => {};
}
async set(path: string, value: NodeValue) {
if (value.updatedAt === undefined) {
throw new Error(`Invalid value: ${JSON.stringify(value)}`);
}
if (value === undefined) {
this.storage.delete(path);
} else {
this.storage.set(path, value);
}
}
list(path: string, callback: Callback): Unsubscribe {
for (const [storedPath, storedValue] of this.storage) {
const remainingPath = storedPath.replace(`${path}/`, '');
if (storedPath.startsWith(`${path}/`) && !remainingPath.includes('/')) {
callback(storedValue.value, storedPath, storedValue.updatedAt, () => {});
}
}
return () => {};
}
}

197
src/js/state/Node.test.ts Normal file
View File

@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import MemoryAdapter from '@/state/MemoryAdapter.ts';
import { Callback, Unsubscribe } from '@/state/types.ts';
import Node, { DIR_VALUE } from './Node';
describe('Node', () => {
let node;
beforeEach(() => {
vi.resetAllMocks();
node = new Node({ id: 'test', adapters: [new MemoryAdapter()] });
});
describe('new Node()', () => {
it('should initialize with defaults', () => {
const newNode = new Node({ adapters: [] });
expect(newNode.id).toEqual('');
});
});
describe('node.on()', () => {
it('should subscribe and unsubscribe with on()', () => {
const mockCallback: Callback = vi.fn();
const unsubscribe: Unsubscribe = node.on(mockCallback);
node.put('someValue');
expect(mockCallback).toHaveBeenCalledWith(
'someValue',
'test',
expect.any(Number),
expect.any(Function),
);
unsubscribe();
node.put('someValue2');
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it('should callback when subscribed after put()', () => {
const mockCallback: Callback = vi.fn();
node.put('someValue');
node.on(mockCallback);
expect(mockCallback).toHaveBeenCalledWith(
'someValue',
'test',
expect.any(Number),
expect.any(Function),
);
});
});
describe('node.once()', () => {
it('should trigger callback once when calling once()', async () => {
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(Number),
expect.any(Function),
);
node.put('someValue2');
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it('should return undefined if value is not found and returnIfUndefined param is true', async () => {
const result = await node.once(undefined, true);
expect(result).toBe(undefined);
});
it('should return if the data was pre-existing in an adapter', async () => {
const adapter = new MemoryAdapter();
const node = new Node({ id: 'user', adapters: [adapter] });
await node.put({ name: 'Snowden', age: 30 });
const node2 = new Node({ id: 'user', adapters: [adapter] });
const result = await node2.once();
expect(result).toEqual({ name: 'Snowden', age: 30 });
});
});
describe('node.map()', () => {
it('should trigger map callbacks when children are present', async () => {
const mockCallback: Callback = vi.fn();
await node.get('child1').put('value1');
await node.get('child2').put('value2');
const unsubscribe: Unsubscribe = node.map(mockCallback);
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();
await node.get('child3').put('value3');
expect(mockCallback).toHaveBeenCalledTimes(2);
});
it('should trigger map callbacks when children are added', async () => {
const mockCallback: Callback = vi.fn();
const unsubscribe: Unsubscribe = node.map(mockCallback);
await node.get('child1').put('value1');
await node.get('child2').put('value2');
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();
await node.get('child3').put('value3');
expect(mockCallback).toHaveBeenCalledTimes(2);
});
it('should trigger map callbacks when a nested child is added', async () => {
const node = new Node({ id: 'root', adapters: [new MemoryAdapter()] });
const mockCallback: Callback = vi.fn();
const unsubscribe = node.get('chats').map(mockCallback);
await node.get('chats').get('someChatId').get('latest').put({ id: 'messageId', text: 'hi' });
expect(mockCallback).toHaveBeenCalledWith(
DIR_VALUE,
'root/chats/someChatId',
expect.any(Number),
expect.any(Function),
);
// TODO test & fix callback only called once
unsubscribe();
});
it('should return if the data was pre-existing in an adapter', async () => {
const adapter = new MemoryAdapter();
const node = new Node({ id: 'user', adapters: [adapter] });
await node.put({ name: 'Snowden', age: 30 });
const node2 = new Node({ id: 'user', adapters: [adapter] });
const fn = vi.fn();
node2.map(fn);
expect(fn).toHaveBeenCalledWith(
'Snowden',
'user/name',
expect.any(Number),
expect.any(Function),
);
});
});
describe('Branch node behavior', () => {
it('should return children when on() is called on a branch node', async () => {
const settingsNode = new Node({ id: 'settings', adapters: [new MemoryAdapter()] });
const mockCallback1: Callback = vi.fn();
const mockCallback2: Callback = vi.fn();
await settingsNode.put({ theme: 'dark', fontSize: 14 });
settingsNode.on(mockCallback1);
expect(mockCallback1).toHaveBeenCalledWith(
{ theme: 'dark', fontSize: 14 },
'settings',
expect.any(Number),
expect.any(Function),
);
settingsNode.get('theme').on(mockCallback2);
expect(mockCallback2).toHaveBeenCalledWith(
'dark',
'settings/theme',
expect.any(Number),
expect.any(Function),
);
});
});
});

204
src/js/state/Node.ts Normal file
View File

@ -0,0 +1,204 @@
import LocalForageAdapter from '@/state/LocalForageAdapter.ts';
import MemoryAdapter from '@/state/MemoryAdapter.ts';
import { Adapter, Callback, NodeValue, Unsubscribe } from '@/state/types.ts';
/**
Inspired by https://github.com/amark/gun
*/
type NodeProps = {
id?: string;
adapters?: Adapter[];
parent?: Node | null;
};
export const DIR_VALUE = '__DIR__';
/**
* Nodes represent queries into the tree rather than the tree itself. The actual tree data is stored by Adapters.
*
* Node can be a branch node or a leaf node. Branch nodes have children, leaf nodes have a value (stored in an adapter).
*/
export default class Node {
id: string;
parent: Node | null;
children = new Map<string, Node>();
on_subscriptions = new Map<number, Callback>();
map_subscriptions = new Map<number, Callback>();
adapters: Adapter[];
private counter = 0;
constructor({ id = '', adapters, parent = null }: NodeProps = {}) {
this.id = id;
this.parent = parent;
this.adapters = adapters ?? parent?.adapters ?? [new MemoryAdapter(), new LocalForageAdapter()];
}
isBranchNode() {
return this.children.size > 0;
}
/**
*
* @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;
}
private async putValue(value: any, updatedAt: number) {
if (value !== DIR_VALUE) {
this.children = new Map();
}
const nodeValue: NodeValue = {
updatedAt,
value,
};
const promises = this.adapters.map((adapter) => adapter.set(this.id, nodeValue));
this.on_subscriptions.forEach((callback) => {
callback(value, this.id, updatedAt, () => {});
});
await Promise.all(promises);
}
private async putChildValues(value: Record<string, any>, updatedAt: number) {
const promises = this.adapters.map((adapter) =>
adapter.set(this.id, { value: DIR_VALUE, updatedAt }),
);
const children = Object.keys(value);
// the following probably causes the same callbacks to be fired too many times
const childPromises = children.map((key) => this.get(key).put(value[key], updatedAt));
await Promise.all([...promises, ...childPromises]);
}
/**
* 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: any, updatedAt = Date.now()) {
if (typeof value === 'object' && value !== null) {
await this.putChildValues(value, updatedAt);
} else {
await this.putValue(value, updatedAt);
}
if (this.parent) {
await this.parent.put(DIR_VALUE, updatedAt);
const childName = this.id.split('/').pop()!;
if (!this.parent.children.has(childName)) {
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);
});
}
}
}
doBranchNodeCallback(callback: Callback) {
const aggregated: Record<string, any> = {};
const keys = Array.from(this.children.keys());
const total = keys.length;
let count = 0;
keys.forEach((key) => {
this.children.get(key)?.once((childValue) => {
aggregated[key] = childValue;
count++;
if (count === total) {
callback(aggregated, this.id, Date.now(), () => {});
}
});
});
}
// is it problematic that on behaves differently for leaf and branch nodes?
/**
* Subscribe to a value
* @param callback
*/
on(callback: Callback, returnIfUndefined: boolean = false): Unsubscribe {
let latest: NodeValue | null = null;
const cb = (value, path, updatedAt, unsubscribe) => {
if (value === undefined) {
if (returnIfUndefined) {
callback(value, path, updatedAt, unsubscribe);
}
return;
}
if (value !== DIR_VALUE && (latest === null || latest.updatedAt < value.updatedAt)) {
latest = { value, updatedAt };
callback(value, path, updatedAt, unsubscribe);
// TODO send to other adapters? or PubSub which decides where to send?
}
};
const subId = this.counter++;
this.on_subscriptions.set(subId, cb);
// if it's not a dir, adapters will call the callback directly
const adapterSubs = this.adapters.map((adapter) => adapter.get(this.id, cb));
if (this.isBranchNode()) {
this.doBranchNodeCallback(callback);
}
const unsubscribe = () => {
this.on_subscriptions.delete(subId);
adapterSubs.forEach((unsub) => unsub());
};
return unsubscribe;
}
/**
* Callback for each child node
* @param callback
*/
map(callback: Callback): Unsubscribe {
const id = this.counter++;
this.map_subscriptions.set(id, callback);
const cb = (value, path, updatedAt) => {
const childName = path.split('/').pop()!;
console.log('map callback', this.id, childName, value, updatedAt);
this.get(childName).put(value, updatedAt);
};
const adapterSubs = this.adapters.map((adapter) => adapter.list(this.id, cb));
const unsubscribe = () => {
this.map_subscriptions.delete(id);
adapterSubs.forEach((unsub) => unsub());
};
return unsubscribe;
}
/**
* Same as on(), but will unsubscribe after the first callback
* @param callback
* @param unsubscribe
*/
once(callback?: Callback, returnIfUndefined = false, unsubscribe?: Unsubscribe): Promise<any> {
return new Promise((resolve) => {
const cb = (value, updatedAt, path, unsub) => {
if (unsubscribe) {
unsubscribe();
}
resolve(value);
callback?.(value, updatedAt, path, () => {});
unsub();
};
this.on(cb, returnIfUndefined);
});
}
}

16
src/js/state/types.ts Normal file
View File

@ -0,0 +1,16 @@
export type Unsubscribe = () => void;
export type NodeValue = {
updatedAt: number;
value: any;
};
export type Callback = (
value: any, // must be serializable?
path: string,
updatedAt: number | undefined,
unsubscribe: Unsubscribe,
) => void;
export abstract class Adapter {
abstract get(path: string, callback: Callback): Unsubscribe;
abstract set(path: string, data: NodeValue): Promise<void>;
abstract list(path: string, callback: Callback): Unsubscribe;
}

View File

@ -3,9 +3,14 @@ import { useCallback, useEffect, useState } from 'react';
import localState from '@/state/LocalState.ts'; import localState from '@/state/LocalState.ts';
export default function useLocalState(key: string, initialValue: any = undefined, once = false) { 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) => {
initialValue = val;
});
}
const [value, setValue] = useState(initialValue);
useEffect(() => { 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); setValue(new_value);
if (once) { if (once) {
unsubscribe(); unsubscribe();

View File

@ -42,8 +42,9 @@ const ChatList = ({ activeChat, className }) => {
const unsubs = [] as any[]; const unsubs = [] as any[];
const addToChats = (value, key) => { const addToChats = (value, key) => {
console.log('addToChats', key, value);
setChats((prevChats) => { setChats((prevChats) => {
prevChats.set(key, { ...value }); prevChats.set(key.split('/').pop(), { ...value });
return prevChats; return prevChats;
}); });
setRenderCount((prevCount) => prevCount + 1); setRenderCount((prevCount) => prevCount + 1);

View File

@ -0,0 +1,21 @@
import localState from '@/state/LocalState.ts';
import ExplorerNode from '@/views/explorer/ExplorerNode.tsx';
import View from '@/views/View.tsx';
type Props = {
p?: string;
path: string;
};
const Explorer = ({ p }: Props) => {
return (
<View hideSideBar={true}>
<div>{p}</div>
<div className="m-2 md:mx-4">
<ExplorerNode expanded={true} name="Local state" node={localState} />
</div>
</View>
);
};
export default Explorer;

View File

@ -0,0 +1,107 @@
import { useEffect, useState } from 'react';
import { ChevronRightIcon } from '@heroicons/react/20/solid';
import Show from '@/components/helpers/Show.tsx';
import Node, { DIR_VALUE } from '@/state/Node';
type Props = {
node: Node;
value?: any;
level?: number;
expanded?: boolean;
name?: string;
parentCounter?: number;
};
const VALUE_TRUNCATE_LENGTH = 50;
export default function ExplorerNode({
node,
value = DIR_VALUE,
level = 0,
expanded = false,
name,
parentCounter = 0,
}: Props) {
const [children, setChildren] = useState<{ [key: string]: { node: Node; value: any } }>({});
const [isOpen, setIsOpen] = useState(expanded);
const [showMore, setShowMore] = useState(false);
const isDirectory = value === DIR_VALUE;
useEffect(() => {
if (!isDirectory) return;
return node.map((value, key) => {
if (!children[key]) {
const childName = key.split('/').pop()!;
setChildren((prev) => ({
...prev,
[key]: { node: node.get(childName), value },
}));
}
});
}, [node.id, value]);
const toggleOpen = () => setIsOpen(!isOpen);
const rowColor = parentCounter % 2 === 0 ? 'bg-gray-800' : 'bg-gray-700';
const displayName = name || node.id.split('/').pop()!;
const renderValue = (value) => {
if (typeof value === 'string') {
return (
<span className="text-xs text-blue-400">
{value.length > VALUE_TRUNCATE_LENGTH && (
<span
className="text-xs text-blue-200 cursor-pointer"
onClick={() => setShowMore(!showMore)}
>
Show {showMore ? 'less' : 'more'}{' '}
</span>
)}
"
{showMore
? value
: value.length > VALUE_TRUNCATE_LENGTH
? `${value.substring(0, VALUE_TRUNCATE_LENGTH)}...`
: value}
"
</span>
);
}
return <span className="text-xs text-green-400">{JSON.stringify(value)}</span>;
};
return (
<div className={`relative w-full ${rowColor}`}>
<div
className={`flex items-center text-white ${isDirectory ? 'cursor-pointer' : null}`}
onClick={toggleOpen}
style={{ paddingLeft: `${level * 15}px` }}
>
<Show when={isDirectory}>
<ChevronRightIcon
className={`w-4 h-4 transition ${isOpen ? 'transform rotate-90' : ''}`}
/>
</Show>
<span className="ml-2 w-1/3 truncate">{displayName}</span>
<Show when={!isDirectory}>
<div className="ml-auto w-1/2">{renderValue(value)}</div>
</Show>
</div>
{isOpen ? (
<div>
{Object.values(children).map((child, index) => (
<ExplorerNode
key={node.id + child.node.id}
node={child.node}
level={level + 1}
expanded={false}
value={child.value}
parentCounter={parentCounter + index + 1}
/>
))}
</div>
) : null}
</div>
);
}

View File

@ -49,7 +49,7 @@ const ExistingAccountLogin: React.FC<Props> = ({ fullScreen, onBack }) => {
if (!k) { if (!k) {
return; return;
} }
await Key.login(k, fullScreen); await Key.login(k);
event.target.value = ''; event.target.value = '';
Helpers.copyToClipboard(''); // clear the clipboard Helpers.copyToClipboard(''); // clear the clipboard
}, },

View File

@ -22,7 +22,7 @@ const Login: React.FC<Props> = ({ fullScreen }) => {
const loginAsNewUser = () => { const loginAsNewUser = () => {
console.log('name', name); console.log('name', name);
Key.loginAsNewUser(fullScreen); Key.loginAsNewUser();
localState.get('showFollowSuggestions').put(true); localState.get('showFollowSuggestions').put(true);
name && name &&
setTimeout(() => { setTimeout(() => {