feat(desktop): connect chat to relay (#6)

This commit is contained in:
Wes
2026-03-09 14:49:50 -07:00
committed by GitHub
parent 70ccc6ea59
commit 0705327312
20 changed files with 2250 additions and 86 deletions
+56
View 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,
};
}
+21 -3
View File
@@ -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
View 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);
},
);
},
});
}
@@ -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
View File
@@ -0,0 +1,9 @@
export type TimelineMessage = {
id: string;
author: string;
role: string;
time: string;
body: string;
accent?: boolean;
pending?: boolean;
};
+127 -45
View File
@@ -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">