Initial commit
This commit is contained in:
commit
2f5e10140c
133 changed files with 10429 additions and 0 deletions
33
packages/chat/package.json
Normal file
33
packages/chat/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
packages/chat/src/behaviour/community.ts
Normal file
3
packages/chat/src/behaviour/community.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function getMessages() {
|
||||
return 0;
|
||||
}
|
||||
22
packages/chat/src/behaviour/conversation.ts
Normal file
22
packages/chat/src/behaviour/conversation.ts
Normal 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;
|
||||
}
|
||||
103
packages/chat/src/components/input.tsx
Normal file
103
packages/chat/src/components/input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
packages/chat/src/components/message.tsx
Normal file
80
packages/chat/src/components/message.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
packages/chat/src/context.tsx
Normal file
108
packages/chat/src/context.tsx
Normal 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;
|
||||
}
|
||||
204
packages/chat/src/screen.tsx
Normal file
204
packages/chat/src/screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
packages/chat/src/values.ts
Normal file
8
packages/chat/src/values.ts
Normal 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];
|
||||
13
packages/chat/tsconfig.json
Normal file
13
packages/chat/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue