mirror of
https://github.com/irislib/iris-messenger.git
synced 2024-10-18 06:03:22 +00:00
Merge branch 'explorer'
This commit is contained in:
commit
51ec3c89d4
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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' : ''}`}
|
||||||
>
|
>
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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 {
|
||||||
|
@ -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>(),
|
||||||
|
56
src/js/state/LocalForageAdapter.ts
Normal file
56
src/js/state/LocalForageAdapter.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
|
||||||
|
32
src/js/state/MemoryAdapter.ts
Normal file
32
src/js/state/MemoryAdapter.ts
Normal 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
197
src/js/state/Node.test.ts
Normal 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
204
src/js/state/Node.ts
Normal 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
16
src/js/state/types.ts
Normal 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;
|
||||||
|
}
|
@ -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();
|
||||||
|
@ -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);
|
||||||
|
21
src/js/views/explorer/Explorer.tsx
Normal file
21
src/js/views/explorer/Explorer.tsx
Normal 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;
|
107
src/js/views/explorer/ExplorerNode.tsx
Normal file
107
src/js/views/explorer/ExplorerNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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
|
||||||
},
|
},
|
||||||
|
@ -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(() => {
|
||||||
|
Loading…
Reference in New Issue
Block a user