Merge pull request #4 from recurse-chat/working-messages

Working messages
This commit is contained in:
2026-08-20 13:20:57 -04:00
committed by GitHub
19 changed files with 350 additions and 92 deletions
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"typecheck": "tsc --noEmit",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
+3 -2
View File
@@ -2,11 +2,12 @@ use serde::{Deserialize, Serialize};
use std::fs;
use tauri::{AppHandle, Manager};
use crate::types::ClientMeta;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Account {
pub display_name: String,
pub avatar: Option<String>,
pub meta: ClientMeta,
pub private_key: String,
}
+1 -1
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs};
use tauri::{AppHandle, Manager};
use crate::protocol::ServerMeta;
use crate::types::ServerMeta;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+1 -1
View File
@@ -1,5 +1,5 @@
pub mod commands;
pub mod protocol;
pub mod types;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
-46
View File
@@ -1,46 +0,0 @@
use std::borrow::Cow;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientMeta {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerMeta {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "method")]
pub enum ClientMethod {
Initialized {
public_key: String,
signature: String,
timestamp: u64,
hostname: String,
},
Error {
error: Cow<'static, str>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "method")]
pub enum ServerMethod {
Initialize {
public_key: String,
signature: String,
timestamp: u64,
hostname: String,
},
Meta(ClientMeta),
Error {
error: String,
},
}
+15
View File
@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientMeta {
pub display_name: String,
pub avatar: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerMeta {
pub name: String,
pub description: String,
}
+2 -1
View File
@@ -9,6 +9,7 @@ import {
} from "./components/ui/resizable";
import Sidebar from "./components/view/Sidebar";
import { ThemeProvider } from "next-themes";
import Page from "./components/page/PageView";
export default function App() {
const appRef = useRef<Enclave | null>(null);
@@ -50,7 +51,7 @@ export default function App() {
<ResizableHandle withHandle />
<ResizablePanel defaultSize="100%">
<div className="h-screen"></div>
<Page appRef={appRef} />
</ResizablePanel>
</ResizablePanelGroup>
</>
+86 -5
View File
@@ -6,6 +6,8 @@ import { base58 } from "@scure/base";
import axios from "axios";
import { Account, AccountsFile, getAccounts } from "@/lib/accounts";
import { Page } from "@/components/page/PageView";
import { ClientMethod } from "./protocol";
ed.hashes.sha512 = sha512;
@@ -22,11 +24,12 @@ ed.hashes.sha512 = sha512;
* should stay a pure display of what `Enclave` exposes — it should not
* reach into `EnclaveServer` or `EnclaveWebSocket` directly.
*/
export default class Enclave {
export default class Enclave<P = Page> {
public accounts?: AccountsFile;
public server?: EnclaveServer;
public serverList: ServerList;
public forceRender: () => void;
public page?: P;
public constructor() {
this.serverList = {};
@@ -42,6 +45,44 @@ export default class Enclave {
return this.accounts?.accounts[this.accounts.activeAccount] || null;
}
public getClientSecretKey() {
if (!this.accounts) return null;
const account = this.getAccount();
return account ? base58.decode(account.privateKey) : null;
}
public sendMessage(content: string, channelId: string) {
const clientSecretKey = this.getClientSecretKey();
if (!clientSecretKey) {
console.error("Acccount not found");
return;
}
const timestamp = Date.now();
if (!this.server?.serverPublicKey) {
console.error("Server pubkey not initialized");
return;
}
const signature = new TextEncoder().encode(
`${timestamp}@${base58.encode(this.server?.serverPublicKey)}@${content}`,
);
this.server?.websocket?.send({
method: "SendMessage",
channel_id: channelId,
data: {
content,
timestamp,
signature: base58.encode(ed.sign(signature, clientSecretKey)),
},
});
}
public async connectToServer(hostname: string, isSecure: boolean) {
if (this.server) {
this.server.disconnect();
@@ -77,6 +118,20 @@ export default class Enclave {
return;
}
const account = this.getAccount();
if (!account) {
console.error("Failed to get account");
return;
}
if (this.server.websocket) {
this.server.websocket.send({ method: "Meta", ...account.meta });
this.server.websocket.websocket.onmessage = (msg) => {
this.onMessage(JSON.parse(msg.data));
};
}
const metaResponse = await axios.get(
getHTTPUrl(hostname, isSecure, "/meta"),
);
@@ -92,11 +147,37 @@ export default class Enclave {
this.forceRender();
}
public getClientSecretKey() {
if (!this.accounts) return null;
public async onMessage(msg: ClientMethod) {
const server = this.server;
const account = this.getAccount();
if (!server) {
console.error("Unable to get server for message, MSG:", msg);
return;
}
return account ? base58.decode(account.privateKey) : null;
switch (msg.method) {
case "Initialized":
console.error("Already initialized");
return;
case "Error":
console.error("Server error:", msg.error);
return;
case "Messages":
Object.entries(msg.messages).forEach(([channelId, messages]) => {
if (!server.messages[channelId]) {
server.messages[channelId] = {};
}
messages.forEach((message) => {
server.messages[channelId][message.id] = message;
});
});
this.forceRender();
return;
}
}
}
+18 -15
View File
@@ -1,10 +1,4 @@
export interface ClientMeta {}
export interface ServerMeta {
name: string;
description: string;
channels: Channel[];
}
import { ClientMeta, MessageData, StoredMessage } from "@/lib/types";
export type ClientMethod =
| {
@@ -17,6 +11,10 @@ export type ClientMethod =
| {
method: "Error";
error: string;
}
| {
method: "Messages";
messages: Record<string, StoredMessage[]>;
};
export type ServerMethod =
@@ -27,15 +25,20 @@ export type ServerMethod =
timestamp: number;
hostname: string;
}
| {
method: "Meta";
}
| {
method: "Error";
error: string;
}
| ({
method: "Meta";
} & ClientMeta)
| {
method: "SendMessage";
channel_id: string;
data: MessageData;
}
| {
method: "GetMessages";
channel_id: string;
chunk: number;
};
export type Channel = { id: string; name: string } & ChannelKind;
export type ChannelKind =
{ kind: "text" } | { kind: "category"; channels: Channel[] };
+3 -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 } from "./protocol";
import { ServerMeta, StoredMessage } from "@/lib/types";
ed.hashes.sha512 = sha512;
@@ -29,10 +29,12 @@ export default class EnclaveServer {
public isSecure: boolean;
public websocket?: EnclaveWebSocket;
public meta?: ServerMeta;
public messages: Record<string, Record<string, StoredMessage>>;
public constructor(hostname: string, isSecure: boolean) {
this.hostname = hostname;
this.isSecure = isSecure;
this.messages = {};
}
public disconnect() {
+23
View File
@@ -0,0 +1,23 @@
import Enclave from "@/app/app";
import ChannelPage from "./channel/ChannelPage";
import { Channel } from "@/lib/types";
export type ChannelPageProps = {
kind: "channel";
channel: Channel;
};
export type Page = ChannelPageProps;
export default function Page({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
}) {
if (!appRef.current?.page?.kind) return null;
switch (appRef.current?.page?.kind) {
case "channel":
return <ChannelPage appRef={appRef} />;
}
}
@@ -0,0 +1,16 @@
import Enclave from "@/app/app";
import { ChannelPageProps } from "@/components/page/PageView";
import TextChannel from "./TextChannel";
export default function ChannelPage({
appRef,
}: {
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
}) {
if (!appRef.current?.page?.channel.kind) return null;
switch (appRef.current?.page?.channel.kind) {
case "text":
return <TextChannel appRef={appRef} />;
}
}
+103
View File
@@ -0,0 +1,103 @@
import Enclave from "@/app/app";
import { ChannelPageProps } from "@/components/page/PageView";
import { Avatar, AvatarFallback } 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";
export default function TextChannel({
appRef,
}: {
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
}) {
const [currentMessage, setCurrentMessage] = useState("");
const channel = appRef.current?.page?.channel;
useEffect(() => {
if (!channel) return;
appRef.current?.server?.websocket?.send({
method: "GetMessages",
channel_id: channel.id,
chunk: 0,
});
appRef.current?.forceRender();
}, [channel]);
if (!channel) return null;
const sendMessage = () => {
appRef.current?.sendMessage(currentMessage, channel.id);
setCurrentMessage("");
};
return (
<div className="flex flex-col h-full">
<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>
<div className="pb-6 px-4 flex flex-row gap-3">
<Input
className="h-10"
placeholder={`Message ${channel.name}`}
value={currentMessage}
onChange={(e) => setCurrentMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
/>
{currentMessage.trim() && <Button onClick={sendMessage}>Send</Button>}
</div>
</div>
);
}
export function TextMessage({
message,
}: {
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
message: StoredMessage;
}) {
const time = new Date(message.timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
return (
<div className="rounded-lg flex gap-3 px-3 py-3 hover:bg-muted/40">
<Avatar className="h-10 w-10">
<AvatarFallback>
{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)}
</span>
<span className="text-xs text-muted-foreground">{time}</span>
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-snug">
{message.content}
</p>
</div>
</div>
);
}
+3 -3
View File
@@ -20,8 +20,8 @@ export default function AccountCard({
<Card className="px-3 py-3 h-full w-full flex flex-row">
<div className="flex gap-2.5 items-center w-full">
<Avatar className="h-full w-auto aspect-square">
<AvatarImage src={account.avatar} />
<AvatarFallback>{account.displayName[0]}</AvatarFallback>
<AvatarImage src={account.meta.avatar} />
<AvatarFallback>{account.meta.displayName[0]}</AvatarFallback>
</Avatar>
<div className="flex flex-col w-full">
<span
@@ -34,7 +34,7 @@ export default function AccountCard({
);
}}
>
<p>{account.displayName}</p>
<p>{account.meta.displayName}</p>
<CopyIcon className="text-muted-foreground size-3" />
</span>
<span className="text-muted-foreground">Online</span>
+7 -7
View File
@@ -68,16 +68,16 @@ export function NewProfilePage({
const { secretKey } = ed.keygen();
const newAccount = {
displayName: name,
privateKey: base58.encode(secretKey),
avatar: avatar ?? undefined,
};
appRef.current.accounts.activeAccount =
appRef.current.accounts.accounts.length;
appRef.current.accounts.accounts.push(newAccount);
appRef.current.accounts.accounts.push({
meta: {
displayName: name,
avatar: avatar ?? undefined,
},
privateKey: base58.encode(secretKey),
});
saveAccounts(appRef.current.accounts);
} catch (e) {
+39 -7
View File
@@ -1,8 +1,9 @@
import Enclave from "@/app/app";
import { Channel, ChannelKind } from "@/app/protocol";
import { Channel, ChannelKind } from "@/lib/types";
import { ChevronDown, ChevronUp, HashIcon } from "lucide-react";
import { useState } from "react";
import AccountCard from "./AccountCard";
import { cn } from "@/lib/utils";
export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) {
switch (kind) {
@@ -13,7 +14,13 @@ export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) {
}
}
export function RenderCategory({ channel }: { channel: Channel }) {
export function RenderCategory({
appRef,
channel,
}: {
appRef: React.RefObject<Enclave | null>;
channel: Channel;
}) {
if (channel.kind !== "category") return null;
const [open, setOpen] = useState(true);
@@ -33,19 +40,41 @@ export function RenderCategory({ channel }: { channel: Channel }) {
</div>
{open && (
<div className="pt-0.5 w-full flex flex-col gap-1">
<RenderChannels channels={channel.channels} />
<RenderChannels appRef={appRef} channels={channel.channels} />
</div>
)}
</div>
);
}
export function RenderChannels({ channels }: { channels: Channel[] }) {
export function RenderChannels({
appRef,
channels,
}: {
appRef: React.RefObject<Enclave | null>;
channels: Channel[];
}) {
return channels.map((channel) =>
channel.kind === "category" ? (
<RenderCategory key={channel.id} channel={channel} />
<RenderCategory key={channel.id} appRef={appRef} channel={channel} />
) : (
<div key={channel.id} className="w-full hover:bg-accent px-2.5 py-1.5 rounded-md flex gap-2.5 items-center select-none cursor-default text-sm text-muted-foreground">
<div
key={channel.id}
className={cn(
"w-full hover:bg-accent px-2.5 py-1.5 rounded-md flex gap-2.5 items-center select-none cursor-default text-sm text-muted-foreground",
channel.id === appRef.current?.page?.channel.id,
)}
onClick={() => {
if (!appRef.current) {
console.error("AppRef is not initialized yet");
return;
}
appRef.current.page = { kind: "channel", channel };
appRef.current.forceRender();
}}
>
<ChannelIcon kind={channel.kind} />
{channel.name}
</div>
@@ -67,7 +96,10 @@ export default function Sidebar({
</header>
<section className="px-1.5 pt-3.5 w-full flex flex-col gap-1">
<RenderChannels channels={appRef.current.server.meta.channels} />
<RenderChannels
appRef={appRef}
channels={appRef.current.server.meta.channels}
/>
</section>
</>
)}
+2 -2
View File
@@ -1,8 +1,8 @@
import { invoke } from "@tauri-apps/api/core";
import { ClientMeta } from "./types";
export interface Account {
displayName: string;
avatar?: string;
meta: ClientMeta;
privateKey: string;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { ServerMeta } from "@/app/protocol";
import { invoke } from "@tauri-apps/api/core";
import { ServerMeta } from "./types";
export interface KnownServer {
meta: ServerMeta;
+26
View File
@@ -0,0 +1,26 @@
export interface ClientMeta {
displayName: string;
avatar?: string;
}
export interface ServerMeta {
name: string;
description: string;
channels: Channel[];
}
export interface MessageData {
content: string;
timestamp: number;
signature: string;
}
export interface StoredMessage extends MessageData {
id: string;
author: string;
}
export type Channel = { id: string; name: string } & ChannelKind;
export type ChannelKind =
{ kind: "text" } | { kind: "category"; channels: Channel[] };