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 (
-
+
-
- {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 ? (
+
+ ) : (
+
+ {message.content}
+
+ )}
);
+
+ // Don't allow the context menu while editing
+ if (!isOwnMessage || editing) {
+ return content;
+ }
+
+ return (
+
+ {content}
+
+
+
+
+ Edit
+
+
+
+
+ Delete
+
+
+
+ );
}
diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx
new file mode 100644
index 0000000..86840fa
--- /dev/null
+++ b/src/components/ui/context-menu.tsx
@@ -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
+}
+
+function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuTrigger({
+ className,
+ ...props
+}: ContextMenuPrimitive.Trigger.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuContent({
+ className,
+ align = "start",
+ alignOffset = 4,
+ side = "right",
+ sideOffset = 0,
+ ...props
+}: ContextMenuPrimitive.Popup.Props &
+ Pick<
+ ContextMenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuLabel({
+ className,
+ inset,
+ ...props
+}: ContextMenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function ContextMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: ContextMenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: ContextMenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function ContextMenuSubContent({
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+ )
+}
+
+function ContextMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: ContextMenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function ContextMenuRadioGroup({
+ ...props
+}: ContextMenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: ContextMenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function ContextMenuSeparator({
+ className,
+ ...props
+}: ContextMenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuCheckboxItem,
+ ContextMenuRadioItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuGroup,
+ ContextMenuPortal,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuRadioGroup,
+}
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/components/view/ServerList.tsx b/src/components/view/ServerList.tsx
index ae84df4..2e1da81 100644
--- a/src/components/view/ServerList.tsx
+++ b/src/components/view/ServerList.tsx
@@ -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({
{
if (!appRef.current) {
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 1c77ea6..647e08e 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -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;