feat: add channel creation flow (#8)

This commit is contained in:
Wes
2026-03-09 17:33:13 -07:00
committed by GitHub
parent 774bd62fb3
commit 6f900bcbcc
16 changed files with 582 additions and 78 deletions
+43 -4
View File
@@ -1,17 +1,56 @@
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getChannels } from "@/shared/api/tauri";
import type { Channel } from "@/shared/api/types";
import { createChannel, getChannels } from "@/shared/api/tauri";
import type { Channel, CreateChannelInput } from "@/shared/api/types";
const channelsQueryKey = ["channels"] as const;
const channelTypeOrder = {
stream: 0,
forum: 1,
dm: 2,
} as const;
function sortChannels(channels: Channel[]) {
return [...channels].sort((left, right) => {
const typeOrder =
channelTypeOrder[left.channelType] - channelTypeOrder[right.channelType];
if (typeOrder !== 0) {
return typeOrder;
}
return left.name.localeCompare(right.name);
});
}
export function useChannelsQuery() {
return useQuery({
queryKey: ["channels"],
queryKey: channelsQueryKey,
queryFn: getChannels,
staleTime: 30_000,
});
}
export function useCreateChannelMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateChannelInput) => createChannel(input),
onSuccess: (createdChannel) => {
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current = []) =>
sortChannels([
...current.filter((channel) => channel.id !== createdChannel.id),
createdChannel,
]),
);
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
},
});
}
export function useSelectedChannel(
channels: Channel[],
preferredChannelId: string | null,
@@ -35,12 +35,6 @@ export function formatTimelineMessages(
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",
+1 -1
View File
@@ -1,7 +1,7 @@
export type TimelineMessage = {
id: string;
author: string;
role: string;
role?: string;
time: string;
body: string;
accent?: boolean;
@@ -20,10 +20,10 @@ function MessageRow({ message }: { message: TimelineMessage }) {
.toUpperCase();
return (
<article className="flex gap-4">
<article className="flex gap-3">
<div
className={cn(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl text-sm font-semibold shadow-sm",
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
@@ -32,20 +32,26 @@ function MessageRow({ message }: { message: TimelineMessage }) {
{initials}
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-semibold tracking-tight">{message.author}</h3>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{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
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold tracking-tight">
{message.author}
</h3>
{message.role ? (
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{message.role}
</p>
) : null}
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
{message.pending ? (
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
Sending
</p>
) : null}
<p className="whitespace-nowrap">{message.time}</p>
</div>
</div>
<Markdown className="max-w-3xl" content={message.body} />
<Markdown className="max-w-3xl" compact content={message.body} />
</div>
</article>
);
@@ -57,10 +63,10 @@ function TimelineSkeleton() {
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" />
<div className="flex gap-3" key={row}>
<Skeleton className="h-9 w-9 rounded-xl" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-44" />
<Skeleton className="h-4 w-full max-w-2xl" />
<Skeleton className="h-4 w-full max-w-xl" />
</div>
@@ -77,8 +83,8 @@ export function MessageTimeline({
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">
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<div className="flex items-center gap-4">
<Separator className="flex-1" />
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
+206 -8
View File
@@ -1,13 +1,16 @@
import { CircleDot, FileText, Hash } from "lucide-react";
import { CircleDot, FileText, Hash, Plus } from "lucide-react";
import * as React from "react";
import type { Channel } from "@/shared/api/types";
import { ThemeToggle } from "@/shared/theme/ThemeToggle";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
@@ -22,8 +25,13 @@ import {
type AppSidebarProps = {
channels: Channel[];
isLoading: boolean;
isCreatingChannel: boolean;
errorMessage?: string;
selectedChannelId: string | null;
onCreateChannel: (input: {
name: string;
description?: string;
}) => Promise<void>;
onSelectChannel: (channelId: string) => void;
};
@@ -78,16 +86,143 @@ function SidebarSection({
);
}
function StreamsSection({
items,
isCreateOpen,
isCreatingChannel,
draftName,
draftDescription,
createInputRef,
createErrorMessage,
onToggleCreate,
onChangeName,
onChangeDescription,
onCreateChannel,
onCancelCreate,
onSelectChannel,
selectedChannelId,
}: {
items: Channel[];
isCreateOpen: boolean;
isCreatingChannel: boolean;
draftName: string;
draftDescription: string;
createInputRef: React.RefObject<HTMLInputElement | null>;
createErrorMessage?: string;
onToggleCreate: () => void;
onChangeName: (value: string) => void;
onChangeDescription: (value: string) => void;
onCreateChannel: (event: React.FormEvent<HTMLFormElement>) => void;
onCancelCreate: () => void;
onSelectChannel: (channelId: string) => void;
selectedChannelId: string | null;
}) {
return (
<SidebarGroup>
<SidebarGroupLabel>Streams</SidebarGroupLabel>
<SidebarGroupAction
aria-expanded={isCreateOpen}
aria-label={isCreateOpen ? "Close new stream form" : "Create a stream"}
className="top-3 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
onClick={onToggleCreate}
type="button"
>
<Plus
className={
isCreateOpen
? "rotate-45 transition-transform"
: "transition-transform"
}
/>
</SidebarGroupAction>
<SidebarGroupContent>
{isCreateOpen ? (
<form
className="mb-2 space-y-2 rounded-lg border border-sidebar-border/70 bg-sidebar-accent/60 p-2"
onSubmit={onCreateChannel}
>
<Input
autoComplete="off"
className="h-8 bg-background/80"
disabled={isCreatingChannel}
onChange={(event) => onChangeName(event.target.value)}
placeholder="release-notes"
ref={createInputRef}
value={draftName}
/>
<Input
autoComplete="off"
className="h-8 bg-background/80"
disabled={isCreatingChannel}
onChange={(event) => onChangeDescription(event.target.value)}
placeholder="What this stream is for"
value={draftDescription}
/>
<div className="flex items-center gap-2">
<Button
disabled={isCreatingChannel || draftName.trim().length === 0}
size="sm"
type="submit"
>
{isCreatingChannel ? "Creating..." : "Create"}
</Button>
<Button
disabled={isCreatingChannel}
onClick={onCancelCreate}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</div>
{createErrorMessage ? (
<p className="text-sm text-destructive">{createErrorMessage}</p>
) : null}
</form>
) : null}
{items.length > 0 ? (
<SidebarMenu>
{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>
) : null}
</SidebarGroupContent>
</SidebarGroup>
);
}
export function AppSidebar({
channels,
isLoading,
isCreatingChannel,
errorMessage,
selectedChannelId,
onCreateChannel,
onSelectChannel,
}: AppSidebarProps) {
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
const [query, setQuery] = React.useState("");
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [draftName, setDraftName] = React.useState("");
const [draftDescription, setDraftDescription] = React.useState("");
const [createErrorMessage, setCreateErrorMessage] = React.useState<
string | undefined
>();
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
const createInputRef = React.useRef<HTMLInputElement>(null);
const filteredChannels = React.useMemo(() => {
if (!deferredQuery) {
@@ -109,6 +244,41 @@ export function AppSidebar({
(channel) => channel.channelType === "dm",
);
React.useEffect(() => {
if (!isCreateOpen) {
return;
}
createInputRef.current?.focus();
}, [isCreateOpen]);
async function handleCreateChannel(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = draftName.trim();
const description = draftDescription.trim();
if (!name) {
return;
}
setCreateErrorMessage(undefined);
try {
await onCreateChannel({
name,
description: description || undefined,
});
setDraftName("");
setDraftDescription("");
setIsCreateOpen(false);
} catch (error) {
setCreateErrorMessage(
error instanceof Error ? error.message : "Failed to create stream.",
);
}
}
return (
<Sidebar collapsible="offcanvas" variant="sidebar">
<SidebarHeader className="gap-3">
@@ -123,11 +293,13 @@ export function AppSidebar({
</p>
</div>
</div>
<SidebarInput
onChange={(event) => setQuery(event.target.value)}
placeholder="Jump to channel"
value={query}
/>
<div className="flex items-center gap-2">
<SidebarInput
onChange={(event) => setQuery(event.target.value)}
placeholder="Jump to channel"
value={query}
/>
</div>
</SidebarHeader>
<SidebarSeparator />
@@ -148,11 +320,37 @@ export function AppSidebar({
{!isLoading ? (
<>
<SidebarSection
<StreamsSection
createErrorMessage={createErrorMessage}
createInputRef={createInputRef}
draftDescription={draftDescription}
draftName={draftName}
isCreateOpen={isCreateOpen}
isCreatingChannel={isCreatingChannel}
items={streamChannels}
onCancelCreate={() => {
setCreateErrorMessage(undefined);
setDraftName("");
setDraftDescription("");
setIsCreateOpen(false);
}}
onChangeDescription={(value) => {
setCreateErrorMessage(undefined);
setDraftDescription(value);
}}
onChangeName={(value) => {
setCreateErrorMessage(undefined);
setDraftName(value);
}}
onCreateChannel={(event) => {
void handleCreateChannel(event);
}}
onSelectChannel={onSelectChannel}
onToggleCreate={() => {
setCreateErrorMessage(undefined);
setIsCreateOpen((current) => !current);
}}
selectedChannelId={selectedChannelId}
title="Streams"
/>
<SidebarSection
items={forumChannels}