Files
buzz/desktop/src/app/AppShell.tsx
T

427 lines
14 KiB
TypeScript
Raw Normal View History

import { getCurrentWindow } from "@tauri-apps/api/window";
2026-03-09 14:49:50 -07:00
import * as React from "react";
2026-03-10 17:11:49 -07:00
import { Settings2 } from "lucide-react";
2026-03-09 14:49:50 -07:00
2026-03-09 13:02:11 -07:00
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
2026-03-09 14:49:50 -07:00
import {
2026-03-09 17:33:13 -07:00
useCreateChannelMutation,
2026-03-09 14:49:50 -07:00
useChannelsQuery,
useSelectedChannel,
} from "@/features/channels/hooks";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
2026-03-10 17:11:49 -07:00
import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet";
2026-03-09 19:32:56 -07:00
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
2026-03-09 14:49:50 -07:00
import {
useChannelMessagesQuery,
2026-03-10 13:54:25 -07:00
mergeMessages,
2026-03-09 14:49:50 -07:00
useSendMessageMutation,
2026-03-11 12:09:09 -07:00
useChannelSubscription,
2026-03-09 14:49:50 -07:00
} from "@/features/messages/hooks";
2026-03-11 12:09:09 -07:00
import {
collectMessageAuthorPubkeys,
formatTimelineMessages,
} from "@/features/messages/lib/formatTimelineMessages";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
2026-03-10 13:54:25 -07:00
import { SearchDialog } from "@/features/search/ui/SearchDialog";
2026-03-11 10:34:41 -07:00
import { SettingsView } from "@/features/settings/ui/SettingsView";
2026-03-09 13:02:11 -07:00
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
2026-03-10 13:54:25 -07:00
import { getEventById } from "@/shared/api/tauri";
2026-03-09 14:49:50 -07:00
import { useIdentityQuery } from "@/shared/api/hooks";
2026-03-10 13:54:25 -07:00
import type { RelayEvent, SearchHit } from "@/shared/api/types";
2026-03-10 17:11:49 -07:00
import { Button } from "@/shared/ui/button";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/shared/ui/sidebar";
2026-03-09 13:02:11 -07:00
2026-03-11 10:34:41 -07:00
type AppView = "home" | "channel" | "settings";
2026-03-09 19:32:56 -07:00
2026-03-10 13:54:25 -07:00
function createSearchAnchorEvent(hit: SearchHit): RelayEvent {
return {
id: hit.eventId,
pubkey: hit.pubkey,
created_at: hit.createdAt,
kind: hit.kind,
2026-03-10 15:26:19 -07:00
tags: [["h", hit.channelId]],
2026-03-10 13:54:25 -07:00
content: hit.content,
sig: "",
};
}
2026-03-09 13:02:11 -07:00
export function AppShell() {
2026-03-09 19:32:56 -07:00
const [selectedView, setSelectedView] = React.useState<AppView>("home");
2026-03-10 17:11:49 -07:00
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
2026-03-10 13:54:25 -07:00
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
null,
);
const [searchAnchorChannelId, setSearchAnchorChannelId] = React.useState<
string | null
>(null);
const [searchAnchorEvent, setSearchAnchorEvent] =
React.useState<RelayEvent | null>(null);
2026-03-09 14:49:50 -07:00
const identityQuery = useIdentityQuery();
2026-03-11 12:09:09 -07:00
const profileQuery = useProfileQuery();
2026-03-09 19:32:56 -07:00
const homeFeedQuery = useHomeFeedQuery();
2026-03-09 14:49:50 -07:00
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
const { selectedChannel, setSelectedChannelId } = useSelectedChannel(
channels,
null,
);
2026-03-09 17:33:13 -07:00
const createChannelMutation = useCreateChannelMutation();
2026-03-09 19:32:56 -07:00
const activeChannel = selectedView === "channel" ? selectedChannel : null;
const { unreadChannelIds } = useUnreadChannels(channels, activeChannel);
2026-03-09 14:49:50 -07:00
2026-03-09 19:32:56 -07:00
const messagesQuery = useChannelMessagesQuery(activeChannel);
useChannelSubscription(activeChannel);
2026-03-09 14:49:50 -07:00
const sendMessageMutation = useSendMessageMutation(
2026-03-09 19:32:56 -07:00
activeChannel,
2026-03-09 14:49:50 -07:00
identityQuery.data,
);
2026-03-09 19:32:56 -07:00
const homeUrgentCount =
(homeFeedQuery.data?.feed.mentions.length ?? 0) +
(homeFeedQuery.data?.feed.needsAction.length ?? 0);
const availableChannelIds = React.useMemo(
() => new Set(channels.map((channel) => channel.id)),
[channels],
);
2026-03-10 13:54:25 -07:00
const resolvedMessages = React.useMemo(() => {
const currentMessages = messagesQuery.data ?? [];
if (
!activeChannel ||
!searchAnchorEvent ||
searchAnchorChannelId !== activeChannel.id
) {
return currentMessages;
}
return mergeMessages(currentMessages, searchAnchorEvent);
}, [
activeChannel,
messagesQuery.data,
searchAnchorChannelId,
searchAnchorEvent,
]);
2026-03-11 12:09:09 -07:00
const messageAuthorPubkeys = React.useMemo(
() => collectMessageAuthorPubkeys(resolvedMessages),
[resolvedMessages],
);
const messageProfilesQuery = useUsersBatchQuery(messageAuthorPubkeys, {
enabled: resolvedMessages.length > 0,
});
2026-03-09 14:49:50 -07:00
const timelineMessages = React.useMemo(
() =>
formatTimelineMessages(
2026-03-10 13:54:25 -07:00
resolvedMessages,
2026-03-09 19:32:56 -07:00
activeChannel,
2026-03-09 14:49:50 -07:00
identityQuery.data?.pubkey,
2026-03-11 12:09:09 -07:00
profileQuery.data?.avatarUrl ?? null,
messageProfilesQuery.data?.profiles,
2026-03-09 14:49:50 -07:00
),
2026-03-11 12:09:09 -07:00
[
activeChannel,
identityQuery.data?.pubkey,
profileQuery.data?.avatarUrl,
messageProfilesQuery.data?.profiles,
resolvedMessages,
],
2026-03-09 14:49:50 -07:00
);
2026-03-09 19:32:56 -07:00
const channelDescription = activeChannel
2026-03-10 17:11:49 -07:00
? [
activeChannel.archivedAt ? "Archived." : null,
activeChannel.topic,
activeChannel.description,
activeChannel.purpose,
activeChannel.channelType === "forum"
? "Forum channels are listed, but this first pass only wires message streams and DMs."
: null,
]
.filter((value) => value && value.trim().length > 0)
.join(" ") || "Channel details and activity."
2026-03-09 14:49:50 -07:00
: "Connect to the relay to browse channels and read messages.";
const contentPaneKey =
2026-03-11 10:34:41 -07:00
selectedView === "home"
? "home"
: selectedView === "settings"
? "settings"
: `channel:${activeChannel?.id ?? "none"}`;
2026-03-10 13:54:25 -07:00
const isTimelineLoading =
messagesQuery.isLoading && resolvedMessages.length === 0;
const handleOpenChannel = React.useCallback(
(channelId: string) => {
React.startTransition(() => {
setSelectedChannelId(channelId);
setSelectedView("channel");
});
},
[setSelectedChannelId],
);
2026-03-11 10:34:41 -07:00
const handleOpenSettings = React.useCallback(() => {
setIsSearchOpen(false);
setIsChannelManagementOpen(false);
React.startTransition(() => {
setSelectedView("settings");
});
}, []);
2026-03-10 13:54:25 -07:00
const handleOpenSearchResult = React.useCallback(
(hit: SearchHit) => {
setSearchAnchor(hit);
setSearchAnchorChannelId(hit.channelId);
setSearchAnchorEvent(createSearchAnchorEvent(hit));
handleOpenChannel(hit.channelId);
void getEventById(hit.eventId)
.then((event) => {
setSearchAnchorEvent((current) => {
if (current?.id !== hit.eventId) {
return current;
}
return event;
});
})
.catch((error) => {
console.error(
"Failed to load search result event",
hit.eventId,
error,
);
});
},
[handleOpenChannel],
);
2026-03-09 14:49:50 -07:00
2026-03-11 10:34:41 -07:00
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const isSettingsShortcut =
(event.key === "," || event.code === "Comma") &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey;
if (!isSettingsShortcut) {
return;
}
event.preventDefault();
handleOpenSettings();
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleOpenSettings]);
2026-03-09 13:02:11 -07:00
return (
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
<SidebarTrigger className="fixed left-[80px] top-[9px] z-50 h-6 w-6" />
2026-03-09 14:49:50 -07:00
<AppSidebar
channels={channels}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
2026-03-09 19:32:56 -07:00
homeUrgentCount={homeUrgentCount}
2026-03-09 14:49:50 -07:00
isLoading={channelsQuery.isLoading}
2026-03-09 17:33:13 -07:00
isCreatingChannel={createChannelMutation.isPending}
onCreateChannel={async ({ description, name }) => {
const createdChannel = await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility: "open",
});
2026-03-09 19:32:56 -07:00
React.startTransition(() => {
setSelectedChannelId(createdChannel.id);
setSelectedView("channel");
});
}}
2026-03-10 13:54:25 -07:00
onOpenSearch={() => {
setIsSearchOpen(true);
}}
2026-03-09 19:32:56 -07:00
onSelectHome={() => {
React.startTransition(() => {
setSelectedView("home");
});
void homeFeedQuery.refetch();
2026-03-09 17:33:13 -07:00
}}
2026-03-10 13:54:25 -07:00
onSelectChannel={handleOpenChannel}
2026-03-11 10:34:41 -07:00
onSelectSettings={handleOpenSettings}
2026-03-09 14:49:50 -07:00
selectedChannelId={selectedChannel?.id ?? null}
2026-03-09 19:32:56 -07:00
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
2026-03-09 14:49:50 -07:00
/>
2026-03-09 13:02:11 -07:00
<SidebarInset
className="relative min-h-0 min-w-0 overflow-hidden pt-7"
key={contentPaneKey}
>
{/* Drag strip covering the traffic-light inset area */}
<div
className="absolute inset-x-0 top-0 flex h-7 items-center px-2"
onPointerDown={(e) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('button, a, input, [role="button"]')) return;
e.preventDefault();
getCurrentWindow().startDragging();
}}
/>
2026-03-09 19:32:56 -07:00
{selectedView === "home" ? (
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."
mode="home"
title="Home"
/>
2026-03-11 10:34:41 -07:00
) : selectedView === "settings" ? (
<ChatHeader
description="Theme, appearance, and profile preferences for your current identity."
mode="settings"
title="Settings"
/>
2026-03-09 19:32:56 -07:00
) : (
<ChatHeader
2026-03-10 17:11:49 -07:00
actions={
activeChannel ? (
<Button
data-testid="channel-management-trigger"
onClick={() => {
setIsChannelManagementOpen(true);
}}
size="sm"
type="button"
variant="outline"
>
<Settings2 className="h-4 w-4" />
Manage
</Button>
) : null
}
2026-03-09 19:32:56 -07:00
channelType={activeChannel?.channelType}
description={channelDescription}
title={activeChannel?.name ?? "Channels"}
/>
)}
2026-03-09 13:02:11 -07:00
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
2026-03-09 19:32:56 -07:00
{selectedView === "home" ? (
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
isRefreshing={homeFeedQuery.isRefetching}
2026-03-10 13:54:25 -07:00
onOpenChannel={handleOpenChannel}
2026-03-09 19:32:56 -07:00
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
2026-03-11 10:34:41 -07:00
) : selectedView === "settings" ? (
<SettingsView
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
/>
2026-03-09 19:32:56 -07:00
) : (
<>
<MessageTimeline
emptyDescription={
activeChannel?.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={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
2026-03-10 13:54:25 -07:00
isLoading={isTimelineLoading}
key={activeChannel?.id ?? "no-channel"}
2026-03-09 19:32:56 -07:00
messages={timelineMessages}
2026-03-10 13:54:25 -07:00
onTargetReached={(messageId) => {
setSearchAnchor((current) =>
current?.eventId === messageId ? null : current,
);
}}
targetMessageId={
activeChannel && searchAnchor?.channelId === activeChannel.id
? searchAnchor.eventId
: null
}
2026-03-09 19:32:56 -07:00
/>
<MessageComposer
channelName={activeChannel?.name ?? "channel"}
disabled={
!activeChannel ||
2026-03-10 17:11:49 -07:00
activeChannel.archivedAt !== null ||
2026-03-09 19:32:56 -07:00
activeChannel.channelType === "forum" ||
sendMessageMutation.isPending
}
isSending={sendMessageMutation.isPending}
2026-03-10 13:54:25 -07:00
key={activeChannel?.id ?? "no-channel"}
2026-03-09 19:32:56 -07:00
onSend={async (content) => {
await sendMessageMutation.mutateAsync(content);
}}
placeholder={
2026-03-10 17:11:49 -07:00
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
2026-03-09 19:32:56 -07:00
}
/>
</>
)}
2026-03-09 13:02:11 -07:00
</div>
2026-03-10 13:54:25 -07:00
<SearchDialog
channels={channels}
2026-03-11 12:09:09 -07:00
currentPubkey={identityQuery.data?.pubkey}
2026-03-10 13:54:25 -07:00
onOpenResult={handleOpenSearchResult}
onOpenChange={setIsSearchOpen}
open={isSearchOpen}
/>
2026-03-10 17:11:49 -07:00
<ChannelManagementSheet
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onDeleted={() => {
React.startTransition(() => {
setIsChannelManagementOpen(false);
setSelectedView("home");
});
}}
onOpenChange={setIsChannelManagementOpen}
open={isChannelManagementOpen && activeChannel !== null}
/>
2026-03-09 13:02:11 -07:00
</SidebarInset>
</SidebarProvider>
);
}