mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add channel creation flow (#8)
This commit is contained in:
@@ -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,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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user