Initial commit

This commit is contained in:
Alois 2026-03-10 20:44:25 +01:00
commit 2f5e10140c
133 changed files with 10429 additions and 0 deletions

View file

@ -0,0 +1,21 @@
{
"name": "@tensamin/core-crypto",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./context": "./src/context.tsx",
"./worker": "./src/worker.ts"
},
"scripts": {
"format": "bunx prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@noble/curves": "^2.0.1",
"@tensamin/ui": "workspace:*",
"comlink": "^4.4.2",
"solid-js": "^1.9.11"
}
}

View file

@ -0,0 +1,107 @@
import {
createContext,
onMount,
onCleanup,
createSignal,
Show,
useContext,
} from "solid-js";
import type { ParentProps } from "solid-js";
import * as Comlink from "comlink";
import Loading from "@tensamin/ui/screens/loading";
export const context = createContext<contextType>();
export default function Provider(props: ParentProps) {
let apiRef: ApiRef | null = null;
const [isWorkerReady, setIsWorkerReady] = createSignal(false);
const { encrypt, decrypt, get_shared_secret } = createCryptoActions(
() => apiRef,
);
onMount(() => {
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
apiRef = Comlink.wrap(worker);
setIsWorkerReady(true);
onCleanup(() => {
apiRef = null;
worker.terminate();
});
});
return (
<Show when={isWorkerReady()} fallback={<Loading progress={10} />}>
<context.Provider value={{ encrypt, decrypt, get_shared_secret }}>
{props.children}
</context.Provider>
</Show>
);
}
type contextType = {
decrypt: (secret: string, data: string) => Promise<string>;
encrypt: (secret: string, data: string) => Promise<string>;
get_shared_secret: (
ownPrivateKey: string,
ownPublicKey: string,
otherPublicKey: string,
) => Promise<string>;
};
type ApiRef = {
encrypt: (secret: string, message: string) => Promise<string>;
decrypt: (secret: string, encryptedMessage: string) => Promise<string>;
get_shared_secret: (
own_private_key: string,
own_public_key: string,
other_public_key: string,
) => Promise<string>;
};
export function createCryptoActions(
getApiRef: () => ApiRef | null,
): contextType {
const encrypt = async (secret: string, message: string): Promise<string> => {
const apiRef = getApiRef();
if (!apiRef) throw "API not initialized";
return await apiRef.encrypt(secret, message);
};
const decrypt = async (
secret: string,
encryptedMessage: string,
): Promise<string> => {
const apiRef = getApiRef();
if (!apiRef) throw "API not initialized";
return await apiRef.decrypt(secret, encryptedMessage);
};
const get_shared_secret = async (
own_private_key: string,
own_public_key: string,
other_public_key: string,
): Promise<string> => {
const apiRef = getApiRef();
if (!apiRef) throw "API not initialized";
return await apiRef.get_shared_secret(
own_private_key,
own_public_key,
other_public_key,
);
};
return { encrypt, decrypt, get_shared_secret };
}
export function useCrypto(): contextType {
const ctx = useContext(context);
if (!ctx) {
throw new Error("useCrypto must be used within a CryptoProvider");
}
return ctx;
}

View file

@ -0,0 +1,364 @@
import * as Comlink from "comlink";
type Base64URLString = string;
type JWK = {
kty: string;
crv: string;
x?: string;
d?: string;
};
const textEncoder = new TextEncoder();
const crypto = globalThis.crypto;
export async function encrypt(
password: string,
input: string,
): Promise<string> {
const sharedSecret = new Uint8Array(
password.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
);
const hkdfKey = await crypto.subtle.importKey(
"raw",
sharedSecret,
"HKDF",
false,
["deriveBits"],
);
const okm = await crypto.subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array([]),
info: textEncoder.encode("x448-aes-gcm-no-overhead"),
},
hkdfKey,
44 * 8,
);
const okmBytes = new Uint8Array(okm);
const keyBytes = okmBytes.slice(0, 32);
const nonce = okmBytes.slice(32, 44);
const aesKey = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "AES-GCM" },
false,
["encrypt"],
);
const encryptedBuffer = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce },
aesKey,
textEncoder.encode(input),
);
return btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer)));
}
export async function decrypt(
password: string,
input: Base64URLString | string,
): Promise<string> {
const sharedSecret = new Uint8Array(
password.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
);
const ciphertext = Uint8Array.from(atob(input), (c) => c.charCodeAt(0));
const hkdfKey = await crypto.subtle.importKey(
"raw",
sharedSecret,
"HKDF",
false,
["deriveBits"],
);
const okm = await crypto.subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array([]),
info: textEncoder.encode("x448-aes-gcm-no-overhead"),
},
hkdfKey,
44 * 8,
);
const okmBytes = new Uint8Array(okm);
const keyBytes = okmBytes.slice(0, 32);
const nonce = okmBytes.slice(32, 44);
const aesKey = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "AES-GCM" },
false,
["decrypt"],
);
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: nonce,
},
aesKey,
ciphertext,
);
return new TextDecoder().decode(decryptedBuffer);
}
export async function get_shared_secret(
own_private_key: string,
own_public_key: string,
other_public_key: string,
): Promise<string> {
const other_jwk: JWK = { kty: "OKP", crv: "X448", x: other_public_key };
const own_jwk: JWK = {
kty: "OKP",
crv: "X448",
x: own_public_key,
d: own_private_key,
};
const bytesToHex = (u8: Uint8Array): string =>
Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
const b64ToBytes = (s: Base64URLString): Uint8Array => {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
};
const b64uToBytes = (s: Base64URLString): Uint8Array => {
const b64 =
s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
return b64ToBytes(b64);
};
const bytesToB64u = (u8: Uint8Array): string => {
const b64 = btoa(String.fromCharCode(...u8));
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
};
const decodeBase64Auto = (s: string): Uint8Array =>
/[-_]/.test(s) ? b64uToBytes(s) : b64ToBytes(s);
const readTLV = (view: Uint8Array, off: number) => {
const tag = view[off++];
if (off >= view.length) throw new Error("DER: truncated");
let len = view[off++];
if (len & 0x80) {
const n = len & 0x7f;
if (n === 0) throw new Error("DER: indefinite length not supported");
if (off + n > view.length) throw new Error("DER: truncated length");
len = 0;
for (let i = 0; i < n; i++) len = (len << 8) | view[off++];
}
const start = off;
const end = off + len;
if (end > view.length) throw new Error("DER: content truncated");
return { tag, len, start, end };
};
const ensureOidX448 = (view: Uint8Array, start: number): boolean => {
const oid = readTLV(view, start);
if (oid.tag !== 0x06) return false;
const len = oid.end - oid.start;
if (len !== 3) return false;
return (
view[oid.start] === 0x2b &&
view[oid.start + 1] === 0x65 &&
view[oid.start + 2] === 0x6f
);
};
const extractRawX448FromSPKI = (spkiBytes: Uint8Array): Uint8Array => {
const view = spkiBytes;
const outer = readTLV(view, 0);
if (outer.tag !== 0x30) throw new Error("SPKI: expected SEQUENCE");
const alg = readTLV(view, outer.start);
if (alg.tag !== 0x30) throw new Error("SPKI: expected AlgorithmIdentifier");
if (!ensureOidX448(view, alg.start)) throw new Error("SPKI: not X448");
const bitstr = readTLV(view, alg.end);
if (bitstr.tag !== 0x03) throw new Error("SPKI: expected BIT STRING");
const unusedBits = view[bitstr.start];
if (unusedBits !== 0x00) throw new Error("SPKI: unexpected unused bits");
const raw = view.subarray(bitstr.start + 1, bitstr.end);
if (raw.length !== 56)
throw new Error("SPKI: X448 public key must be 56 bytes");
return raw;
};
const extractRawX448FromPKCS8 = (pkcs8Bytes: Uint8Array): Uint8Array => {
const view = pkcs8Bytes;
const outer = readTLV(view, 0);
if (outer.tag !== 0x30) throw new Error("PKCS8: expected SEQUENCE");
let off = outer.start;
const version = readTLV(view, off);
if (version.tag !== 0x02)
throw new Error("PKCS8: expected version INTEGER");
off = version.end;
const alg = readTLV(view, off);
if (alg.tag !== 0x30)
throw new Error("PKCS8: expected AlgorithmIdentifier");
if (!ensureOidX448(view, alg.start)) throw new Error("PKCS8: not X448");
off = alg.end;
const priv = readTLV(view, off);
if (priv.tag !== 0x04)
throw new Error("PKCS8: expected privateKey OCTET STRING");
let raw = view.subarray(priv.start, priv.end);
// Some encoders nest another OCTET STRING inside
if (raw[0] === 0x04) {
const inner = readTLV(raw, 0);
if (inner.tag === 0x04) {
raw = raw.subarray(inner.start, inner.end);
}
}
if (raw.length !== 56)
throw new Error("PKCS8: X448 private key must be 56 bytes");
return raw;
};
const normalizeOkpX448Jwk = (jwk: JWK, label: string): JWK => {
if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "X448") {
throw new Error(`${label}: expected OKP JWK with crv "X448"`);
}
const out = { ...jwk };
if (out.x) {
const xBytes = decodeBase64Auto(out.x);
let rawX: Uint8Array;
try {
rawX = extractRawX448FromSPKI(xBytes);
} catch {
if (xBytes.length !== 56) {
throw new Error(
`${label}: "x" is not a valid X448 SPKI or raw 56-byte key`,
);
}
rawX = xBytes;
}
out.x = bytesToB64u(rawX);
}
if (out.d) {
const dBytes = decodeBase64Auto(out.d);
let rawD: Uint8Array;
try {
rawD = extractRawX448FromPKCS8(dBytes);
} catch {
if (dBytes.length !== 56) {
throw new Error(
`${label}: "d" is not a valid X448 PKCS#8 or raw 56-byte key`,
);
}
rawD = dBytes;
}
out.d = bytesToB64u(rawD);
}
return out;
};
const getSubtle = () => globalThis.crypto?.subtle;
{
/*
const hkdfAesGcmFromShared = async (
sharedSecret: BufferSource,
infoStr: string
): Promise<CryptoKey> => {
const subtle = getSubtle();
if (!subtle) throw new Error("WebCrypto subtle not available");
const info = textEncoder.encode(infoStr);
const baseKey = await subtle.importKey(
"raw",
sharedSecret,
"HKDF",
false,
["deriveKey"]
);
return await subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array(0),
info,
},
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
};
*/
}
const myJwk: JWK = normalizeOkpX448Jwk(own_jwk, "own_jwk");
const peerJwk: JWK = normalizeOkpX448Jwk(other_jwk, "other_jwk");
const subtle = getSubtle();
//const infoStr = `ECDH-X448-AES-GCM-v1|my=${myJwk.x}|peer=${peerJwk.x}`;
if (subtle) {
const algorithms = [{ name: "ECDH", namedCurve: "X448" }, { name: "X448" }];
for (const algorithm of algorithms) {
try {
const [myPriv, peerPub] = await Promise.all([
subtle.importKey("jwk", myJwk, algorithm, false, ["deriveBits"]),
subtle.importKey("jwk", peerJwk, algorithm, false, []),
]);
const sharedBits = await subtle.deriveBits(
{ name: algorithm.name, public: peerPub },
myPriv,
448,
);
const sharedSecret = new Uint8Array(sharedBits);
//const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr);
return bytesToHex(sharedSecret);
} catch {
// Browser doesn't support this algorithm, try next or fall through to software fallback
}
}
}
const { d: dMyB64u } = myJwk;
//const { x: xMyB64u, d: dMyB64u } = myJwk;
const { x: xPeerB64u } = peerJwk;
if (!dMyB64u || !xPeerB64u) {
return "Failed to get shared secret due to missing keys";
}
const [dRaw, xRawPeer] = [b64uToBytes(dMyB64u), b64uToBytes(xPeerB64u)];
if (dRaw.length !== 56 || xRawPeer.length !== 56) {
return "Failed to get shared secret due to invalid key lengths";
}
const { x448 } = await import("@noble/curves/ed448.js");
const sharedSecret = new Uint8Array(x448.getSharedSecret(dRaw, xRawPeer));
//const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr);
return bytesToHex(sharedSecret);
}
Comlink.expose({
encrypt,
decrypt,
get_shared_secret,
});

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

