diff --git a/src/app/app.ts b/src/app/app.ts index b79a940..1150284 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -165,19 +165,45 @@ export default class Enclave

{ return; case "Messages": - Object.entries(msg.messages).forEach(([channelId, messages]) => { - if (!server.messages[channelId]) { - server.messages[channelId] = {}; - } + const authors = Object.entries(msg.messages).flatMap( + ([channelId, messages]) => { + if (!server.messages[channelId]) { + server.messages[channelId] = {}; + } - messages.forEach((message) => { - server.messages[channelId][message.id] = message; - }); + return messages.map((message) => { + server.messages[channelId][message.id] = message; + return message.author; + }); + }, + ); + + const dedupedAuthors = Array.from(new Set(authors)); + + this.server?.getUsers(dedupedAuthors); + + this.forceRender(); + + return; + + case "Users": + Object.entries(msg.users).forEach(([pubKey, user]) => { + server.users[pubKey] = user; }); this.forceRender(); return; + + case "MessageDeleted": + delete server.messages[msg.channel_id][msg.message_id]; + this.forceRender(); + return; + + case "MessageEdited": + server.messages[msg.channel_id][msg.message.id] = msg.message; + this.forceRender(); + return; } } } diff --git a/src/app/protocol.ts b/src/app/protocol.ts index ed48003..0d1c204 100644 --- a/src/app/protocol.ts +++ b/src/app/protocol.ts @@ -15,6 +15,20 @@ export type ClientMethod = | { method: "Messages"; messages: Record; + } + | { + method: "Users"; + users: Record; + } + | { + method: "MessageEdited"; + channel_id: string; + message: StoredMessage; + } + | { + method: "MessageDeleted"; + channel_id: string; + message_id: string; }; export type ServerMethod = @@ -41,4 +55,20 @@ export type ServerMethod = method: "GetMessages"; channel_id: string; chunk: number; + } + | { + method: "GetUsers"; + pubkeys: string[]; + } + | { + method: "EditMessage"; + message_id: string; + channel_id: string; + content: string; + signature: string; + } + | { + method: "DeleteMessage"; + channel_id: string; + message_id: string; }; diff --git a/src/app/server.ts b/src/app/server.ts index be0d8fe..37ed228 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -3,7 +3,7 @@ import * as ed from "@noble/ed25519"; import EnclaveWebSocket from "./ws"; import { sha512 } from "@noble/hashes/sha2.js"; import { getWSUrl } from "@/lib/serverList"; -import { ServerMeta, StoredMessage } from "@/lib/types"; +import { ClientMeta, ServerMeta, StoredMessage } from "@/lib/types"; ed.hashes.sha512 = sha512; @@ -30,11 +30,13 @@ export default class EnclaveServer { public websocket?: EnclaveWebSocket; public meta?: ServerMeta; public messages: Record>; + public users: Record; public constructor(hostname: string, isSecure: boolean) { this.hostname = hostname; this.isSecure = isSecure; this.messages = {}; + this.users = {}; } public disconnect() { @@ -94,4 +96,14 @@ export default class EnclaveServer { throw Error("Invalid signature"); } } + + public getUsers(pubkeys: string[]) { + const filteredPubkeys = pubkeys.filter((pubkey) => { + const exists = this.users[pubkey] !== undefined; + if (!exists) this.users[pubkey] = null; + return !exists; + }); + + this.websocket?.send({ method: "GetUsers", pubkeys: filteredPubkeys }); + } } diff --git a/src/components/page/channel/TextChannel.tsx b/src/components/page/channel/TextChannel.tsx index e613936..efb70cf 100644 --- a/src/components/page/channel/TextChannel.tsx +++ b/src/components/page/channel/TextChannel.tsx @@ -1,10 +1,20 @@ import Enclave from "@/app/app"; import { ChannelPageProps } from "@/components/page/PageView"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { StoredMessage } from "@/lib/types"; -import { useEffect, useState } from "react"; +import { Channel, StoredMessage } from "@/lib/types"; +import { base58 } from "@scure/base"; +import { useEffect, useRef, useState } from "react"; +import * as ed from "@noble/ed25519"; +import { CircleXIcon, PencilIcon, Trash2Icon } from "lucide-react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Textarea } from "@/components/ui/textarea"; export default function TextChannel({ appRef, @@ -14,38 +24,74 @@ export default function TextChannel({ const [currentMessage, setCurrentMessage] = useState(""); const channel = appRef.current?.page?.channel; + const intersectionRef = useRef(null); + const chunkRef = useRef(0); + + const account = appRef.current?.getAccount(); + + const secretKey = (account && base58.decode(account.privateKey)) || null; + + const userPubKey = secretKey && base58.encode(ed.getPublicKey(secretKey)); + useEffect(() => { - if (!channel) return; + if (!intersectionRef.current) return; - appRef.current?.server?.websocket?.send({ - method: "GetMessages", - channel_id: channel.id, - chunk: 0, - }); + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + console.log("Element is in view!"); + if (!channel) return; - appRef.current?.forceRender(); - }, [channel]); + appRef.current?.server?.websocket?.send({ + method: "GetMessages", + channel_id: channel.id, + chunk: chunkRef.current, + }); + + appRef.current?.forceRender(); + chunkRef.current += 1; + } + }, + { + threshold: 0.5, + }, + ); + + observer.observe(intersectionRef.current); + + return () => observer.disconnect(); + }, []); if (!channel) return null; + const messages = appRef.current?.server?.messages[channel.id]; + const sendMessage = () => { appRef.current?.sendMessage(currentMessage, channel.id); setCurrentMessage(""); }; return ( -

