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,33 @@
{
"name": "@tensamin/chat",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./context": "./src/context.tsx",
"./screen": "./src/screen.tsx",
"./behaviour-conversation": "./src/behaviour/conversation.ts",
"./behaviour-community": "./src/behaviour/community.ts"
},
"scripts": {
"format": "bunx prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@solidjs/router": "^0.15.4",
"@tanstack/solid-query": "^5.90.23",
"@tanstack/solid-virtual": "^3.13.21",
"@tensamin/core-crypto": "workspace:*",
"@tensamin/ttp": "git+https://github.com/Tensamin/TTP.git",
"@tensamin/core-storage": "workspace:*",
"@tensamin/core-user": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/ui": "workspace:*",
"lucide-solid": "^0.564.0",
"solid-js": "^1.9.11",
"solid-sonner": "^0.2.8",
"zod": "^4.3.6"
}
}

View file

@ -0,0 +1,3 @@
export function getMessages() {
return 0;
}

View file

@ -0,0 +1,22 @@
import { useSocket } from "@tensamin/ttp/context";
import type { RawMessages } from "../values";
export async function getMessages(
amount: number,
offset: number,
user_id: number,
): Promise<RawMessages> {
const { send } = useSocket();
const messages = await send("messages_get", {
amount: amount,
offset: offset,
user_id: user_id,
});
if (messages.type.startsWith("error")) {
throw new Error(messages.type);
}
return messages.data.messages;
}

View file

@ -0,0 +1,103 @@
import Input from "@tensamin/markdown/input";
import { Card, CardHeader } from "@tensamin/ui/card";
import { useStorage } from "@tensamin/core-storage/context";
import { createEffect, createSignal } from "solid-js";
import { Button } from "@tensamin/ui/button";
import { Plus, Laugh, Clapperboard } from "lucide-solid";
import { useChat } from "../context";
import { useSocket } from "@tensamin/ttp/context";
import { useCrypto } from "@tensamin/core-crypto/context";
import { log, toast } from "@tensamin/shared/log";
export default function InputComponent() {
const [value, setValue] = createSignal("");
const [invertEnterBehavior, setInvertEnterBehavior] = createSignal(false);
const { encrypt } = useCrypto();
const { send } = useSocket();
const { addLiveMessage, sharedSecret, userId } = useChat();
const { load } = useStorage();
createEffect(() => {
void load("chat_invert_enter_behaviour").then((shouldInvert) => {
setInvertEnterBehavior(shouldInvert);
});
});
async function handleSubmit() {
if (value().trim() === "") return;
const time = Date.now();
const currentValue = value();
const currentUserId = userId();
addLiveMessage({
height: 0,
not_encrypted: true,
timestamp: time,
content: currentValue,
sent_by_self: true,
});
const encryptedContext = await encrypt(sharedSecret(), currentValue);
send("message_send", {
height: 0,
content: encryptedContext,
receiver_id: currentUserId,
send_time: time,
}).catch((e) => {
log(0, "Chat", "red", "Failed to send message", e, {
content: currentValue,
encryptedContext,
receiver_id: currentUserId,
send_time: time,
});
toast("error", "Failed to send message");
});
setValue("");
}
return (
<Card class="rounded-none rounded-t-xl border border-input/50 border-b-0">
<CardHeader class="p-0 flex flex-col">
<Input
placeholder="Send a message..."
value={value()}
setValue={setValue}
onSubmit={handleSubmit}
invertEnterBehavior={invertEnterBehavior()}
/>
<div class="w-full flex justify-between gap-2 p-2 pt-0">
<div class="flex gap-2">
<Button
class="w-9 h-9 p-0 border border-ring/10"
variant="secondary"
>
<Plus size={20} />
</Button>
<div class="w-auto flex">
<p>Files list</p>
</div>
</div>
<div class="flex gap-2">
<Button
class="w-9 h-9 p-0 border border-ring/10"
variant="secondary"
>
<Laugh size={20} />
</Button>
<Button
class="w-9 h-9 p-0 border border-ring/10"
variant="secondary"
>
<Clapperboard size={20} />
</Button>
</div>
</div>
</CardHeader>
</Card>
);
}

View file

