mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): connect chat to relay (#6)
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
18
desktop/pnpm-lock.yaml
generated
18
desktop/pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.21
|
||||
version: 5.90.21(react@19.2.4)
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2
|
||||
version: 2.10.1
|
||||
@@ -900,6 +903,14 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tanstack/query-core@5.90.20':
|
||||
resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==}
|
||||
|
||||
'@tanstack/react-query@5.90.21':
|
||||
resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tauri-apps/api@2.10.1':
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
|
||||
@@ -2184,6 +2195,13 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@tanstack/query-core@5.90.20': {}
|
||||
|
||||
'@tanstack/react-query@5.90.21(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.90.20
|
||||
react: 19.2.4
|
||||
|
||||
'@tauri-apps/api@2.10.1': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
||||
|
||||
865
desktop/src-tauri/Cargo.lock
generated
865
desktop/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -20,5 +20,8 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
nostr = "0.37"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "opener:default"]
|
||||
"permissions": ["core:default", "opener:default", "websocket:default"]
|
||||
}
|
||||
|
||||
@@ -1,14 +1,162 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use std::sync::Mutex;
|
||||
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, ToBech32};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct AppState {
|
||||
pub keys: Mutex<Keys>,
|
||||
pub http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IdentityInfo {
|
||||
pub pubkey: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ChannelInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub channel_type: String,
|
||||
pub description: String,
|
||||
pub participants: Vec<String>,
|
||||
pub participant_pubkeys: Vec<String>,
|
||||
}
|
||||
|
||||
fn relay_ws_url() -> String {
|
||||
std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn relay_api_base_url() -> String {
|
||||
if let Ok(base) = std::env::var("SPROUT_RELAY_HTTP") {
|
||||
return base;
|
||||
}
|
||||
|
||||
relay_ws_url()
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
}
|
||||
|
||||
async fn build_authed_request(
|
||||
client: &reqwest::Client,
|
||||
path: &str,
|
||||
state: &AppState,
|
||||
) -> Result<reqwest::RequestBuilder, String> {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
let url = format!("{}{}", relay_api_base_url(), path);
|
||||
|
||||
Ok(client.get(url).header("X-Pubkey", pubkey_hex))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
fn get_identity(state: tauri::State<'_, AppState>) -> Result<IdentityInfo, String> {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
let pubkey = keys.public_key();
|
||||
let pubkey_hex = pubkey.to_hex();
|
||||
let bech32 = pubkey
|
||||
.to_bech32()
|
||||
.map_err(|e| format!("bech32 encode failed: {e}"))?;
|
||||
let display_name = if bech32.len() > 16 {
|
||||
format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..])
|
||||
} else {
|
||||
bech32
|
||||
};
|
||||
|
||||
Ok(IdentityInfo {
|
||||
pubkey: pubkey_hex,
|
||||
display_name,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_relay_ws_url() -> String {
|
||||
relay_ws_url()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn sign_event(
|
||||
kind: u16,
|
||||
content: String,
|
||||
tags: Vec<Vec<String>>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let nostr_tags = tags
|
||||
.into_iter()
|
||||
.map(|tag| Tag::parse(tag).map_err(|e| format!("invalid tag: {e}")))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(kind), content)
|
||||
.tags(nostr_tags)
|
||||
.sign_with_keys(&keys)
|
||||
.map_err(|e| format!("sign failed: {e}"))?;
|
||||
|
||||
Ok(event.as_json())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_auth_event(
|
||||
challenge: String,
|
||||
relay_url: String,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let tags = vec![
|
||||
Tag::parse(vec!["relay", &relay_url]).map_err(|e| format!("relay tag failed: {e}"))?,
|
||||
Tag::parse(vec!["challenge", &challenge])
|
||||
.map_err(|e| format!("challenge tag failed: {e}"))?,
|
||||
];
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(22242), "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(&keys)
|
||||
.map_err(|e| format!("sign failed: {e}"))?;
|
||||
|
||||
Ok(event.as_json())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_channels(state: tauri::State<'_, AppState>) -> Result<Vec<ChannelInfo>, String> {
|
||||
let request = build_authed_request(&state.http_client, "/api/channels", &state).await?;
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("relay returned {status}: {body}"));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<Vec<ChannelInfo>>()
|
||||
.await
|
||||
.map_err(|e| format!("parse failed: {e}"))
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let app_state = AppState {
|
||||
keys: Mutex::new(Keys::generate()),
|
||||
http_client: reqwest::Client::new(),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_identity,
|
||||
get_relay_ws_url,
|
||||
sign_event,
|
||||
create_auth_event,
|
||||
get_channels,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -1,24 +1,115 @@
|
||||
import { currentChannel, messages } from "@/features/chat/data/chatData";
|
||||
import * as React from "react";
|
||||
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import {
|
||||
useChannelsQuery,
|
||||
useSelectedChannel,
|
||||
} from "@/features/channels/hooks";
|
||||
import { MessageComposer } from "@/features/chat/ui/MessageComposer";
|
||||
import { MessageTimeline } from "@/features/chat/ui/MessageTimeline";
|
||||
import {
|
||||
useChannelMessagesQuery,
|
||||
useChannelSubscription,
|
||||
useSendMessageMutation,
|
||||
} from "@/features/messages/hooks";
|
||||
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
|
||||
|
||||
export function AppShell() {
|
||||
const identityQuery = useIdentityQuery();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channels = channelsQuery.data ?? [];
|
||||
const { selectedChannel, setSelectedChannelId } = useSelectedChannel(
|
||||
channels,
|
||||
null,
|
||||
);
|
||||
|
||||
const messagesQuery = useChannelMessagesQuery(selectedChannel);
|
||||
useChannelSubscription(selectedChannel);
|
||||
|
||||
const sendMessageMutation = useSendMessageMutation(
|
||||
selectedChannel,
|
||||
identityQuery.data,
|
||||
);
|
||||
|
||||
const timelineMessages = React.useMemo(
|
||||
() =>
|
||||
formatTimelineMessages(
|
||||
messagesQuery.data ?? [],
|
||||
selectedChannel,
|
||||
identityQuery.data?.pubkey,
|
||||
),
|
||||
[identityQuery.data?.pubkey, messagesQuery.data, selectedChannel],
|
||||
);
|
||||
|
||||
const channelDescription = selectedChannel
|
||||
? selectedChannel.channelType === "forum"
|
||||
? `${selectedChannel.description} Forum channels are listed, but this first pass only wires message streams and DMs.`
|
||||
: selectedChannel.description
|
||||
: "Connect to the relay to browse channels and read messages.";
|
||||
|
||||
return (
|
||||
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
|
||||
<AppSidebar />
|
||||
<AppSidebar
|
||||
channels={channels}
|
||||
errorMessage={
|
||||
channelsQuery.error instanceof Error
|
||||
? channelsQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
onSelectChannel={(channelId) => {
|
||||
React.startTransition(() => setSelectedChannelId(channelId));
|
||||
}}
|
||||
selectedChannelId={selectedChannel?.id ?? null}
|
||||
/>
|
||||
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<ChatHeader
|
||||
description={currentChannel.description}
|
||||
title={currentChannel.name}
|
||||
channelType={selectedChannel?.channelType}
|
||||
description={channelDescription}
|
||||
title={selectedChannel?.name ?? "Channels"}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<MessageTimeline messages={messages} />
|
||||
<MessageComposer channelName={currentChannel.name} />
|
||||
<MessageTimeline
|
||||
emptyDescription={
|
||||
selectedChannel?.channelType === "forum"
|
||||
? "Select a stream or DM to load real message history in this first integration pass."
|
||||
: "Messages will appear here once the relay has history for this channel."
|
||||
}
|
||||
emptyTitle={
|
||||
selectedChannel
|
||||
? selectedChannel.channelType === "forum"
|
||||
? "Forum channels are next"
|
||||
: "No messages yet"
|
||||
: "No channel selected"
|
||||
}
|
||||
isLoading={messagesQuery.isLoading}
|
||||
messages={timelineMessages}
|
||||
/>
|
||||
<MessageComposer
|
||||
channelName={selectedChannel?.name ?? "channel"}
|
||||
disabled={
|
||||
!selectedChannel ||
|
||||
selectedChannel.channelType === "forum" ||
|
||||
sendMessageMutation.isPending
|
||||
}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
key={selectedChannel?.id ?? "no-channel"}
|
||||
onSend={async (content) => {
|
||||
await sendMessageMutation.mutateAsync(content);
|
||||
}}
|
||||
placeholder={
|
||||
selectedChannel?.channelType === "forum"
|
||||
? "Forum posting is not wired in this pass."
|
||||
: selectedChannel
|
||||
? `Message #${selectedChannel.name}`
|
||||
: "Select a channel"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
56
desktop/src/features/channels/hooks.ts
Normal file
56
desktop/src/features/channels/hooks.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getChannels } from "@/shared/api/tauri";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
|
||||
export function useChannelsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["channels"],
|
||||
queryFn: getChannels,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSelectedChannel(
|
||||
channels: Channel[],
|
||||
preferredChannelId: string | null,
|
||||
) {
|
||||
const [selectedChannelId, setSelectedChannelId] = React.useState<
|
||||
string | null
|
||||
>(preferredChannelId);
|
||||
|
||||
const selectedChannel = React.useMemo(
|
||||
() =>
|
||||
channels.find((channel) => channel.id === selectedChannelId) ??
|
||||
channels.find((channel) => channel.channelType !== "forum") ??
|
||||
channels[0] ??
|
||||
null,
|
||||
[channels, selectedChannelId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedChannel && channels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedChannelId && selectedChannel) {
|
||||
setSelectedChannelId(selectedChannel.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedChannelId &&
|
||||
!channels.some((channel) => channel.id === selectedChannelId) &&
|
||||
selectedChannel
|
||||
) {
|
||||
setSelectedChannelId(selectedChannel.id);
|
||||
}
|
||||
}, [channels, selectedChannel, selectedChannelId]);
|
||||
|
||||
return {
|
||||
selectedChannel,
|
||||
selectedChannelId,
|
||||
setSelectedChannelId,
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,38 @@
|
||||
import { Hash } from "lucide-react";
|
||||
import { CircleDot, FileText, Hash } from "lucide-react";
|
||||
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { SidebarTrigger } from "@/shared/ui/sidebar";
|
||||
|
||||
type ChatHeaderProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
channelType?: ChannelType;
|
||||
};
|
||||
|
||||
export function ChatHeader({ title, description }: ChatHeaderProps) {
|
||||
function ChannelIcon({ channelType }: { channelType?: ChannelType }) {
|
||||
if (channelType === "dm") {
|
||||
return <CircleDot className="h-5 w-5 text-primary" />;
|
||||
}
|
||||
|
||||
if (channelType === "forum") {
|
||||
return <FileText className="h-5 w-5 text-primary" />;
|
||||
}
|
||||
|
||||
return <Hash className="h-5 w-5 text-primary" />;
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
title,
|
||||
description,
|
||||
channelType,
|
||||
}: ChatHeaderProps) {
|
||||
return (
|
||||
<header className="flex min-w-0 items-center gap-3 border-b border-border/80 bg-background px-4 py-3 sm:px-6">
|
||||
<SidebarTrigger />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Hash className="h-5 w-5 text-primary" />
|
||||
<ChannelIcon channelType={channelType} />
|
||||
<h1 className="truncate text-lg font-semibold tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
@@ -1,39 +1,84 @@
|
||||
import { Paperclip, SendHorizontal, SmilePlus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
type MessageComposerProps = {
|
||||
channelName: string;
|
||||
disabled?: boolean;
|
||||
isSending?: boolean;
|
||||
onSend: (content: string) => Promise<void>;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function MessageComposer({ channelName }: MessageComposerProps) {
|
||||
export function MessageComposer({
|
||||
channelName,
|
||||
disabled = false,
|
||||
isSending = false,
|
||||
onSend,
|
||||
placeholder,
|
||||
}: MessageComposerProps) {
|
||||
const [content, setContent] = React.useState("");
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || disabled || isSending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setContent("");
|
||||
|
||||
try {
|
||||
await onSend(trimmed);
|
||||
} catch {
|
||||
setContent(trimmed);
|
||||
}
|
||||
},
|
||||
[content, disabled, isSending, onSend],
|
||||
);
|
||||
|
||||
return (
|
||||
<footer className="border-t border-border/80 bg-background p-4">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
|
||||
<div className="rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4">
|
||||
<form
|
||||
className="rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
|
||||
onSubmit={(event) => {
|
||||
void handleSubmit(event);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
aria-label="Message channel"
|
||||
className="h-auto border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0"
|
||||
placeholder={`Message #${channelName}`}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder={placeholder ?? `Message #${channelName}`}
|
||||
value={content}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" type="button" variant="ghost">
|
||||
<Button disabled size="icon" type="button" variant="ghost">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" type="button" variant="ghost">
|
||||
<Button disabled size="icon" type="button" variant="ghost">
|
||||
<SmilePlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button className="gap-2" type="button">
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={disabled || isSending || content.trim().length === 0}
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
Send
|
||||
{isSending ? "Sending" : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { Message } from "@/features/chat/data/chatData";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
type MessageTimelineProps = {
|
||||
messages: Message[];
|
||||
messages: TimelineMessage[];
|
||||
isLoading?: boolean;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
};
|
||||
|
||||
function MessageRow({ message }: { message: Message }) {
|
||||
function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
const initials = message.author
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
@@ -34,6 +38,11 @@ function MessageRow({ message }: { message: Message }) {
|
||||
{message.role}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{message.time}</p>
|
||||
{message.pending ? (
|
||||
<p className="text-xs font-medium uppercase tracking-[0.2em] text-primary/80">
|
||||
Sending
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="max-w-3xl break-words text-sm leading-7 text-foreground/90">
|
||||
{message.body}
|
||||
@@ -43,7 +52,31 @@ function MessageRow({ message }: { message: Message }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageTimeline({ messages }: MessageTimelineProps) {
|
||||
function TimelineSkeleton() {
|
||||
const skeletonRows = ["first", "second", "third", "fourth"];
|
||||
|
||||
return (
|
||||
<>
|
||||
{skeletonRows.map((row) => (
|
||||
<div className="flex gap-4" key={row}>
|
||||
<Skeleton className="h-11 w-11 rounded-2xl" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-full max-w-2xl" />
|
||||
<Skeleton className="h-4 w-full max-w-xl" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageTimeline({
|
||||
messages,
|
||||
isLoading = false,
|
||||
emptyTitle = "No messages yet",
|
||||
emptyDescription = "Send the first message to start the thread.",
|
||||
}: MessageTimelineProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-6 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
@@ -55,12 +88,24 @@ export function MessageTimeline({ messages }: MessageTimelineProps) {
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
{messages.map((message) => (
|
||||
<MessageRow
|
||||
key={`${message.author}-${message.time}`}
|
||||
message={message}
|
||||
/>
|
||||
))}
|
||||
{isLoading ? <TimelineSkeleton /> : null}
|
||||
|
||||
{!isLoading && messages.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-sm">
|
||||
<p className="text-base font-semibold tracking-tight">
|
||||
{emptyTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading
|
||||
? messages.map((message) => (
|
||||
<MessageRow key={message.id} message={message} />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
179
desktop/src/features/messages/hooks.ts
Normal file
179
desktop/src/features/messages/hooks.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useEffectEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
type MessageQueryContext = {
|
||||
optimisticId: string;
|
||||
previousMessages: RelayEvent[];
|
||||
queryKey: readonly ["channel-messages", string];
|
||||
};
|
||||
|
||||
function mergeMessages(
|
||||
current: RelayEvent[],
|
||||
incoming: RelayEvent,
|
||||
): RelayEvent[] {
|
||||
const deduped = current.filter(
|
||||
(message) =>
|
||||
message.id !== incoming.id &&
|
||||
!(message.pending && incoming.content === message.content),
|
||||
);
|
||||
|
||||
return [...deduped, incoming].sort(
|
||||
(left, right) => left.created_at - right.created_at,
|
||||
);
|
||||
}
|
||||
|
||||
function createOptimisticMessage(
|
||||
channelId: string,
|
||||
content: string,
|
||||
identity: Identity,
|
||||
): RelayEvent {
|
||||
return {
|
||||
id: `optimistic-${crypto.randomUUID()}`,
|
||||
pubkey: identity.pubkey,
|
||||
created_at: Math.floor(Date.now() / 1_000),
|
||||
kind: 4_0001,
|
||||
tags: [["e", channelId]],
|
||||
content,
|
||||
sig: "",
|
||||
pending: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function useChannelMessagesQuery(channel: Channel | null) {
|
||||
return useQuery({
|
||||
enabled: channel !== null && channel.channelType !== "forum",
|
||||
queryKey: ["channel-messages", channel?.id ?? "none"],
|
||||
queryFn: async () => {
|
||||
if (!channel) {
|
||||
throw new Error("No channel selected.");
|
||||
}
|
||||
|
||||
return relayClient.fetchChannelHistory(channel.id);
|
||||
},
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: 30 * 60 * 1_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useChannelSubscription(channel: Channel | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const appendMessage = useEffectEvent((event: RelayEvent) => {
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", channel.id],
|
||||
(current = []) => mergeMessages(current, event),
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!channel || channel.channelType === "forum") {
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
relayClient
|
||||
.subscribeToChannel(channel.id, (event) => {
|
||||
if (!isDisposed) {
|
||||
appendMessage(event);
|
||||
}
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (isDisposed) {
|
||||
void dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup = dispose;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to subscribe to channel", channel.id, error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (cleanup) {
|
||||
void cleanup();
|
||||
}
|
||||
};
|
||||
}, [channel]);
|
||||
}
|
||||
|
||||
export function useSendMessageMutation(
|
||||
channel: Channel | null,
|
||||
identity: Identity | undefined,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
RelayEvent,
|
||||
Error,
|
||||
string,
|
||||
MessageQueryContext | undefined
|
||||
>({
|
||||
mutationFn: async (content) => {
|
||||
if (!channel || channel.channelType === "forum") {
|
||||
throw new Error("This channel does not support message sending yet.");
|
||||
}
|
||||
|
||||
return relayClient.sendMessage(channel.id, content);
|
||||
},
|
||||
onMutate: async (content) => {
|
||||
if (!channel || !identity || channel.channelType === "forum") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const queryKey = ["channel-messages", channel.id] as const;
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
|
||||
const previousMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
const optimisticMessage = createOptimisticMessage(
|
||||
channel.id,
|
||||
content.trim(),
|
||||
identity,
|
||||
);
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
queryKey,
|
||||
mergeMessages(previousMessages, optimisticMessage),
|
||||
);
|
||||
|
||||
return {
|
||||
optimisticId: optimisticMessage.id,
|
||||
previousMessages,
|
||||
queryKey,
|
||||
};
|
||||
},
|
||||
onError: (_error, _content, context) => {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(context.queryKey, context.previousMessages);
|
||||
},
|
||||
onSuccess: (message, _content, context) => {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
context.queryKey,
|
||||
(current = []) => {
|
||||
const withoutOptimistic = current.filter(
|
||||
(item) => item.id !== context.optimisticId,
|
||||
);
|
||||
return mergeMessages(withoutOptimistic, message);
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
52
desktop/src/features/messages/lib/formatTimelineMessages.ts
Normal file
52
desktop/src/features/messages/lib/formatTimelineMessages.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
function truncatePubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatMessageAuthor(
|
||||
event: RelayEvent,
|
||||
channel: Channel | null,
|
||||
currentPubkey: string | undefined,
|
||||
) {
|
||||
if (currentPubkey && event.pubkey === currentPubkey) {
|
||||
return "You";
|
||||
}
|
||||
|
||||
if (channel?.channelType === "dm") {
|
||||
const participantIndex = channel.participantPubkeys.indexOf(event.pubkey);
|
||||
if (participantIndex >= 0) {
|
||||
return (
|
||||
channel.participants[participantIndex] ?? truncatePubkey(event.pubkey)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return truncatePubkey(event.pubkey);
|
||||
}
|
||||
|
||||
export function formatTimelineMessages(
|
||||
events: RelayEvent[],
|
||||
channel: Channel | null,
|
||||
currentPubkey: string | undefined,
|
||||
): TimelineMessage[] {
|
||||
return events.map((event) => ({
|
||||
id: event.id,
|
||||
author: formatMessageAuthor(event, channel, currentPubkey),
|
||||
role:
|
||||
currentPubkey && event.pubkey === currentPubkey
|
||||
? "Local"
|
||||
: channel?.channelType === "dm"
|
||||
? "Participant"
|
||||
: "Pubkey",
|
||||
time: new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(event.created_at * 1_000)),
|
||||
body: event.content,
|
||||
accent: currentPubkey === event.pubkey,
|
||||
pending: event.pending,
|
||||
}));
|
||||
}
|
||||
9
desktop/src/features/messages/types.ts
Normal file
9
desktop/src/features/messages/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type TimelineMessage = {
|
||||
id: string;
|
||||
author: string;
|
||||
role: string;
|
||||
time: string;
|
||||
body: string;
|
||||
accent?: boolean;
|
||||
pending?: boolean;
|
||||
};
|
||||
@@ -1,72 +1,76 @@
|
||||
import { CircleDot, Hash, Lock, Plus } from "lucide-react";
|
||||
import { CircleDot, FileText, Hash } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
sidebarSections,
|
||||
type Channel,
|
||||
type ChannelSection,
|
||||
} from "@/features/sidebar/data/sidebarData";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { ThemeToggle } from "@/shared/theme/ThemeToggle";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarSeparator,
|
||||
} from "@/shared/ui/sidebar";
|
||||
|
||||
function SidebarChannelIcon({ variant }: Pick<Channel, "variant">) {
|
||||
if (variant === "private") {
|
||||
return <Lock className="h-4 w-4" />;
|
||||
type AppSidebarProps = {
|
||||
channels: Channel[];
|
||||
isLoading: boolean;
|
||||
errorMessage?: string;
|
||||
selectedChannelId: string | null;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
};
|
||||
|
||||
function SidebarChannelIcon({ channel }: { channel: Channel }) {
|
||||
if (channel.channelType === "dm") {
|
||||
return <CircleDot className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
if (variant === "direct") {
|
||||
return <CircleDot className="h-4 w-4" />;
|
||||
if (channel.channelType === "forum") {
|
||||
return <FileText className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
return <Hash className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
function SidebarChannelItem({ channel }: { channel: Channel }) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={channel.active}
|
||||
tooltip={channel.name}
|
||||
type="button"
|
||||
>
|
||||
<SidebarChannelIcon variant={channel.variant} />
|
||||
<span>{channel.name}</span>
|
||||
</SidebarMenuButton>
|
||||
{channel.unread ? (
|
||||
<SidebarMenuBadge>{channel.unread}</SidebarMenuBadge>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
function SidebarSection({
|
||||
items,
|
||||
selectedChannelId,
|
||||
title,
|
||||
onSelectChannel,
|
||||
}: {
|
||||
items: Channel[];
|
||||
selectedChannelId: string | null;
|
||||
title: string;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function SidebarChannelSection({ section }: { section: ChannelSection }) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{section.title}</SidebarGroupLabel>
|
||||
<SidebarGroupAction aria-label={`Add ${section.title}`} type="button">
|
||||
<Plus className="h-4 w-4" />
|
||||
</SidebarGroupAction>
|
||||
<SidebarGroupLabel>{title}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{section.items.map((channel) => (
|
||||
<SidebarChannelItem
|
||||
channel={channel}
|
||||
key={`${section.title}-${channel.name}`}
|
||||
/>
|
||||
{items.map((channel) => (
|
||||
<SidebarMenuItem key={channel.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={selectedChannelId === channel.id}
|
||||
onClick={() => onSelectChannel(channel.id)}
|
||||
tooltip={channel.name}
|
||||
type="button"
|
||||
>
|
||||
<SidebarChannelIcon channel={channel} />
|
||||
<span>{channel.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
@@ -74,7 +78,37 @@ function SidebarChannelSection({ section }: { section: ChannelSection }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
export function AppSidebar({
|
||||
channels,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
selectedChannelId,
|
||||
onSelectChannel,
|
||||
}: AppSidebarProps) {
|
||||
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
|
||||
const [query, setQuery] = React.useState("");
|
||||
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
|
||||
|
||||
const filteredChannels = React.useMemo(() => {
|
||||
if (!deferredQuery) {
|
||||
return channels;
|
||||
}
|
||||
|
||||
return channels.filter((channel) =>
|
||||
channel.name.toLowerCase().includes(deferredQuery),
|
||||
);
|
||||
}, [channels, deferredQuery]);
|
||||
|
||||
const streamChannels = filteredChannels.filter(
|
||||
(channel) => channel.channelType === "stream",
|
||||
);
|
||||
const forumChannels = filteredChannels.filter(
|
||||
(channel) => channel.channelType === "forum",
|
||||
);
|
||||
const directMessages = filteredChannels.filter(
|
||||
(channel) => channel.channelType === "dm",
|
||||
);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="offcanvas" variant="sidebar">
|
||||
<SidebarHeader className="gap-3">
|
||||
@@ -89,15 +123,63 @@ export function AppSidebar() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarInput placeholder="Jump to channel" />
|
||||
<SidebarInput
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Jump to channel"
|
||||
value={query}
|
||||
/>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarContent>
|
||||
{sidebarSections.map((section) => (
|
||||
<SidebarChannelSection key={section.title} section={section} />
|
||||
))}
|
||||
{isLoading ? (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Channels</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{skeletonRows.map((row) => (
|
||||
<SidebarMenuSkeleton key={row} showIcon />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
|
||||
{!isLoading ? (
|
||||
<>
|
||||
<SidebarSection
|
||||
items={streamChannels}
|
||||
onSelectChannel={onSelectChannel}
|
||||
selectedChannelId={selectedChannelId}
|
||||
title="Streams"
|
||||
/>
|
||||
<SidebarSection
|
||||
items={forumChannels}
|
||||
onSelectChannel={onSelectChannel}
|
||||
selectedChannelId={selectedChannelId}
|
||||
title="Forums"
|
||||
/>
|
||||
<SidebarSection
|
||||
items={directMessages}
|
||||
onSelectChannel={onSelectChannel}
|
||||
selectedChannelId={selectedChannelId}
|
||||
title="Direct Messages"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!isLoading && filteredChannels.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-sidebar-foreground/70">
|
||||
No channels match that filter.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="items-end">
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { App } from "@/app/App";
|
||||
import "@/shared/styles/globals.css";
|
||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
11
desktop/src/shared/api/hooks.ts
Normal file
11
desktop/src/shared/api/hooks.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getIdentity } from "@/shared/api/tauri";
|
||||
|
||||
export function useIdentityQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["identity"],
|
||||
queryFn: getIdentity,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
}
|
||||
450
desktop/src/shared/api/relayClient.ts
Normal file
450
desktop/src/shared/api/relayClient.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import {
|
||||
createAuthEvent,
|
||||
getRelayWsUrl,
|
||||
signRelayEvent,
|
||||
} from "@/shared/api/tauri";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
type RelaySubscriptionFilter = {
|
||||
kinds: number[];
|
||||
"#e": string[];
|
||||
limit: number;
|
||||
};
|
||||
|
||||
type HistorySubscription = {
|
||||
mode: "history";
|
||||
events: RelayEvent[];
|
||||
resolve: (events: RelayEvent[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type LiveSubscription = {
|
||||
mode: "live";
|
||||
onEvent: (event: RelayEvent) => void;
|
||||
};
|
||||
|
||||
type PendingEvent = {
|
||||
event: RelayEvent;
|
||||
resolve: (event: RelayEvent) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type RelaySubscription = HistorySubscription | LiveSubscription;
|
||||
|
||||
function sortEvents(events: RelayEvent[]) {
|
||||
return [...events].sort((left, right) => left.created_at - right.created_at);
|
||||
}
|
||||
|
||||
function getTextPayload(message: unknown) {
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"type" in message &&
|
||||
message.type === "Text" &&
|
||||
"data" in message &&
|
||||
typeof message.data === "string"
|
||||
) {
|
||||
return message.data;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"Text" in message &&
|
||||
typeof message.Text === "string"
|
||||
) {
|
||||
return message.Text;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
class RelayClient {
|
||||
private wsId: number | null = null;
|
||||
private relayUrl: string | null = null;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private authRequest: {
|
||||
pendingEventId: string;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
} | null = null;
|
||||
private subscriptions = new Map<string, RelaySubscription>();
|
||||
private pendingEvents = new Map<string, PendingEvent>();
|
||||
private activeLiveSubscriptionId: string | null = null;
|
||||
|
||||
async fetchChannelHistory(channelId: string, limit = 50) {
|
||||
await this.ensureConnected();
|
||||
|
||||
return new Promise<RelayEvent[]>((resolve, reject) => {
|
||||
const subId = `history-${crypto.randomUUID()}`;
|
||||
const timeout = window.setTimeout(() => {
|
||||
this.subscriptions.delete(subId);
|
||||
void this.closeSubscription(subId);
|
||||
reject(new Error("Timed out while loading channel history."));
|
||||
}, 8_000);
|
||||
|
||||
this.subscriptions.set(subId, {
|
||||
mode: "history",
|
||||
events: [],
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
void this.sendRaw([
|
||||
"REQ",
|
||||
subId,
|
||||
this.buildChannelFilter(channelId, limit),
|
||||
]).catch((error) => {
|
||||
window.clearTimeout(timeout);
|
||||
this.subscriptions.delete(subId);
|
||||
reject(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Failed to request channel history."),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(channelId: string, content: string) {
|
||||
await this.ensureConnected();
|
||||
|
||||
const event = await signRelayEvent({
|
||||
kind: 40001,
|
||||
content: content.trim(),
|
||||
tags: [["e", channelId]],
|
||||
});
|
||||
|
||||
return new Promise<RelayEvent>((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
this.pendingEvents.delete(event.id);
|
||||
reject(new Error("Timed out while sending the message."));
|
||||
}, 8_000);
|
||||
|
||||
this.pendingEvents.set(event.id, {
|
||||
event,
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
void this.sendRaw(["EVENT", event]).catch((error) => {
|
||||
window.clearTimeout(timeout);
|
||||
this.pendingEvents.delete(event.id);
|
||||
reject(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Failed to send the message."),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async subscribeToChannel(
|
||||
channelId: string,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
) {
|
||||
await this.ensureConnected();
|
||||
|
||||
const subId = `live-${crypto.randomUUID()}`;
|
||||
const previousSubscriptionId = this.activeLiveSubscriptionId;
|
||||
this.activeLiveSubscriptionId = subId;
|
||||
|
||||
if (previousSubscriptionId) {
|
||||
this.subscriptions.delete(previousSubscriptionId);
|
||||
await this.closeSubscription(previousSubscriptionId);
|
||||
}
|
||||
|
||||
this.subscriptions.set(subId, {
|
||||
mode: "live",
|
||||
onEvent,
|
||||
});
|
||||
|
||||
await this.sendRaw(["REQ", subId, this.buildChannelFilter(channelId, 50)]);
|
||||
|
||||
return async () => {
|
||||
const active = this.subscriptions.get(subId);
|
||||
if (!active || active.mode !== "live") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscriptions.delete(subId);
|
||||
if (this.activeLiveSubscriptionId === subId) {
|
||||
this.activeLiveSubscriptionId = null;
|
||||
}
|
||||
await this.closeSubscription(subId);
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureConnected() {
|
||||
if (this.connectPromise) {
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
if (this.wsId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.connectPromise = this.connect();
|
||||
|
||||
try {
|
||||
await this.connectPromise;
|
||||
} finally {
|
||||
this.connectPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async connect() {
|
||||
if (!this.relayUrl) {
|
||||
this.relayUrl = await getRelayWsUrl();
|
||||
}
|
||||
|
||||
const onMessageChannel = new Channel<unknown>((message) => {
|
||||
void this.handleWsMessage(message);
|
||||
});
|
||||
|
||||
this.wsId = await invoke<number>("plugin:websocket|connect", {
|
||||
url: this.relayUrl,
|
||||
onMessage: onMessageChannel,
|
||||
config: {},
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
this.authRequest = null;
|
||||
this.resetConnection(
|
||||
new Error("Timed out while waiting for relay authentication."),
|
||||
);
|
||||
reject(new Error("Timed out while waiting for relay authentication."));
|
||||
}, 8_000);
|
||||
|
||||
this.authRequest = {
|
||||
pendingEventId: "",
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private buildChannelFilter(
|
||||
channelId: string,
|
||||
limit: number,
|
||||
): RelaySubscriptionFilter {
|
||||
return {
|
||||
kinds: [40001],
|
||||
"#e": [channelId],
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
private async sendRaw(payload: unknown[]) {
|
||||
if (this.wsId === null) {
|
||||
throw new Error("Relay socket is not connected.");
|
||||
}
|
||||
|
||||
await invoke("plugin:websocket|send", {
|
||||
id: this.wsId,
|
||||
message: {
|
||||
type: "Text",
|
||||
data: JSON.stringify(payload),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async closeSubscription(subId: string) {
|
||||
if (this.wsId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendRaw(["CLOSE", subId]);
|
||||
}
|
||||
|
||||
private async handleWsMessage(message: unknown) {
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"type" in message &&
|
||||
message.type === "Close"
|
||||
) {
|
||||
this.resetConnection(new Error("Relay connection closed."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"type" in message &&
|
||||
message.type === "Error"
|
||||
) {
|
||||
this.resetConnection(new Error("Relay connection errored."));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = getTextPayload(message);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(payload);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [type, ...rest] = data;
|
||||
if (type === "AUTH" && typeof rest[0] === "string") {
|
||||
await this.handleAuthChallenge(rest[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "EVENT" && typeof rest[0] === "string" && rest[1]) {
|
||||
this.handleEvent(rest[0], rest[1] as RelayEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
type === "OK" &&
|
||||
typeof rest[0] === "string" &&
|
||||
typeof rest[1] === "boolean"
|
||||
) {
|
||||
this.handleOk(
|
||||
rest[0],
|
||||
rest[1],
|
||||
typeof rest[2] === "string" ? rest[2] : "",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "EOSE" && typeof rest[0] === "string") {
|
||||
this.handleEose(rest[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAuthChallenge(challenge: string) {
|
||||
if (!this.relayUrl) {
|
||||
this.relayUrl = await getRelayWsUrl();
|
||||
}
|
||||
|
||||
const event = await createAuthEvent({
|
||||
challenge,
|
||||
relayUrl: this.relayUrl,
|
||||
});
|
||||
|
||||
if (!this.authRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.authRequest.pendingEventId = event.id;
|
||||
await this.sendRaw(["AUTH", event]);
|
||||
}
|
||||
|
||||
private handleEvent(subId: string, event: RelayEvent) {
|
||||
const subscription = this.subscriptions.get(subId);
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscription.mode === "history") {
|
||||
subscription.events.push(event);
|
||||
return;
|
||||
}
|
||||
|
||||
subscription.onEvent(event);
|
||||
}
|
||||
|
||||
private handleEose(subId: string) {
|
||||
const subscription = this.subscriptions.get(subId);
|
||||
if (!subscription || subscription.mode !== "history") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(subscription.timeout);
|
||||
this.subscriptions.delete(subId);
|
||||
void this.closeSubscription(subId);
|
||||
subscription.resolve(sortEvents(subscription.events));
|
||||
}
|
||||
|
||||
private handleOk(eventId: string, success: boolean, message: string) {
|
||||
if (this.authRequest && this.authRequest.pendingEventId === eventId) {
|
||||
window.clearTimeout(this.authRequest.timeout);
|
||||
const authRequest = this.authRequest;
|
||||
this.authRequest = null;
|
||||
|
||||
if (success) {
|
||||
authRequest.resolve();
|
||||
} else {
|
||||
const error = new Error(message || "Relay authentication rejected.");
|
||||
authRequest.reject(error);
|
||||
this.resetConnection(error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingEvent = this.pendingEvents.get(eventId);
|
||||
if (!pendingEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(pendingEvent.timeout);
|
||||
this.pendingEvents.delete(eventId);
|
||||
|
||||
if (success) {
|
||||
pendingEvent.resolve(pendingEvent.event);
|
||||
} else {
|
||||
pendingEvent.reject(new Error(message || "Relay rejected the event."));
|
||||
}
|
||||
}
|
||||
|
||||
private resetConnection(error: Error) {
|
||||
if (this.wsId !== null) {
|
||||
void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch(
|
||||
() => {
|
||||
return;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
this.wsId = null;
|
||||
|
||||
if (this.authRequest) {
|
||||
window.clearTimeout(this.authRequest.timeout);
|
||||
this.authRequest.reject(error);
|
||||
this.authRequest = null;
|
||||
}
|
||||
|
||||
for (const [subId, subscription] of this.subscriptions) {
|
||||
if (subscription.mode === "history") {
|
||||
window.clearTimeout(subscription.timeout);
|
||||
subscription.reject(error);
|
||||
}
|
||||
this.subscriptions.delete(subId);
|
||||
}
|
||||
|
||||
for (const [eventId, pendingEvent] of this.pendingEvents) {
|
||||
window.clearTimeout(pendingEvent.timeout);
|
||||
pendingEvent.reject(error);
|
||||
this.pendingEvents.delete(eventId);
|
||||
}
|
||||
|
||||
this.activeLiveSubscriptionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const relayClient = new RelayClient();
|
||||
65
desktop/src/shared/api/tauri.ts
Normal file
65
desktop/src/shared/api/tauri.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
Channel,
|
||||
ChannelType,
|
||||
Identity,
|
||||
RelayEvent,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
type RawIdentity = {
|
||||
pubkey: string;
|
||||
display_name: string;
|
||||
};
|
||||
|
||||
type RawChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
channel_type: ChannelType;
|
||||
description: string;
|
||||
participants: string[];
|
||||
participant_pubkeys: string[];
|
||||
};
|
||||
|
||||
export async function getIdentity(): Promise<Identity> {
|
||||
const identity = await invoke<RawIdentity>("get_identity");
|
||||
|
||||
return {
|
||||
pubkey: identity.pubkey,
|
||||
displayName: identity.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRelayWsUrl(): Promise<string> {
|
||||
return invoke<string>("get_relay_ws_url");
|
||||
}
|
||||
|
||||
export async function getChannels(): Promise<Channel[]> {
|
||||
const channels = await invoke<RawChannel[]>("get_channels");
|
||||
|
||||
return channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
channelType: channel.channel_type,
|
||||
description: channel.description,
|
||||
participants: channel.participants,
|
||||
participantPubkeys: channel.participant_pubkeys,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function signRelayEvent(input: {
|
||||
kind: number;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
}): Promise<RelayEvent> {
|
||||
const eventJson = await invoke<string>("sign_event", input);
|
||||
return JSON.parse(eventJson) as RelayEvent;
|
||||
}
|
||||
|
||||
export async function createAuthEvent(input: {
|
||||
challenge: string;
|
||||
relayUrl: string;
|
||||
}): Promise<RelayEvent> {
|
||||
const eventJson = await invoke<string>("create_auth_event", input);
|
||||
return JSON.parse(eventJson) as RelayEvent;
|
||||
}
|
||||
26
desktop/src/shared/api/types.ts
Normal file
26
desktop/src/shared/api/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type ChannelType = "stream" | "forum" | "dm";
|
||||
|
||||
export type Channel = {
|
||||
id: string;
|
||||
name: string;
|
||||
channelType: ChannelType;
|
||||
description: string;
|
||||
participants: string[];
|
||||
participantPubkeys: string[];
|
||||
};
|
||||
|
||||
export type Identity = {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type RelayEvent = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
sig: string;
|
||||
pending?: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user