+

{channel.name}

-
- {appRef.current?.server?.messages[channel.id] && - Object.entries(appRef.current?.server?.messages[channel.id]).map( - ([_, message]) => ( - - ), - )} +
+ {messages && + Object.entries(messages) + .sort((a, b) => b[1].timestamp - a[1].timestamp) + .map(([_, message]) => ( + + ))} +
@@ -69,35 +115,188 @@ export default function TextChannel({ } export function TextMessage({ + appRef, message, + channel, + userPubKey, + secretKey, }: { appRef: React.RefObject | null>; message: StoredMessage; + channel: Channel; + userPubKey: string | null; + secretKey: Uint8Array | null; }) { const time = new Date(message.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit", }); - return ( + const author = appRef.current?.server?.users[message.author]; + + const [verified, setVerified] = useState(true); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(message.content); + + useEffect(() => { + const serverPubKey = + appRef.current?.server?.serverPublicKey && + base58.encode(appRef.current.server.serverPublicKey); + + if (!serverPubKey) return; + + const authorPubKey = base58.decode(message.author); + + setVerified( + ed.verify( + base58.decode(message.signature), + new TextEncoder().encode( + `${message.timestamp}@${serverPubKey}@${message.content}`, + ), + authorPubKey, + ), + ); + }, []); + + const isOwnMessage = message.author === userPubKey; + + function handleDelete() { + appRef.current?.server?.websocket?.send({ + method: "DeleteMessage", + message_id: message.id, + channel_id: channel.id, + }); + } + + function startEditing() { + setDraft(message.content); + setEditing(true); + } + + function cancelEditing() { + setDraft(message.content); + setEditing(false); + } + + function handleSave() { + const content = draft.trim(); + + if ( + !content || + content === message.content || + !secretKey || + !appRef.current?.server?.serverPublicKey + ) { + cancelEditing(); + return; + } + + const signature = new TextEncoder().encode( + `${message.timestamp}@${base58.encode(appRef.current.server?.serverPublicKey)}@${content}`, + ); + + appRef.current?.server?.websocket?.send({ + method: "EditMessage", + message_id: message.id, + channel_id: channel.id, + signature: base58.encode(ed.sign(signature, secretKey)), + content, + }); + + setEditing(false); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + cancelEditing(); + } + + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSave(); + } + } + + const content = (
+ - {message.author.slice(0, 2).toUpperCase()} + {author?.displayName.slice(0, 1).toUpperCase() || + message.author.slice(0, 2).toUpperCase()}
-
- - {message.author.slice(0, 8)} +
+ + {!verified && ( + + )} + {author?.displayName || message.author.slice(0, 8)} + {time} + + {message.is_edited && !editing && ( + (edited) + )}
-

- {message.content} -

+ + {editing ? ( +
+