@ -0,0 +1,80 @@
import { useCrypto } from "@tensamin/core-crypto/context";
import { createEffect, createSignal, onCleanup, Show } from "solid-js";
import { useChat } from "../context";
import type { RawMessage } from "../values";
import { log } from "@tensamin/shared/log";
import Text from "@tensamin/markdown/text";
export default function Message(props: {
message: RawMessage;
notEncrypted?: boolean;
}) {
const { sharedSecret } = useChat();
const { decrypt } = useCrypto();
const message = () => props.message;
const [decodedContent, setDecodedContent] = createSignal("");
const [isReady, setIsReady] = createSignal(false);
createEffect(() => {
if (props.notEncrypted) {
setDecodedContent(message().content);
setIsReady(true);
return;
}
const content = message().content;
const secret = sharedSecret();
let active = true;
if (!secret) {
setIsReady(false);
return;
}
setIsReady(false);
decrypt(secret, content)
.then((value) => {
if (!active) {
return;
}
setDecodedContent(value);
setIsReady(true);
})
.catch((e) => {
if (!active) {
return;
}
log(0, "Chat", "red", "Failed to decrypt message", e, {
content,
secret,
});
setDecodedContent("Failed to decrypt");
setIsReady(true);
});
onCleanup(() => {
active = false;
});
});
return (
<div class="w-full flex justify-start">
<div
class={`animate-in fade-in duration-200 max-w-[80%] rounded-xl px-2 py-1 whitespace-pre-wrap wrap-break-word ${
message().sent_by_self
? "bg-primary text-primary-foreground"
: "bg-muted"
}`}
>
<Show when={isReady()}>
<Text value={decodedContent()} />
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,108 @@
import {
createContext,
createEffect,
createSignal,
useContext,
} from "solid-js";
import type { ParentProps } from "solid-js";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import type { RawMessage, RawMessages } from "./values";
import { useSearchParams } from "@solidjs/router";
import { useCrypto } from "@tensamin/core-crypto/context";
import { useUser } from "@tensamin/core-user/context";
import { useStorage } from "@tensamin/core-storage/context";
export const context = createContext<contextType>();
const queryClient = new QueryClient();
export default function Provider(
props: ParentProps & {
getMessages: (
amount: number,
offset: number,
user_id: number,
) => Promise<RawMessages>;
},
) {
const { get_shared_secret } = useCrypto();
const { get } = useUser();
const { load } = useStorage();
const [liveMessages, setLiveMessages] = createSignal<RawMessages>([]);
const [currentSharedSecret, setCurrentSharedSecret] = createSignal("");
const [searchParams] = useSearchParams();
const userId = () => Number(searchParams.id ?? 0);
createEffect(() => {
const recipientId = userId();
if (!recipientId) {
setCurrentSharedSecret("");
return;
}
get(recipientId).then(async (recipientData) => {
const ownId = await load("user_id");
const privateKey = await load("private_key");
const ownData = await get(ownId);
const sharedSecret = await get_shared_secret(
privateKey,
ownData.public_key,
recipientData.public_key,
);
setCurrentSharedSecret(sharedSecret);
});
});
async function customGetMessages(amount: number, offset: number) {
const messages = await props.getMessages(amount, offset, userId());
const sorted = [...messages].sort((a, b) => a.timestamp - b.timestamp);
return sorted;
}
function addLiveMessage(message: RawMessage) {
setLiveMessages((prev) => [...prev, message]);
}
function clearLiveMessages() {
setLiveMessages([]);
}
return (
<QueryClientProvider client={queryClient}>
<context.Provider
value={{
getMessages: customGetMessages,
liveMessages,
addLiveMessage,
clearLiveMessages,
sharedSecret: currentSharedSecret,
userId,
}}
>
{props.children}
</context.Provider>
</QueryClientProvider>
);
}
type contextType = {
getMessages: (amount: number, offset: number) => Promise<RawMessages>;
liveMessages: () => RawMessages;
addLiveMessage: (message: RawMessage) => void;
clearLiveMessages: () => void;
sharedSecret: () => string;
userId: () => number;
};
export function useChat(): contextType {
const ctx = useContext(context);
if (!ctx) {
throw new Error("useChat must be used within a ChatProvider");
}
return ctx;
}

View file

@ -0,0 +1,204 @@
import { createVirtualizer } from "@tanstack/solid-virtual";
import { useInfiniteQuery } from "@tanstack/solid-query";
import { useChat } from "./context";
import { createEffect, createMemo, createSignal, For } from "solid-js";
import InputComponent from "./components/input";
import Message from "./components/message";
const PAGE_SIZE = 50;
export default function Screen() {
const { getMessages, liveMessages, clearLiveMessages, userId } = useChat();
// eslint-disable-next-line no-unassigned-vars
let scrollRef!: HTMLDivElement;
const [hasScrolledToBottomInitially, setHasScrolledToBottomInitially] =
createSignal(false);
const [lastLiveMessageCount, setLastLiveMessageCount] = createSignal(0);
const [prependAnchor, setPrependAnchor] = createSignal<{
totalSize: number;
scrollTop: number;
} | null>(null);
const messagesQuery = useInfiniteQuery(() => ({
queryKey: ["chat-messages", String(userId())],
initialPageParam: 0,
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, pageParam),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < PAGE_SIZE) {
return undefined;
}
return allPages.length * PAGE_SIZE;
},
}));
const historicalMessages = createMemo(() => {
const pages = messagesQuery.data?.pages ?? [];
return [...pages].reverse().flat();
});
createEffect(() => {
userId();
clearLiveMessages();
setHasScrolledToBottomInitially(false);
setLastLiveMessageCount(0);
setPrependAnchor(null);
});
const messages = createMemo(() => [
...historicalMessages(),
...liveMessages(),
]);
async function onScroll() {
if (!scrollRef) {
return;
}
if (scrollRef.scrollTop > 96) {
return;
}
if (messagesQuery.isFetchingNextPage || !messagesQuery.hasNextPage) {
return;
}
setPrependAnchor({
totalSize: virtualizer.getTotalSize(),
scrollTop: scrollRef.scrollTop,
});
await messagesQuery.fetchNextPage();
}
const virtualizer = createVirtualizer({
get count() {
return messages().length;
},
getScrollElement: () => scrollRef,
estimateSize: (index) => {
const message = messages()[index];
if (!message) {
return 56;
}
let decodedContent = message.content;
try {
decodedContent = atob(message.content);
} catch {
/* noop */
}
const lineCount = decodedContent.split("\n").length;
const wrappedLineCount = Math.ceil(decodedContent.length / 42);
const fileCount = message.files?.length ?? 0;
return 28 + Math.max(lineCount, wrappedLineCount) * 20 + fileCount * 20;
},
overscan: 6,
});
createEffect(() => {
const currentMessages = messages();
if (
!scrollRef ||
hasScrolledToBottomInitially() ||
currentMessages.length === 0
) {
return;
}
queueMicrotask(() => {
if (!scrollRef) {
return;
}
scrollRef.scrollTop = scrollRef.scrollHeight;
setHasScrolledToBottomInitially(true);
});
});
createEffect(() => {
const count = liveMessages().length;
if (!scrollRef) {
return;
}
if (count > lastLiveMessageCount()) {
queueMicrotask(() => {
if (!scrollRef) {
return;
}
scrollRef.scrollTop = scrollRef.scrollHeight;
});
}
setLastLiveMessageCount(count);
});
createEffect(() => {
const anchor = prependAnchor();
if (!scrollRef || !anchor || messagesQuery.isFetchingNextPage) {
return;
}
queueMicrotask(() => {
if (!scrollRef) {
return;
}
const delta = virtualizer.getTotalSize() - anchor.totalSize;
scrollRef.scrollTop = anchor.scrollTop + delta;
setPrependAnchor(null);
});
});
return (
<div class="w-full h-full flex flex-col overflow-hidden px-2">
<div
ref={scrollRef}
id="chat_container"
class={`flex-1 max-h-[calc(100vh-151px)] overflow-y-auto px-2.5`}
onScroll={onScroll}
>
<div
class="relative w-full"
style={{
height: `${virtualizer.getTotalSize()}px`,
}}
>
<For each={virtualizer.getVirtualItems()}>
{(virtualRow) => (
<div
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
padding: "4px 0",
}}
>
<Message
message={messages()[virtualRow.index]}
notEncrypted={messages()[virtualRow.index].not_encrypted}
/>
</div>
)}
</For>
</div>
</div>
<InputComponent />
</div>
);
}

View file

@ -0,0 +1,8 @@
import { z } from "zod";
import { socket } from "@tensamin/shared/data";
export type RawMessages = z.infer<
typeof socket.messages_get.response
>["messages"];
export type RawMessage = RawMessages[number];

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"]
}