Merge pull request #4 from recurse-chat/working-messages
Working messages
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
pub mod commands;
|
||||
pub mod protocol;
|
||||
pub mod types;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,5 +1,5 @@
|
||||
import { ServerMeta } from "@/app/protocol";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ServerMeta } from "./types";
|
||||
|
||||
export interface KnownServer {
|
||||
meta: ServerMeta;
|
||||
|
||||
@@ -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[] };
|
||||
Reference in New Issue
Block a user