View file

@ -0,0 +1,23 @@
{
"name": "@tensamin/markdown",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./text": "./src/text.tsx",
"./input": "./src/input.tsx"
},
"scripts": {
"format": "bunx prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.16",
"@tensamin/ui": "workspace:*",
"solid-js": "^1.9.11"
}
}

View file

@ -0,0 +1,364 @@
import { markdown } from "@codemirror/lang-markdown";
import {
EditorState,
type Extension,
type Range,
type SelectionRange,
} from "@codemirror/state";
import {
Decoration,
EditorView,
keymap,
placeholder,
ViewPlugin,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
import {
createEffect,
createMemo,
onCleanup,
onMount,
type Setter,
} from "solid-js";
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
export type InputProps = {
ref?: HTMLDivElement;
placeholder?: string;
value: string;
setValue: Setter<string>;
onSubmit?: () => void;
invertEnterBehavior?: boolean;
};
type TokenRange = {
from: number;
to: number;
};
const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" });
const strongDecoration = Decoration.mark({ class: "tm-md-strong" });
const emDecoration = Decoration.mark({ class: "tm-md-em" });
const delDecoration = Decoration.mark({ class: "tm-md-del" });
const codeDecoration = Decoration.mark({ class: "tm-md-code" });
const linkDecoration = Decoration.mark({ class: "tm-md-link" });
const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" });
/**
* Builds markdown styling decorations every time the document or cursor selection changes.
* Token delimiters are hidden unless the cursor is currently intersecting that token range.
*/
const markdownDecorations = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
}
}
},
{
decorations: (instance: { decorations: DecorationSet }) =>
instance.decorations,
},
);
export default function Input(props: InputProps) {
ensureMarkdownStyles();
// eslint-disable-next-line no-unassigned-vars
let element: HTMLDivElement | undefined;
let view: EditorView | undefined;
let ignoreSync = false;
const externalValue = createMemo(() => props.value);
onMount(() => {
if (!element) return;
const state = EditorState.create({
doc: props.value,
extensions: createEditorExtensions(
(value) => {
ignoreSync = true;
props.setValue(value);
},
() => props.placeholder,
() => Boolean(props.invertEnterBehavior),
() => props.onSubmit?.(),
),
});
view = new EditorView({
state,
parent: element,
});
});
createEffect(() => {
const editor = view;
if (!editor) return;
const next = externalValue();
const current = editor.state.doc.toString();
if (ignoreSync) {
ignoreSync = false;
return;
}
if (next === current) return;
editor.dispatch({
changes: {
from: 0,
to: current.length,
insert: next,
},
});
});
onCleanup(() => {
view?.destroy();
view = undefined;
});
return <div ref={element} class="tm-md-root" />;
}
function createEditorExtensions(
onChange: (value: string) => void,
getPlaceholder: () => string | undefined,
getInvertEnterBehavior: () => boolean,
onSubmit: () => void,
): Extension[] {
const customEnterKeymap = keymap.of([
{
key: "Shift-Enter",
run: () => {
if (!getInvertEnterBehavior()) {
return false;
}
onSubmit();
return true;
},
},
{
key: "Enter",
run: () => {
if (getInvertEnterBehavior()) {
return false;
}
onSubmit();
return true;
},
},
]);
return [
history(),
markdown(),
customEnterKeymap,
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
EditorView.lineWrapping,
placeholder(getPlaceholder() ?? ""),
EditorView.updateListener.of((update: ViewUpdate) => {
if (!update.docChanged) return;
onChange(update.state.doc.toString());
}),
EditorView.theme({
"&": {
fontSize: "1rem",
},
"&.cm-editor": {
width: "100%",
},
}),
EditorView.editorAttributes.of({
class: "tm-md-editor",
spellcheck: "true",
"aria-label": "Markdown input",
}),
markdownDecorations,
];
}
function buildDecorations(view: EditorView): DecorationSet {
const builder: Range<Decoration>[] = [];
const selections = view.state.selection.ranges.map(
(range: SelectionRange) => ({
from: range.from,
to: range.to,
}),
);
let codeFenceOpen = false;
for (
let lineNumber = 1;
lineNumber <= view.state.doc.lines;
lineNumber += 1
) {
const line = view.state.doc.line(lineNumber);
const text = line.text;
const lineFrom = line.from;
const trimmed = text.trim();
const fence = text.match(/^```\s*([^`]*)$/);
if (fence) {
const ticksStart = lineFrom + text.indexOf("```");
const ticksEnd = ticksStart + 3;
addHiddenToken(builder, selections, { from: ticksStart, to: ticksEnd });
if (trimmed.length > 3) {
addHiddenToken(builder, selections, {
from: ticksEnd,
to: line.to,
});
}
codeFenceOpen = !codeFenceOpen;
continue;
}
if (codeFenceOpen) {
builder.push(codeLineDecoration.range(lineFrom));
continue;
}
const heading = text.match(/^(#{1,6})\s+/);
if (heading) {
const markerLength = heading[0].length;
addHiddenToken(builder, selections, {
from: lineFrom,
to: lineFrom + markerLength,
});
const level = heading[1].length;
const headingClass = Decoration.mark({
class: `tm-md-heading tm-md-h${String(level)}`,
});
const contentFrom = lineFrom + markerLength;
if (contentFrom < line.to) {
builder.push(headingClass.range(contentFrom, line.to));
}
}
const quote = text.match(/^>\s?/);
if (quote) {
addHiddenToken(builder, selections, {
from: lineFrom,
to: lineFrom + quote[0].length,
});
}
const unordered = text.match(/^(\s*)([-+*])\s+(?:\[( |x|X)\]\s+)?/);
if (unordered) {
const markerStart = lineFrom + unordered[1].length;
const markerEnd = markerStart + unordered[2].length + 1;
addHiddenToken(builder, selections, { from: markerStart, to: markerEnd });
const checkbox = unordered[0].match(/\[( |x|X)\]\s+$/);
if (checkbox) {
const checkboxStart = lineFrom + unordered[0].lastIndexOf("[");
addHiddenToken(builder, selections, {
from: checkboxStart,
to: checkboxStart + checkbox[0].length,
});
}
}
const ordered = text.match(/^(\s*)(\d+\.)\s+/);
if (ordered) {
const markerStart = lineFrom + ordered[1].length;
addHiddenToken(builder, selections, {
from: markerStart,
to: markerStart + ordered[2].length + 1,
});
}
if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(trimmed)) {
if (lineFrom < line.to) {
builder.push(
Decoration.mark({ class: "tm-md-hr" }).range(lineFrom, line.to),
);
}
continue;
}
const tableSeparator = /^\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?$/.test(
text,
);
if (tableSeparator) {
if (lineFrom < line.to) {
builder.push(
Decoration.mark({ class: "tm-md-del" }).range(lineFrom, line.to),
);
}
continue;
}
const { styleRanges, tokenRanges } = collectInlineRanges(text, lineFrom);
for (const range of styleRanges) {
if (range.from >= range.to) continue;
if (range.className === "tm-md-strong") {
builder.push(strongDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-em") {
builder.push(emDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-del") {
builder.push(delDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-code") {
builder.push(codeDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-link") {
builder.push(linkDecoration.range(range.from, range.to));
}
}
for (const token of tokenRanges) {
addHiddenToken(builder, selections, token);
}
}
return Decoration.set(builder, true);
}
/**
* Keeps markdown syntax visible only when user selection intersects the token.
* This preserves cursor predictability and cross-token selection while still hiding syntax during reading.
*/
function addHiddenToken(
builder: Range<Decoration>[],
selections: ReadonlyArray<{ from: number; to: number }>,
token: TokenRange,
): void {
if (token.from >= token.to) return;
const overlapsSelection = selections.some((selection) => {
const selectionFrom = Math.min(selection.from, selection.to);
const selectionTo = Math.max(selection.from, selection.to);
if (selectionFrom === selectionTo) {
return selectionFrom >= token.from && selectionFrom <= token.to;
}
return selectionFrom < token.to && selectionTo > token.from;
});
if (overlapsSelection) return;
builder.push(hiddenTokenDecoration.range(token.from, token.to));
}

View file

@ -0,0 +1,623 @@
import { For, Index, type JSX } from "solid-js";
type InlineNode =
| { type: "text"; value: string }
| { type: "strong"; value: string }
| { type: "em"; value: string }
| { type: "del"; value: string }
| { type: "code"; value: string }
| { type: "link"; label: string; href: string }
| { type: "image"; alt: string; src: string };
type InlineDecorationRange = {
from: number;
to: number;
className: string;
};
type InlineTokenRange = {
from: number;
to: number;
};
type ParagraphBlock = {
type: "paragraph";
text: string;
};
type HeadingBlock = {
type: "heading";
level: number;
text: string;
};
type HrBlock = {
type: "hr";
};
type BlockQuoteBlock = {
type: "blockquote";
text: string;
};
type CodeBlock = {
type: "code";
language: string;
code: string;
};
type ListItem = {
text: string;
checked: boolean | null;
};
type ListBlock = {
type: "list";
ordered: boolean;
items: ListItem[];
};
type TableBlock = {
type: "table";
headers: string[];
rows: string[][];
};
type MarkdownBlock =
| ParagraphBlock
| HeadingBlock
| HrBlock
| BlockQuoteBlock
| CodeBlock
| ListBlock
| TableBlock;
const INLINE_TOKEN_REGEX =
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|_([^_\n]+)_/g;
export function parseInlineNodes(input: string): InlineNode[] {
const nodes: InlineNode[] = [];
let cursor = 0;
let match = INLINE_TOKEN_REGEX.exec(input);
while (match) {
const index = match.index;
const raw = match[0];
if (index > cursor) {
nodes.push({ type: "text", value: input.slice(cursor, index) });
}
if (match[1] !== undefined && match[2] !== undefined) {
nodes.push({ type: "image", alt: match[1], src: normalizeUrl(match[2]) });
} else if (match[3] !== undefined && match[4] !== undefined) {
nodes.push({
type: "link",
label: match[3],
href: normalizeUrl(match[4]),
});
} else if (match[5] !== undefined) {
nodes.push({ type: "code", value: match[5] });
} else if (match[6] !== undefined) {
nodes.push({ type: "del", value: match[6] });
} else if (match[7] !== undefined || match[8] !== undefined) {
nodes.push({ type: "strong", value: match[7] ?? match[8] ?? "" });
} else if (match[9] !== undefined || match[10] !== undefined) {
nodes.push({ type: "em", value: match[9] ?? match[10] ?? "" });
} else {
nodes.push({ type: "text", value: raw });
}
cursor = index + raw.length;
match = INLINE_TOKEN_REGEX.exec(input);
}
if (cursor < input.length) {
nodes.push({ type: "text", value: input.slice(cursor) });
}
INLINE_TOKEN_REGEX.lastIndex = 0;
return nodes;
}
export function collectInlineRanges(
input: string,
offset = 0,
): {
styleRanges: InlineDecorationRange[];
tokenRanges: InlineTokenRange[];
} {
const styleRanges: InlineDecorationRange[] = [];
const tokenRanges: InlineTokenRange[] = [];
let match = INLINE_TOKEN_REGEX.exec(input);
while (match) {
const raw = match[0];
const start = offset + match.index;
const end = start + raw.length;
if (match[1] !== undefined && match[2] !== undefined) {
const openLength = 2;
const closeLength = raw.endsWith(")") ? 1 : 0;
const imageEnd = start + openLength + match[1].length;
tokenRanges.push({ from: start, to: start + openLength });
tokenRanges.push({ from: imageEnd, to: imageEnd + 1 });
const srcStart = imageEnd + 1;
const srcEnd = end - closeLength;
tokenRanges.push({ from: srcStart, to: srcStart + 1 });
tokenRanges.push({ from: srcEnd, to: srcEnd + closeLength });
} else if (match[3] !== undefined && match[4] !== undefined) {
const label = match[3];
const labelStart = start + 1;
const labelEnd = labelStart + label.length;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: labelEnd, to: labelEnd + 1 });
tokenRanges.push({ from: labelEnd + 1, to: labelEnd + 2 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: labelStart,
to: labelEnd,
className: "tm-md-link",
});
} else if (match[5] !== undefined) {
const codeStart = start + 1;
const codeEnd = end - 1;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: codeStart,
to: codeEnd,
className: "tm-md-code",
});
} else if (match[6] !== undefined) {
const contentStart = start + 2;
const contentEnd = end - 2;
tokenRanges.push({ from: start, to: start + 2 });
tokenRanges.push({ from: end - 2, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-del",
});
} else if (match[7] !== undefined || match[8] !== undefined) {
const contentStart = start + 2;
const contentEnd = end - 2;
tokenRanges.push({ from: start, to: start + 2 });
tokenRanges.push({ from: end - 2, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-strong",
});
} else if (match[9] !== undefined || match[10] !== undefined) {
const contentStart = start + 1;
const contentEnd = end - 1;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-em",
});
}
match = INLINE_TOKEN_REGEX.exec(input);
}
INLINE_TOKEN_REGEX.lastIndex = 0;
return { styleRanges, tokenRanges };
}
export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: MarkdownBlock[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
const codeFence = line.match(/^```\s*([^`]*)$/);
if (codeFence) {
const language = (codeFence[1] ?? "").trim();
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) {
index += 1;
}
blocks.push({ type: "code", language, code: codeLines.join("\n") });
continue;
}
if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(line.trim())) {
blocks.push({ type: "hr" });
index += 1;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
blocks.push({
type: "heading",
level: heading[1].length,
text: heading[2],
});
index += 1;
continue;
}
const quote = line.match(/^>\s?(.*)$/);
if (quote) {
const quoteLines: string[] = [quote[1]];
index += 1;
while (index < lines.length) {
const next = lines[index].match(/^>\s?(.*)$/);
if (!next) break;
quoteLines.push(next[1]);
index += 1;
}
blocks.push({ type: "blockquote", text: quoteLines.join("\n") });
continue;
}
const tableCandidate = readTable(lines, index);
if (tableCandidate) {
blocks.push(tableCandidate.block);
index = tableCandidate.nextIndex;
continue;
}
const unordered = line.match(/^\s*[-*+]\s+(.*)$/);
const ordered = line.match(/^\s*\d+\.\s+(.*)$/);
if (unordered || ordered) {
const orderedList = Boolean(ordered);
const items: ListItem[] = [];
while (index < lines.length) {
const current = lines[index];
const match = orderedList
? current.match(/^\s*\d+\.\s+(.*)$/)
: current.match(/^\s*[-*+]\s+(.*)$/);
if (!match) break;
const task = match[1].match(/^\[( |x|X)\]\s+(.*)$/);
if (task) {
items.push({
text: task[2],
checked: task[1].toLowerCase() === "x",
});
} else {
items.push({ text: match[1], checked: null });
}
index += 1;
}
blocks.push({ type: "list", ordered: orderedList, items });
continue;
}
const paragraphLines = [line];
index += 1;
while (
index < lines.length &&
lines[index].trim() &&
!/^(#{1,6})\s+/.test(lines[index]) &&
!/^```\s*/.test(lines[index]) &&
!/^>\s?/.test(lines[index]) &&
!/^\s*[-*+]\s+/.test(lines[index]) &&
!/^\s*\d+\.\s+/.test(lines[index]) &&
!/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(lines[index].trim())
) {
paragraphLines.push(lines[index]);
index += 1;
}
blocks.push({ type: "paragraph", text: paragraphLines.join("\n") });
}
return blocks;
}
export function renderInline(nodes: InlineNode[]): JSX.Element[] {
return nodes.map((node) => {
if (node.type === "text") {
return node.value;
}
if (node.type === "strong") {
return <strong class="tm-md-strong">{node.value}</strong>;
}
if (node.type === "em") {
return <em class="tm-md-em">{node.value}</em>;
}
if (node.type === "del") {
return <del class="tm-md-del">{node.value}</del>;
}
if (node.type === "code") {
return <code class="tm-md-code">{node.value}</code>;
}
if (node.type === "link") {
return (
<a class="tm-md-link" href={node.href} target="_blank" rel="noreferrer">
{node.label}
</a>
);
}
return (
<img
class="tm-md-image"
src={node.src}
alt={node.alt}
loading="lazy"
decoding="async"
/>
);
});
}
export function renderBlocks(blocks: MarkdownBlock[]): JSX.Element {
return (
<For each={blocks}>
{(block) => {
if (block.type === "heading") {
const className = `tm-md-heading tm-md-h${String(block.level)}`;
if (block.level === 1)
return (
<h1 class={className}>
{renderInline(parseInlineNodes(block.text))}
</h1>
);
if (block.level === 2)
return (
<h2 class={className}>
{renderInline(parseInlineNodes(block.text))}
</h2>
);
if (block.level === 3)
return (
<h3 class={className}>
{renderInline(parseInlineNodes(block.text))}
</h3>
);
if (block.level === 4)
return (
<h4 class={className}>
{renderInline(parseInlineNodes(block.text))}
</h4>
);
if (block.level === 5)
return (
<h5 class={className}>
{renderInline(parseInlineNodes(block.text))}
</h5>
);
return (
<h6 class={className}>
{renderInline(parseInlineNodes(block.text))}
</h6>
);
}
if (block.type === "blockquote") {
return (
<blockquote class="tm-md-blockquote">
<For each={block.text.split("\n")}>
{(line) => <p>{renderInline(parseInlineNodes(line))}</p>}
</For>
</blockquote>
);
}
if (block.type === "code") {
return (
<pre class="tm-md-pre">
<code class="tm-md-codeblock" data-language={block.language}>
{block.code}
</code>
</pre>
);
}
if (block.type === "list") {
const Tag = block.ordered ? "ol" : "ul";
return (
<Tag class={block.ordered ? "tm-md-ol" : "tm-md-ul"}>
<For each={block.items}>
{(item) => (
<li class="tm-md-li">
{item.checked !== null ? (
<input
type="checkbox"
checked={item.checked}
disabled
class="tm-md-checkbox"
/>
) : null}
<span>{renderInline(parseInlineNodes(item.text))}</span>
</li>
)}
</For>
</Tag>
);
}
if (block.type === "table") {
return (
<div class="tm-md-table-wrap">
<table class="tm-md-table">
<thead>
<tr>
<For each={block.headers}>
{(header) => (
<th>{renderInline(parseInlineNodes(header))}</th>
)}
</For>
</tr>
</thead>
<tbody>
<For each={block.rows}>
{(row) => (
<tr>
<For each={row}>
{(cell) => (
<td>{renderInline(parseInlineNodes(cell))}</td>
)}
</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
);
}
if (block.type === "hr") {
return <hr class="tm-md-hr" />;
}
return (
<p class="tm-md-p">
<Index each={block.text.split("\n")}>
{(line, idx) => (
<>
{idx > 0 ? <br /> : null}
{renderInline(parseInlineNodes(line()))}
</>
)}
</Index>
</p>
);
}}
</For>
);
}
function normalizeUrl(input: string): string {
const value = input.trim();
if (/^(https?:|mailto:|tel:|\/)/i.test(value)) {
return value;
}
return "#";
}
function splitTableRow(row: string): string[] {
const cleaned = row.trim().replace(/^\|/, "").replace(/\|$/, "");
return cleaned.split("|").map((cell) => cell.trim());
}
function readTable(
lines: string[],
index: number,
): { block: TableBlock; nextIndex: number } | null {
const header = lines[index] ?? "";
const separator = lines[index + 1] ?? "";
if (!header.includes("|") || !separator.includes("|")) {
return null;
}
const separatorCells = splitTableRow(separator);
const isSeparator = separatorCells.every((cell) => /^:?-{3,}:?$/.test(cell));
if (!isSeparator) {
return null;
}
const headers = splitTableRow(header);
const rows: string[][] = [];
let cursor = index + 2;
while (cursor < lines.length && lines[cursor].includes("|")) {
rows.push(splitTableRow(lines[cursor]));
cursor += 1;
}
return {
block: { type: "table", headers, rows },
nextIndex: cursor,
};
}
export const markdownStyles = `
.tm-md-root { color: var(--foreground); line-height: 1.55; font-size: 0.95rem; }
.tm-md-root * { box-sizing: border-box; }
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
.tm-md-h1 { font-size: 1.65rem; }
.tm-md-h2 { font-size: 1.45rem; }
.tm-md-h3 { font-size: 1.25rem; }
.tm-md-h4 { font-size: 1.1rem; }
.tm-md-h5 { font-size: 1rem; }
.tm-md-h6 { font-size: 0.95rem; opacity: 0.9; }
.tm-md-p { margin: 0.25rem 0; }
.tm-md-blockquote { margin: 0.45rem 0; padding-left: 0.75rem; border-left: 2px solid var(--border); opacity: 0.95; }
.tm-md-blockquote p { margin: 0.2rem 0; }
.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border: 1px solid var(--border); border-radius: 0.5rem; background: var(--muted); overflow-x: auto; }
.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.tm-md-code { padding: 0.08rem 0.32rem; border-radius: 0.28rem; background: var(--muted); }
.tm-md-strong { font-weight: 700; }
.tm-md-em { font-style: italic; }
.tm-md-del { text-decoration: line-through; }
.tm-md-link { color: var(--primary); text-decoration: underline; text-underline-offset: 0.14rem; }
.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; }
.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; }
.tm-md-li { margin: 0.2rem 0; }
.tm-md-checkbox { margin-right: 0.5rem; vertical-align: middle; }
.tm-md-table-wrap { overflow-x: auto; margin: 0.45rem 0; }
.tm-md-table { border-collapse: collapse; width: 100%; min-width: 16rem; }
.tm-md-table th, .tm-md-table td { border: 1px solid var(--border); padding: 0.4rem 0.5rem; text-align: left; }
.tm-md-table th { background: var(--muted); font-weight: 600; }
.tm-md-hr { border: 0; border-top: 1px solid var(--border); margin: 0.55rem 0; }
.cm-editor.tm-md-editor { border: 1px solid var(--border); border-radius: 0.65rem; background: var(--background); }
.cm-editor.tm-md-editor.cm-focused { outline: 2px solid var(--ring); outline-offset: 1px; }
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
.cm-editor.tm-md-editor .cm-content { padding: 0.7rem 0.85rem; min-height: 2.75rem; }
.cm-editor.tm-md-editor .cm-line { padding: 0 1px; }
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: var(--muted); border-radius: 0.3rem; }
`;
export function ensureMarkdownStyles(): void {
if (typeof document === "undefined") return;
const styleId = "tensamin-markdown-styles";
if (document.getElementById(styleId)) return;
const style = document.createElement("style");
style.id = styleId;
style.textContent = markdownStyles;
document.head.appendChild(style);
}

View file

@ -0,0 +1,19 @@
import { createMemo } from "solid-js";
import {
ensureMarkdownStyles,
parseMarkdownBlocks,
renderBlocks,
} from "./markdown";
export type TextProps = {
value: string;
};
export default function Text(props: TextProps) {
ensureMarkdownStyles();
const blocks = createMemo(() => parseMarkdownBlocks(props.value));
return <div class="tm-md-root">{renderBlocks(blocks())}</div>;
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

View file

@ -0,0 +1,20 @@
{
"name": "@tensamin/core-storage",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./context": "./src/context.tsx",
"./indexed-db": "./src/indexed-db.ts"
},
"scripts": {
"format": "bunx prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tensamin/shared": "workspace:*",
"@tensamin/ui": "workspace:*",
"solid-js": "^1.9.11"
}
}

View file

@ -0,0 +1,131 @@
import {
createContext,
createSignal,
Show,
useContext,
type ParentProps,
} from "solid-js";
import {
type Storage as StorageSchema,
storageDefaults as defaults,
} from "@tensamin/shared/data";
import { getEntry, setEntry, deleteEntry } from "./indexed-db";
import ErrorScreen from "@tensamin/ui/screens/error";
import { createStore } from "solid-js/store";
import { log } from "@tensamin/shared/log";
interface StorageContextValue {
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
save<K extends keyof StorageSchema>(
key: K,
value: StorageSchema[K],
): Promise<void>;
clear: () => Promise<void>;
}
const StorageContext = createContext<StorageContextValue>();
const isIndexedDBSupported = typeof indexedDB !== "undefined";
export default function StorageProvider(props: ParentProps) {
const [storage, setStorage] = createStore<StorageSchema>(defaults);
const [error, setError] = createSignal<string>("");
const [errorDescription, setErrorDescription] = createSignal<string>("");
async function loadIO<K extends keyof StorageSchema>(
key: K,
): Promise<StorageSchema[K]> {
let stored;
try {
stored = await getEntry(key);
} catch (err) {
setError("Failed to load data");
setErrorDescription(
"An error occurred while loading data from IndexedDB. Please try again.",
);
log(0, "Storage", "red", err);
}
if (stored !== undefined) {
setStorage(key, stored);
return stored;
}
return defaults[key];
}
async function saveIO<K extends keyof StorageSchema>(
key: K,
value: StorageSchema[K],
): Promise<void> {
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
await deleteEntry(key);
setStorage(key, defaults[key]);
} else {
await setEntry(key, value);
setStorage(key, value);
}
}
const value: StorageContextValue = {
async load<K extends keyof StorageSchema>(
key: K,
): Promise<StorageSchema[K]> {
if (
storage[key] === undefined ||
JSON.stringify(storage[key]) === JSON.stringify(defaults[key])
) {
const value = await loadIO(key);
setStorage(key, value);
}
return storage[key];
},
async save<K extends keyof StorageSchema>(
key: K,
value: StorageSchema[K],
): Promise<void> {
await saveIO(key, value);
setStorage(key, value);
},
async clear() {
const keys = Object.keys(defaults) as (keyof StorageSchema)[];
await Promise.all(keys.map((key) => deleteEntry(key)));
},
};
// @ts-expect-error development utility
window.save = value.save;
return (
<Show
when={error() !== "" && errorDescription() !== ""}
fallback={
<Show
when={isIndexedDBSupported}
fallback={
<ErrorScreen
error="Unsupported Browser"
description="Your browser does not support IndexedDB, which is required for this application to function."
/>
}
>
<StorageContext.Provider value={value}>
{props.children}
</StorageContext.Provider>
</Show>
}
>
<ErrorScreen error={error()} description={errorDescription()} />
</Show>
);
}
export function useStorage(): StorageContextValue {
const context = useContext(StorageContext);
if (!context) {
throw new Error("useStorage must be used within a StorageProvider");
}
return context;
}

View file

@ -0,0 +1,71 @@
import type { Storage as StorageSchema } from "@tensamin/shared/data";
const DB_NAME = "tensamin";
const DB_VERSION = 1;
const STORE_NAME = "storage";
let dbPromise: Promise<IDBDatabase> | null = null;
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return dbPromise;
}
export async function getEntry<K extends keyof StorageSchema>(
key: K,
): Promise<StorageSchema[K] | undefined> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const request = store.get(key as string);
request.onsuccess = () =>
resolve(request.result as StorageSchema[K] | undefined);
request.onerror = () => reject(request.error);
});
}
export async function setEntry<K extends keyof StorageSchema>(
key: K,
value: StorageSchema[K],
): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const request = store.put(value, key as string);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
export async function deleteEntry<K extends keyof StorageSchema>(
key: K,
): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const request = store.delete(key as string);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

View file

@ -0,0 +1,22 @@
{
"name": "@tensamin/core-user",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./context": "./src/context.tsx",
"./wrapper": "./src/wrapper.tsx",
"./values": "./src/values.ts"
},
"scripts": {
"format": "bunx prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tensamin/ttp": "git+https://github.com/Tensamin/TTP.git",
"@tensamin/shared": "workspace:*",
"solid-js": "^1.9.11",
"zod": "^4.3.6"
}
}

View file

@ -0,0 +1,54 @@
import { createContext, useContext, type ParentProps } from "solid-js";
import { createStore } from "solid-js/store";
import { useSocket } from "@tensamin/ttp/context";
import { socket as schemas } from "@tensamin/shared/data";
import type z from "zod";
import { failedUser } from "./values";
export type User = z.infer<typeof schemas.get_user_data.response>;
interface contextValue {
get(userId: number): Promise<User>;
}
const UserContext = createContext<contextValue>();
export default function UserProvider(props: ParentProps) {
const [storage, setStorage] = createStore<Record<number, User>>({});
const { send } = useSocket();
async function get(userId: number): Promise<User> {
if (storage[userId] === undefined) {
try {
const userData = await send("get_user_data", { user_id: userId });
// Temp, add base64 stuff
userData.data.avatar = userData.data.avatar
? `data:image/png;base64,${userData.data.avatar}`
: undefined;
// Temp end
setStorage(userId, userData.data);
} catch {
setStorage(userId, failedUser);
}
}
return storage[userId];
}
return (
<UserContext.Provider value={{ get }}>
{props.children}
</UserContext.Provider>
);
}
export function useUser(): contextValue {
const context = useContext(UserContext);
if (!context) {
throw new Error("useUser must be used within a UserProvider");
}
return context;
}

View file

@ -0,0 +1,13 @@
import type { User } from "./context";
export const failedUser: User = {
user_id: 0,
display: "Failed",
iota_id: 0,
omikron_connections: [],
online_status: "user_offline",
public_key: "",
sub_end: 0,
sub_level: 0,
username: "failed",
};

View file

@ -0,0 +1,16 @@
import { createEffect, createSignal, type JSX } from "solid-js";
import { useUser, type User } from "./context";
export default function Wrapper(props: {
userId: number;
component: (user: User) => JSX.Element;
}) {
const { get } = useUser();
const [user, setUser] = createSignal<User | null>(null);
createEffect(() => {
get(props.userId).then(setUser);
});
return <>{user() ? props.component(user() as User) : null}</>;
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}