feat: basic dom update

This commit is contained in:
tsukino
2023-12-22 15:33:35 -08:00
parent b00608fd3c
commit 03ba98f52e
9 changed files with 276 additions and 47 deletions

View File

@@ -208,7 +208,7 @@ export default class LevelDBAdapter implements BaseDBAdapter {
reverse?: boolean;
limit?: number;
offset?: string;
}): Promise<Any[]> {
}): Promise<Post[]> {
const predicate = (msg: Any) => {
switch (msg.subtype) {
case PostSubtype.Default:
@@ -225,7 +225,7 @@ export default class LevelDBAdapter implements BaseDBAdapter {
const db = this.#indices.global.sublevel(MessageType[MessageType.Post]);
return this.#query(db, predicate, options);
return this.#query(db, predicate, options) as Promise<Post[]>;
}
async getPostsByUser(

View File

@@ -1,4 +1,4 @@
export type StateOptions = { [key: string]: BaseState };
export type StateOptions = { [key: string]: Store };
export type RPC<Params = any> = {
method: string;
@@ -11,14 +11,13 @@ export type RPC<Params = any> = {
export type RPCHandler = (rpc: RPC) => void | Promise<void>;
export type SubscriptionHandler = (
prevState: Map<string, BaseState | any>,
nextState: Map<string, BaseState | any>,
prevState: Map<string, Store | any>,
nextState: Map<string, Store | any>,
) => void | Promise<void>;
export default class BaseState {
private states: Map<string, BaseState | any> = new Map();
export default class Store {
private states: Map<string, Store | any> = new Map();
private handlers: Map<string, RPCHandler> = new Map();
private subscriptions: SubscriptionHandler[] = [];
constructor(states?: StateOptions) {
Object.entries(states || {}).forEach(([key, value]) => {
@@ -26,7 +25,7 @@ export default class BaseState {
});
}
get(key: string): BaseState | undefined {
get<RetType = Store | undefined>(key: string): RetType {
return this.states.get(key);
}
@@ -35,8 +34,6 @@ export default class BaseState {
}
async dispatch(rpc: RPC) {
const prevState = new Map(this.states);
const handler = this.handlers.get(rpc.method);
if (handler) {
@@ -44,19 +41,76 @@ export default class BaseState {
}
for (const [, state] of this.states) {
if (state instanceof BaseState) {
if (state instanceof Store) {
await state.dispatch(rpc);
}
}
}
}
const nextState = this.states;
export type Subscription<ValueType = any> =
| {
next?: (value: ValueType) => void;
error?: (err: Error) => void;
complete?: () => void;
}
| ((value: ValueType) => void);
for (const cb of this.subscriptions) {
await cb(prevState, nextState);
export class Observables<ObservableValue = any> {
#state: ObservableValue;
#error: Error | null = null;
#subscriptions: Subscription[] = [];
get state() {
return this.#state;
}
set state(state: ObservableValue) {
this.#state = state;
for (const sub of this.#subscriptions) {
if (typeof sub === 'function') {
sub(state);
} else if (typeof sub !== 'function' && sub.next) {
sub.next(state);
}
}
}
subscribe(callback: SubscriptionHandler) {
this.subscriptions.push(callback);
get error(): Error | null {
return this.#error;
}
set error(error: Error) {
this.#error = error;
for (const sub of this.#subscriptions) {
if (typeof sub !== 'function' && sub.error) {
sub.error(error);
}
}
}
constructor(state: ObservableValue) {
this.#state = state;
}
done() {
for (const sub of this.#subscriptions) {
if (typeof sub !== 'function' && sub.complete) {
sub.complete();
}
}
}
subscribe<ValueType = any>(subscription: Subscription<ValueType>) {
const currIndex = this.#subscriptions.indexOf(subscription);
if (currIndex === -1) {
this.#subscriptions.push(subscription);
}
return () => {
const index = this.#subscriptions.indexOf(subscription);
if (index !== -1) {
this.#subscriptions.splice(index, 1);
}
};
}
}

87
packages/web/lib/ui.ts Normal file
View File

@@ -0,0 +1,87 @@
interface CustomElementConstructor {
new (): CustomElement;
}
export class CustomElement extends HTMLElement {
css: string;
html: string;
connectedCallback() {
this.attachShadow({ mode: 'open' });
const temp = document.createElement('template');
temp.innerHTML = `<style>${this.css}</style>${this.html}`;
this.shadowRoot?.appendChild(temp.content);
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function html(htmlString: string) {
const temp = document.createElement('template');
temp.innerHTML = htmlString;
return temp.content;
}
export function register(name: string, el: CustomElementConstructor) {
window.customElements.define(name, el);
}
export const Q = (root: ShadowRoot | Element) => {
return {
find: (str: string) => E(root.querySelector(str)),
findAll: (
str: string,
): Element[] & {
patch: (
result: any[],
mapKeyFn: (data: any) => string,
renderFn: (data: any) => Element | DocumentFragment,
) => void;
} => {
const list = Array.prototype.map.call(
root.querySelectorAll(str),
E,
) as Element[];
// @ts-ignore
list.patch = (
result: any[],
mapKeyFn: (data: any) => string,
renderFn: (data: any) => Element,
) => {
const max = Math.max(list.length, result.length);
for (let i = 0; i < max; i++) {
const data = result[i];
const last = list[i];
const lastKey = last?.getAttribute('key');
const currKey = mapKeyFn(data);
if (!last && data) {
root.append(renderFn(data));
} else if (last && !data) {
root.removeChild(last);
} else if (lastKey !== currKey) {
root.replaceChild(renderFn(data), last);
}
}
};
// @ts-ignore
return list;
},
};
};
const E = (el: Element | null) => {
if (!el) return el;
return {
content: (content: string) => {
el.textContent = content;
return el;
},
attr: (key: string, value: string) => {
el.setAttribute(key, value);
return el;
},
getAttribute: el.getAttribute.bind(el),
};
};

View File

@@ -0,0 +1,33 @@
import { CustomElement, Q, register } from '../../../lib/ui.ts';
import { getStore } from '../../state';
import { default as NodeStore } from '../../state/node.ts';
export default class Post extends CustomElement {
css = `
.post {
}
`;
html = `
<div class="post">
<div id="creator"></div>
<div id="content"></div>
<div id="createdAt"></div>
</div>
`;
async connectedCallback() {
super.connectedCallback();
const store = getStore();
const node = store.get<NodeStore>('node');
const hash = this.dataset.hash!;
const post = await node.getPost(hash);
const q = Q(this.shadowRoot!);
q.find('div#creator')!.content(post?.json.creator || '');
q.find('div#content')!.content(post?.json.content || '');
q.find('div#createdAt')!.content(post?.json.createdAt.toDateString() || '');
}
}
register('post-card', Post);

View File

@@ -1,6 +1,7 @@
import { MessageType, Post, PostSubtype, ProofType } from '@autismjs/message';
import { ECDSA } from '../../crypto/src';
import State from './state';
import { getStore } from './state';
import App from './pages/App';
const ecdsa = new ECDSA();
console.log('ecdsa', ecdsa);
@@ -15,12 +16,11 @@ const p = new Post({
console.log('post', p.json);
(async () => {
const state = new State();
const state = getStore();
console.log('state', state);
state.subscribe((prev, next) => {
console.log(prev, next);
});
document.body.append(new App());
state.dispatch({
method: 'node/check',

View File

@@ -0,0 +1,45 @@
import { CustomElement, html, Q, register } from '../../../lib/ui.ts';
import { getStore } from '../../state';
import { default as NodeState } from '../../state/node.ts';
import { Post as PostType } from '@autismjs/message';
import '../../components/Post';
export default class App extends CustomElement {
css = `
.app {
width: 100vw;
height: 100vh;
}
`;
html = `
<div class="app">
<slot></slot>
</div>
`;
async connectedCallback() {
super.connectedCallback();
const state = getStore();
const node = state.get<NodeState>('node');
const app = this.shadowRoot!.querySelector('div.app')!;
const q = Q(app);
node.posts.subscribe<PostType[]>((posts) => {
const old = q.findAll('post-card');
old.patch(
posts,
(post: PostType) => post.messageId,
(post: PostType) =>
html(`
<post-card
key="${post.messageId}"
data-hash="${post.hash}"
/>
`),
);
});
}
}
register('app-container', App);

View File

@@ -1,16 +1,10 @@
import Node from './node.ts';
import BaseState from '../../lib/state.ts';
import Store from '../../lib/state.ts';
class State extends BaseState {
constructor() {
super({
node: new Node(),
});
const store = new Store({
node: new Node(),
});
this.rpc('state/check', (rpc) => {
console.log(rpc);
});
}
}
export default State;
export const getStore = () => {
return store;
};

View File

@@ -1,20 +1,21 @@
import BaseState, { type StateOptions } from '../../lib/state.ts';
import Store, { Observables, type StateOptions } from '../../lib/state.ts';
import { Autism } from '@autismjs/protocol/src/services/browser.ts';
import { Post } from '@autismjs/message';
export default class Node extends BaseState {
export default class Node extends Store {
node: Autism;
wait: Promise<void>;
posts = new Observables<Post[]>([]);
constructor(options?: StateOptions) {
super(options);
const node = new Autism({
bootstrap: [
'/ip4/127.0.0.1/tcp/57575/ws/p2p/12D3KooWSoKnYV5idyrrJt3T6WM4eB6wcu58zUuy5bj7cEZNLwdm',
],
});
node.on('p2p:peer:discovery', (peer) => {
// console.log('peer discovered', peer);
});
this.node = node;
node.on('p2p:peer:connect', (peer) => {
console.log('peer connected', peer);
@@ -22,20 +23,28 @@ export default class Node extends BaseState {
node.on('pubsub:message:success', (peer) => {
console.log('pubsub:message:success', peer);
this.updatePosts();
});
node.on('sync:new_message', (peer) => {
console.log('sync:new_message', peer);
this.updatePosts();
});
this.node = node;
this.updatePosts();
this.wait = new Promise(async (r) => {
await this.node.start();
this.updatePosts();
r();
});
this.rpc('node/check', (rpc) => {
console.log(rpc);
});
}
updatePosts = async () => {
this.posts.state = await this.node.db.db.getPosts();
};
getPost = async (hash: string): Promise<Post | null> => {
return this.node.db.db.getMessage(hash) as Promise<Post | null>;
};
}

View File

@@ -4,9 +4,16 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Auti.sm</title>
<style>
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
}
</style>
</head>
<body>
<div id="app-container"></div>
<script src="app.js"></script>
</body>
</html>