diff --git a/package.json b/package.json index e29bc23..0eb2196 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "typecheck": "tsc --noEmit", "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri" diff --git a/src-tauri/src/commands/accounts.rs b/src-tauri/src/commands/accounts.rs index a6862e8..fa8461d 100644 --- a/src-tauri/src/commands/accounts.rs +++ b/src-tauri/src/commands/accounts.rs @@ -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, + pub meta: ClientMeta, pub private_key: String, } diff --git a/src-tauri/src/commands/server_list.rs b/src-tauri/src/commands/server_list.rs index e66e0ab..b084088 100644 --- a/src-tauri/src/commands/server_list.rs +++ b/src-tauri/src/commands/server_list.rs @@ -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")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 51cbccc..6a7af70 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,5 @@ pub mod commands; -pub mod protocol; +pub mod types; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs deleted file mode 100644 index 3518cf2..0000000 --- a/src-tauri/src/protocol.rs +++ /dev/null @@ -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, - }, -} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs new file mode 100644 index 0000000..46497db --- /dev/null +++ b/src-tauri/src/types.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerMeta { + pub name: String, + pub description: String, +} diff --git a/src/App.tsx b/src/App.tsx index 3f7d97e..45fbf04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); @@ -50,7 +51,7 @@ export default function App() { -
+
diff --git a/src/app/app.ts b/src/app/app.ts index 98eb5b2..b79a940 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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

{ 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; + } } } diff --git a/src/app/protocol.ts b/src/app/protocol.ts index 5d51ed8..ed48003 100644 --- a/src/app/protocol.ts +++ b/src/app/protocol.ts @@ -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; }; 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[] }; diff --git a/src/app/server.ts b/src/app/server.ts index 8410579..be0d8fe 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 } 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>; public constructor(hostname: string, isSecure: boolean) { this.hostname = hostname; this.isSecure = isSecure; + this.messages = {}; } public disconnect() { diff --git a/src/components/page/PageView.tsx b/src/components/page/PageView.tsx new file mode 100644 index 0000000..7ba5401 --- /dev/null +++ b/src/components/page/PageView.tsx @@ -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; +}) { + if (!appRef.current?.page?.kind) return null; + + switch (appRef.current?.page?.kind) { + case "channel": + return ; + } +} diff --git a/src/components/page/channel/ChannelPage.tsx b/src/components/page/channel/ChannelPage.tsx new file mode 100644 index 0000000..f9b811b --- /dev/null +++ b/src/components/page/channel/ChannelPage.tsx @@ -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 | null>; +}) { + if (!appRef.current?.page?.channel.kind) return null; + + switch (appRef.current?.page?.channel.kind) { + case "text": + return ; + } +} diff --git a/src/components/page/channel/TextChannel.tsx b/src/components/page/channel/TextChannel.tsx new file mode 100644 index 0000000..e613936 --- /dev/null +++ b/src/components/page/channel/TextChannel.tsx @@ -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 | 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 ( +

+
+

{channel.name}

+
+ +
+ {appRef.current?.server?.messages[channel.id] && + Object.entries(appRef.current?.server?.messages[channel.id]).map( + ([_, message]) => ( + + ), + )} +
+ +
+ setCurrentMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }} + /> + + {currentMessage.trim() && } +
+
+ ); +} + +export function TextMessage({ + message, +}: { + appRef: React.RefObject | null>; + message: StoredMessage; +}) { + const time = new Date(message.timestamp).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }); + + return ( +
+ + + {message.author.slice(0, 2).toUpperCase()} + + + +
+
+ + {message.author.slice(0, 8)} + + {time} +
+

+ {message.content} +

+
+
+ ); +} diff --git a/src/components/view/AccountCard.tsx b/src/components/view/AccountCard.tsx index 2961dd7..67b8a9a 100644 --- a/src/components/view/AccountCard.tsx +++ b/src/components/view/AccountCard.tsx @@ -20,8 +20,8 @@ export default function AccountCard({
- - {account.displayName[0]} + + {account.meta.displayName[0]}
-

{account.displayName}

+

{account.meta.displayName}

Online diff --git a/src/components/view/NewProfileView.tsx b/src/components/view/NewProfileView.tsx index a5c06d8..4b3c07a 100644 --- a/src/components/view/NewProfileView.tsx +++ b/src/components/view/NewProfileView.tsx @@ -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) { diff --git a/src/components/view/Sidebar.tsx b/src/components/view/Sidebar.tsx index 5c959f5..d786dc2 100644 --- a/src/components/view/Sidebar.tsx +++ b/src/components/view/Sidebar.tsx @@ -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; + channel: Channel; +}) { if (channel.kind !== "category") return null; const [open, setOpen] = useState(true); @@ -33,19 +40,41 @@ export function RenderCategory({ channel }: { channel: Channel }) {
{open && (
- +
)}
); } -export function RenderChannels({ channels }: { channels: Channel[] }) { +export function RenderChannels({ + appRef, + channels, +}: { + appRef: React.RefObject; + channels: Channel[]; +}) { return channels.map((channel) => channel.kind === "category" ? ( - + ) : ( -
+
{ + if (!appRef.current) { + console.error("AppRef is not initialized yet"); + return; + } + + appRef.current.page = { kind: "channel", channel }; + + appRef.current.forceRender(); + }} + > {channel.name}
@@ -67,7 +96,10 @@ export default function Sidebar({
- +
)} diff --git a/src/lib/accounts.ts b/src/lib/accounts.ts index f9ff4ac..6278ec4 100644 --- a/src/lib/accounts.ts +++ b/src/lib/accounts.ts @@ -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; } diff --git a/src/lib/serverList.ts b/src/lib/serverList.ts index 18e1f81..0517bc6 100644 --- a/src/lib/serverList.ts +++ b/src/lib/serverList.ts @@ -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; diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..1c77ea6 --- /dev/null +++ b/src/lib/types.ts @@ -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[] };