Merge pull request #5 from recurse-chat/finalized-messages-mvp

Finalized messages mvp
This commit is contained in:
2026-08-22 07:34:03 -04:00
committed by GitHub
8 changed files with 596 additions and 40 deletions
+33 -7
View File
@@ -165,19 +165,45 @@ export default class Enclave<P = Page> {
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;
}
}
}
+30
View File
@@ -15,6 +15,20 @@ export type ClientMethod =
| {
method: "Messages";
messages: Record<string, StoredMessage[]>;
}
| {
method: "Users";
users: Record<string, ClientMeta>;
}
| {
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;
};
+13 -1
View File
@@ -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<string, Record<string, StoredMessage>>;
public users: Record<string, ClientMeta | null>;
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 });
}
}
+226 -27
View File
@@ -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<HTMLDivElement | null>(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 (
<div className="flex flex-col h-full">
<div className="flex flex-col h-screen gap-2">
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border">
<h2>{channel.name}</h2>
</header>
<div className="h-full flex flex-col gap-2.5 px-3 pt-4 overflow-y-scroll">
{appRef.current?.server?.messages[channel.id] &&
Object.entries(appRef.current?.server?.messages[channel.id]).map(
([_, message]) => (
<TextMessage key={message.id} appRef={appRef} message={message} />
),
)}
<div className="h-full flex flex-col-reverse gap-2.5 px-3 pt-4 overflow-y-scroll scrollbar-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{messages &&
Object.entries(messages)
.sort((a, b) => b[1].timestamp - a[1].timestamp)
.map(([_, message]) => (
<TextMessage
key={message.id}
appRef={appRef}
message={message}
channel={channel}
userPubKey={userPubKey}
secretKey={secretKey}
/>
))}
<div ref={intersectionRef} />
</div>
<div className="pb-6 px-4 flex flex-row gap-3">
@@ -69,35 +115,188 @@ export default function TextChannel({
}
export function TextMessage({
appRef,
message,
channel,
userPubKey,
secretKey,
}: {
appRef: React.RefObject<Enclave<ChannelPageProps> | 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<HTMLTextAreaElement>) {
if (e.key === "Escape") {
e.preventDefault();
cancelEditing();
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSave();
}
}
const content = (
<div className="rounded-lg flex gap-3 px-3 py-3 hover:bg-muted/40">
<Avatar className="h-10 w-10">
<AvatarImage src={author?.avatar} />
<AvatarFallback>
{message.author.slice(0, 2).toUpperCase()}
{author?.displayName.slice(0, 1).toUpperCase() ||
message.author.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium">
{message.author.slice(0, 8)}
<div className="flex items-center gap-2">
<span className="text-sm font-medium flex items-center gap-1">
{!verified && (
<CircleXIcon className="h-3.5 w-3.5 text-destructive" />
)}
{author?.displayName || message.author.slice(0, 8)}
</span>
<span className="text-xs text-muted-foreground">{time}</span>
{message.is_edited && !editing && (
<span className="text-xs text-muted-foreground">(edited)</span>
)}
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-snug">
{message.content}
</p>
{editing ? (
<div className="mt-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
className="min-h-20 resize-none"
/>
<div className="mt-2 flex items-center gap-2">
<Button size="sm" onClick={handleSave}>
Save
</Button>
<Button size="sm" variant="ghost" onClick={cancelEditing}>
Cancel
</Button>
<span className="text-xs text-muted-foreground">
Enter to save · Shift + Enter for new line · Esc to cancel
</span>
</div>
</div>
) : (
<p className="whitespace-pre-wrap break-words text-sm leading-snug">
{message.content}
</p>
)}
</div>
</div>
);
// Don't allow the context menu while editing
if (!isOwnMessage || editing) {
return content;
}
return (
<ContextMenu>
<ContextMenuTrigger>{content}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={startEditing}>
<PencilIcon className="mr-2 h-4 w-4" />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={handleDelete} variant="destructive">
<Trash2Icon className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
+269
View File
@@ -0,0 +1,269 @@
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuTrigger({
className,
...props
}: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuContent({
className,
align = "start",
alignOffset = 4,
side = "right",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuLabel({
className,
inset,
...props
}: ContextMenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubmenuTrigger>
)
}
function ContextMenuSubContent({
...props
}: React.ComponentProps<typeof ContextMenuContent>) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
className="shadow-lg"
side="right"
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioGroup({
...props
}: ContextMenuPrimitive.RadioGroup.Props) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuSeparator({
className,
...props
}: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+6 -5
View File
@@ -1,6 +1,7 @@
import Enclave from "@/app/app";
import { getHTTPUrl, saveServerList } from "@/lib/serverList";
import { AddServerDialog } from "../dialog/AddServerDialog";
import { cn } from "@/lib/utils";
export default function ServerList({
appRef,
@@ -16,11 +17,11 @@ export default function ServerList({
<img
src={getHTTPUrl(hostname, server.isSecure, "/icon")}
key={hostname}
className={
hostname === appRef.current?.server?.hostname
? "rounded-lg"
: "rounded-full"
}
className={cn(
"rounded-lg",
hostname === appRef.current?.server?.hostname &&
"border-primary border border-2",
)}
onClick={async () => {
if (!appRef.current) {
+1
View File
@@ -18,6 +18,7 @@ export interface MessageData {
export interface StoredMessage extends MessageData {
id: string;
author: string;
is_edited: boolean;
}
export type Channel = { id: string; name: string } & ChannelKind;