mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Update desktop navigation chrome
Move search, workspace switching, and thread controls into the top/profile chrome, including a centered live search variation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
9823ff52e2
commit
5f2fcad0f7
@@ -1,4 +1,4 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
DEFAULT_SETTINGS_SECTION,
|
||||
type SettingsSection,
|
||||
} from "@/features/settings/ui/SettingsPanels";
|
||||
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
|
||||
import { HuddleBar, HuddleProvider } from "@/features/huddle";
|
||||
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
|
||||
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
|
||||
@@ -640,6 +641,29 @@ export function AppShell() {
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<TopbarSearch
|
||||
channels={channels}
|
||||
className="fixed left-1/2 top-[7px] z-50 hidden w-[360px] max-w-[42vw] -translate-x-1/2 md:block"
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onOpenChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
onOpenResult={handleOpenSearchResult}
|
||||
/>
|
||||
<div className="fixed right-3 top-[9px] z-50 flex items-center gap-0.5 md:hidden">
|
||||
<Button
|
||||
aria-label="Search everything"
|
||||
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
data-testid="open-search-compact"
|
||||
onClick={handleOpenSearch}
|
||||
size="icon"
|
||||
title="Search everything"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<AppSidebar
|
||||
activeWorkspace={workspacesHook.activeWorkspace}
|
||||
channels={sidebarChannels}
|
||||
@@ -721,7 +745,6 @@ export function AppShell() {
|
||||
});
|
||||
await goChannel(directMessage.id);
|
||||
}}
|
||||
onOpenSearch={handleOpenSearch}
|
||||
onSelectAgents={() => {
|
||||
void goAgents();
|
||||
}}
|
||||
|
||||
@@ -161,7 +161,6 @@ export function MessageThreadPanel({
|
||||
<aside
|
||||
className={cn(
|
||||
PANEL_BASE_CLASS,
|
||||
!isOverlay && "pt-11",
|
||||
isOverlay && PANEL_OVERLAY_CLASS,
|
||||
)}
|
||||
data-testid="message-thread-panel"
|
||||
@@ -185,24 +184,34 @@ export function MessageThreadPanel({
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"z-40 flex min-h-[44px] cursor-default select-none items-center gap-3 bg-background/70 px-4 py-[6px] backdrop-blur-xl supports-[backdrop-filter]:bg-background/55",
|
||||
isOverlay ? "relative shrink-0" : "absolute left-0 top-0",
|
||||
)}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Thread</h2>
|
||||
<Button
|
||||
aria-label="Close thread"
|
||||
className="h-4 w-4 rounded-full text-muted-foreground/45 opacity-70 hover:bg-muted/60 hover:text-foreground hover:opacity-100 focus-visible:opacity-100"
|
||||
data-testid="message-thread-close"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close thread"
|
||||
data-testid="message-thread-close"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto pb-24"
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto pb-24",
|
||||
!isOverlay && "pt-11",
|
||||
)}
|
||||
data-testid="message-thread-body"
|
||||
onScroll={syncScrollState}
|
||||
ref={threadBodyRef}
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { useSearchMessagesQuery } from "@/features/search/hooks";
|
||||
import type { Channel, SearchHit } from "@/shared/api/types";
|
||||
import {
|
||||
MIN_SEARCH_QUERY_LENGTH,
|
||||
useSearchResults,
|
||||
} from "@/features/search/useSearchResults";
|
||||
import {
|
||||
ChannelResultBody,
|
||||
MessageResultBody,
|
||||
@@ -28,8 +30,6 @@ import {
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
|
||||
function SearchState({
|
||||
icon: Icon,
|
||||
title,
|
||||
@@ -86,70 +86,21 @@ export function SearchDialog({
|
||||
onOpenChannel,
|
||||
onOpenResult,
|
||||
}: SearchDialogProps) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = React.useState("");
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const channelLookup = React.useMemo(
|
||||
() => new Map(channels.map((channel) => [channel.id, channel])),
|
||||
[channels],
|
||||
);
|
||||
|
||||
const searchQuery = useSearchMessagesQuery(debouncedQuery, {
|
||||
enabled: open,
|
||||
limit: 12,
|
||||
});
|
||||
|
||||
const messageResults = searchQuery.data?.hits ?? [];
|
||||
const channelResults = React.useMemo(() => {
|
||||
if (debouncedQuery.length < MIN_QUERY_LENGTH) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedQuery = debouncedQuery.toLowerCase();
|
||||
|
||||
return channels
|
||||
.filter(
|
||||
(channel) =>
|
||||
channel.channelType !== "dm" &&
|
||||
(channel.archivedAt
|
||||
? channel.isMember
|
||||
: channel.visibility === "open" || channel.isMember) &&
|
||||
(channel.name.toLowerCase().includes(normalizedQuery) ||
|
||||
channel.description.toLowerCase().includes(normalizedQuery)),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aNameMatches = a.name.toLowerCase().includes(normalizedQuery);
|
||||
const bNameMatches = b.name.toLowerCase().includes(normalizedQuery);
|
||||
|
||||
if (aNameMatches !== bNameMatches) {
|
||||
return aNameMatches ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.slice(0, 5);
|
||||
}, [channels, debouncedQuery]);
|
||||
const results = React.useMemo<SearchResult[]>(
|
||||
() => [
|
||||
...channelResults.map((channel) => ({
|
||||
kind: "channel" as const,
|
||||
channel,
|
||||
})),
|
||||
...messageResults.map((hit) => ({
|
||||
kind: "message" as const,
|
||||
hit,
|
||||
})),
|
||||
],
|
||||
[channelResults, messageResults],
|
||||
);
|
||||
const resultProfilesQuery = useUsersBatchQuery(
|
||||
messageResults.map((hit) => hit.pubkey),
|
||||
{
|
||||
enabled: open && messageResults.length > 0,
|
||||
},
|
||||
);
|
||||
const resultProfiles = resultProfilesQuery.data?.profiles;
|
||||
const {
|
||||
channelLookup,
|
||||
channelResults,
|
||||
debouncedQuery,
|
||||
messageResults,
|
||||
query,
|
||||
resultProfiles,
|
||||
results,
|
||||
searchQuery,
|
||||
selectedIndex,
|
||||
selectedResult,
|
||||
setQuery,
|
||||
setSelectedIndex,
|
||||
} = useSearchResults({ channels, enabled: open, limit: 12 });
|
||||
|
||||
const openResult = React.useCallback(
|
||||
(result: SearchResult) => {
|
||||
@@ -165,42 +116,6 @@ export function SearchDialog({
|
||||
[onOpenChange, onOpenChannel, onOpenResult],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length < MIN_QUERY_LENGTH) {
|
||||
setDebouncedQuery("");
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setDebouncedQuery(trimmed);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setDebouncedQuery("");
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex((current) => {
|
||||
if (results.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(current, results.length - 1);
|
||||
});
|
||||
}, [results]);
|
||||
|
||||
const selectedResult = results[selectedIndex];
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
@@ -266,7 +181,7 @@ export function SearchDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{debouncedQuery.length < MIN_QUERY_LENGTH ? (
|
||||
{debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH ? (
|
||||
<SearchState
|
||||
description="Type at least two characters to search the relay-backed history for streams, forums, DMs, approvals, and agent updates."
|
||||
icon={MessagesSquare}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { LoaderCircle, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
MIN_SEARCH_QUERY_LENGTH,
|
||||
useSearchResults,
|
||||
} from "@/features/search/useSearchResults";
|
||||
import {
|
||||
resultIcon,
|
||||
resultKey,
|
||||
resultTestId,
|
||||
type SearchResult,
|
||||
} from "@/features/search/ui/SearchResultItem";
|
||||
import type { Channel, SearchHit } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type TopbarSearchProps = {
|
||||
channels: Channel[];
|
||||
className?: string;
|
||||
currentPubkey?: string;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
onOpenResult: (hit: SearchHit) => void;
|
||||
};
|
||||
|
||||
function resultTitle(result: SearchResult) {
|
||||
if (result.kind === "channel") {
|
||||
return result.channel.name;
|
||||
}
|
||||
|
||||
return result.hit.channelName ?? "Message";
|
||||
}
|
||||
|
||||
function resultSummary(result: SearchResult) {
|
||||
if (result.kind === "channel") {
|
||||
return result.channel.description || result.channel.channelType;
|
||||
}
|
||||
|
||||
return result.hit.content.trim() || "No message body.";
|
||||
}
|
||||
|
||||
export function TopbarSearch({
|
||||
channels,
|
||||
className,
|
||||
currentPubkey,
|
||||
onOpenChannel,
|
||||
onOpenResult,
|
||||
}: TopbarSearchProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
channelLookup,
|
||||
debouncedQuery,
|
||||
query,
|
||||
resultProfiles,
|
||||
results,
|
||||
searchQuery,
|
||||
selectedIndex,
|
||||
selectedResult,
|
||||
setQuery,
|
||||
setSelectedIndex,
|
||||
} = useSearchResults({ channels, enabled: isOpen, limit: 8 });
|
||||
|
||||
const openResult = React.useCallback(
|
||||
(result: SearchResult) => {
|
||||
setIsOpen(false);
|
||||
setQuery("");
|
||||
|
||||
if (result.kind === "channel") {
|
||||
onOpenChannel(result.channel.id);
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenResult(result.hit);
|
||||
},
|
||||
[onOpenChannel, onOpenResult, setQuery],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
rootRef.current?.contains(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showSuggestions = isOpen && query.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)} ref={rootRef}>
|
||||
<div className="flex h-7 items-center gap-2 rounded-lg border border-border/70 bg-muted/45 px-2.5 text-xs text-muted-foreground shadow-xs backdrop-blur transition-colors focus-within:border-border focus-within:bg-muted/70 focus-within:text-foreground hover:bg-muted/70 supports-[backdrop-filter]:bg-muted/35">
|
||||
<Search className="h-3.5 w-3.5 shrink-0" />
|
||||
<input
|
||||
aria-label="Search everything"
|
||||
className="min-w-0 flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none"
|
||||
data-testid="open-search"
|
||||
onChange={(event) => {
|
||||
setIsOpen(true);
|
||||
setQuery(event.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" && results.length > 0) {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) =>
|
||||
Math.min(current + 1, results.length - 1),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" && results.length > 0) {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) => Math.max(current - 1, 0));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.nativeEvent.isComposing &&
|
||||
selectedResult
|
||||
) {
|
||||
event.preventDefault();
|
||||
openResult(selectedResult);
|
||||
}
|
||||
}}
|
||||
placeholder="Search everything"
|
||||
value={query}
|
||||
/>
|
||||
<kbd className="shrink-0 text-[10px] text-muted-foreground/70">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{showSuggestions ? (
|
||||
<div className="absolute left-1/2 top-full z-50 mt-1 w-[560px] max-w-[min(80vw,560px)] -translate-x-1/2 overflow-hidden rounded-xl border border-border/80 bg-popover text-popover-foreground shadow-xl">
|
||||
{debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH ? (
|
||||
<p className="px-3 py-3 text-xs text-muted-foreground">
|
||||
Type at least two characters to search.
|
||||
</p>
|
||||
) : searchQuery.isLoading && results.length === 0 ? (
|
||||
<div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground">
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
||||
Searching...
|
||||
</div>
|
||||
) : searchQuery.error instanceof Error && results.length === 0 ? (
|
||||
<p className="px-3 py-3 text-xs text-destructive">
|
||||
{searchQuery.error.message}
|
||||
</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="px-3 py-3 text-xs text-muted-foreground">
|
||||
No matches found.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[360px] overflow-y-auto p-1.5">
|
||||
{results.map((result, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors",
|
||||
index === selectedIndex
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-accent/70",
|
||||
)}
|
||||
key={resultKey(result)}
|
||||
onClick={() => openResult(result)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
type="button"
|
||||
data-testid={resultTestId(result)}
|
||||
>
|
||||
{React.createElement(resultIcon(result, channelLookup), {
|
||||
className: "h-3.5 w-3.5 shrink-0 text-muted-foreground",
|
||||
})}
|
||||
<span className="min-w-0 flex-1 truncate text-xs">
|
||||
<span className="font-medium">{resultTitle(result)}</span>
|
||||
<span className="mx-1.5 text-muted-foreground">-</span>
|
||||
<span className="text-muted-foreground">
|
||||
{resultSummary(result)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
{result.kind === "channel" ? "Channel" : "Message"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { useSearchMessagesQuery } from "@/features/search/hooks";
|
||||
import type { SearchResult } from "@/features/search/ui/SearchResultItem";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
|
||||
export const MIN_SEARCH_QUERY_LENGTH = 2;
|
||||
|
||||
export function useSearchResults({
|
||||
channels,
|
||||
enabled,
|
||||
limit = 12,
|
||||
}: {
|
||||
channels: Channel[];
|
||||
enabled: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = React.useState("");
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
|
||||
const channelLookup = React.useMemo(
|
||||
() => new Map(channels.map((channel) => [channel.id, channel])),
|
||||
[channels],
|
||||
);
|
||||
|
||||
const searchQuery = useSearchMessagesQuery(debouncedQuery, {
|
||||
enabled,
|
||||
limit,
|
||||
});
|
||||
|
||||
const messageResults = searchQuery.data?.hits ?? [];
|
||||
const channelResults = React.useMemo(() => {
|
||||
if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedQuery = debouncedQuery.toLowerCase();
|
||||
|
||||
return channels
|
||||
.filter(
|
||||
(channel) =>
|
||||
channel.channelType !== "dm" &&
|
||||
(channel.archivedAt
|
||||
? channel.isMember
|
||||
: channel.visibility === "open" || channel.isMember) &&
|
||||
(channel.name.toLowerCase().includes(normalizedQuery) ||
|
||||
channel.description.toLowerCase().includes(normalizedQuery)),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aNameMatches = a.name.toLowerCase().includes(normalizedQuery);
|
||||
const bNameMatches = b.name.toLowerCase().includes(normalizedQuery);
|
||||
|
||||
if (aNameMatches !== bNameMatches) {
|
||||
return aNameMatches ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.slice(0, 5);
|
||||
}, [channels, debouncedQuery]);
|
||||
|
||||
const results = React.useMemo<SearchResult[]>(
|
||||
() => [
|
||||
...channelResults.map((channel) => ({
|
||||
kind: "channel" as const,
|
||||
channel,
|
||||
})),
|
||||
...messageResults.map((hit) => ({
|
||||
kind: "message" as const,
|
||||
hit,
|
||||
})),
|
||||
],
|
||||
[channelResults, messageResults],
|
||||
);
|
||||
|
||||
const resultProfilesQuery = useUsersBatchQuery(
|
||||
messageResults.map((hit) => hit.pubkey),
|
||||
{
|
||||
enabled: enabled && messageResults.length > 0,
|
||||
},
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length < MIN_SEARCH_QUERY_LENGTH) {
|
||||
setDebouncedQuery("");
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setDebouncedQuery(trimmed);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
setQuery("");
|
||||
setDebouncedQuery("");
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex((current) => {
|
||||
if (results.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(current, results.length - 1);
|
||||
});
|
||||
}, [results]);
|
||||
|
||||
return {
|
||||
channelLookup,
|
||||
channelResults,
|
||||
debouncedQuery,
|
||||
messageResults,
|
||||
query,
|
||||
resultProfiles: resultProfilesQuery.data?.profiles,
|
||||
results,
|
||||
searchQuery,
|
||||
selectedIndex,
|
||||
selectedResult: results[selectedIndex],
|
||||
setQuery,
|
||||
setSelectedIndex,
|
||||
};
|
||||
}
|
||||
@@ -39,7 +39,6 @@ import type {
|
||||
UserStatus,
|
||||
} from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -126,7 +125,6 @@ type AppSidebarProps = {
|
||||
onOpenAddWorkspace: () => void;
|
||||
onOpenBrowseChannels: () => void;
|
||||
onOpenBrowseForums: () => void;
|
||||
onOpenSearch: () => void;
|
||||
onHideDm: (channelId: string) => void;
|
||||
onMarkChannelUnread: (
|
||||
channelId: string,
|
||||
@@ -385,7 +383,6 @@ export function AppSidebar({
|
||||
onOpenAddWorkspace,
|
||||
onOpenBrowseChannels,
|
||||
onOpenBrowseForums,
|
||||
onOpenSearch,
|
||||
onHideDm,
|
||||
onMarkChannelUnread,
|
||||
onMarkChannelRead,
|
||||
@@ -508,33 +505,9 @@ export function AppSidebar({
|
||||
variant="sidebar"
|
||||
>
|
||||
<SidebarHeader
|
||||
className="cursor-default select-none gap-3 pt-10"
|
||||
className="cursor-default select-none pt-10"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="px-0.5">
|
||||
<WorkspaceSwitcher
|
||||
activeWorkspace={activeWorkspace}
|
||||
onAddWorkspace={onOpenAddWorkspace}
|
||||
onRemoveWorkspace={onRemoveWorkspace}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full justify-between rounded-xl border border-sidebar-border/80 bg-sidebar-accent/60 px-3 text-sidebar-foreground/80 shadow-xs hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
data-testid="open-search"
|
||||
onClick={onOpenSearch}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
Search messages
|
||||
</span>
|
||||
<span className="text-xs text-sidebar-foreground/50">⌘K</span>
|
||||
</Button>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
@@ -718,69 +691,80 @@ export function AppSidebar({
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<ProfilePopover
|
||||
open={profilePopoverOpen}
|
||||
onOpenChange={setProfilePopoverOpen}
|
||||
displayName={resolvedDisplayName}
|
||||
nip05={profile?.nip05Handle}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
currentStatus={selfPresenceStatus}
|
||||
isStatusPending={isPresencePending}
|
||||
userStatusText={selfUserStatus?.text}
|
||||
userStatusEmoji={selfUserStatus?.emoji}
|
||||
onSetStatus={onSetPresenceStatus ?? (() => {})}
|
||||
onSetUserStatus={onSetUserStatus}
|
||||
onClearUserStatus={onClearUserStatus}
|
||||
onOpenSettings={onSelectSettings}
|
||||
<div
|
||||
className="rounded-xl px-2 py-2 transition-colors hover:bg-sidebar-accent/70 focus-within:bg-sidebar-accent/70"
|
||||
data-testid="sidebar-profile-card"
|
||||
>
|
||||
<SidebarMenuButton
|
||||
className="h-auto gap-3 rounded-xl px-2 py-2"
|
||||
data-testid="open-settings"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-3"
|
||||
data-testid="sidebar-profile-card"
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<ProfileAvatar
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
className="h-10 w-10 rounded-2xl text-sm"
|
||||
iconClassName="h-5 w-5"
|
||||
label={resolvedDisplayName}
|
||||
testId="sidebar-profile-avatar"
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<ProfileAvatar
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
className="h-10 w-10 rounded-2xl text-sm"
|
||||
iconClassName="h-5 w-5"
|
||||
label={resolvedDisplayName}
|
||||
testId="sidebar-profile-avatar"
|
||||
/>
|
||||
<span
|
||||
aria-label={getPresenceLabel(selfPresenceStatus)}
|
||||
className="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-sidebar"
|
||||
data-testid="self-presence-badge"
|
||||
role="img"
|
||||
>
|
||||
<PresenceDot
|
||||
className="h-2.5 w-2.5"
|
||||
status={selfPresenceStatus}
|
||||
/>
|
||||
<span
|
||||
aria-label={getPresenceLabel(selfPresenceStatus)}
|
||||
className="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-sidebar"
|
||||
data-testid="self-presence-badge"
|
||||
role="img"
|
||||
>
|
||||
<PresenceDot
|
||||
className="h-2.5 w-2.5"
|
||||
status={selfPresenceStatus}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-current"
|
||||
data-testid="sidebar-profile-name"
|
||||
>
|
||||
{resolvedDisplayName}
|
||||
</p>
|
||||
{selfUserStatus?.text || selfUserStatus?.emoji ? (
|
||||
<p className="truncate text-xs text-sidebar-foreground/50">
|
||||
{selfUserStatus.emoji ? (
|
||||
<span className="mr-1">{selfUserStatus.emoji}</span>
|
||||
) : null}
|
||||
{selfUserStatus.text}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</ProfilePopover>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ProfilePopover
|
||||
open={profilePopoverOpen}
|
||||
onOpenChange={setProfilePopoverOpen}
|
||||
displayName={resolvedDisplayName}
|
||||
nip05={profile?.nip05Handle}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
currentStatus={selfPresenceStatus}
|
||||
isStatusPending={isPresencePending}
|
||||
userStatusText={selfUserStatus?.text}
|
||||
userStatusEmoji={selfUserStatus?.emoji}
|
||||
onSetStatus={onSetPresenceStatus ?? (() => {})}
|
||||
onSetUserStatus={onSetUserStatus}
|
||||
onClearUserStatus={onClearUserStatus}
|
||||
onOpenSettings={onSelectSettings}
|
||||
>
|
||||
<button
|
||||
className="block w-full min-w-0 text-left text-sidebar-foreground"
|
||||
data-testid="open-settings"
|
||||
type="button"
|
||||
>
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-current"
|
||||
data-testid="sidebar-profile-name"
|
||||
>
|
||||
{resolvedDisplayName}
|
||||
</p>
|
||||
</button>
|
||||
</ProfilePopover>
|
||||
<WorkspaceSwitcher
|
||||
activeWorkspace={activeWorkspace}
|
||||
onAddWorkspace={onOpenAddWorkspace}
|
||||
onRemoveWorkspace={onRemoveWorkspace}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
variant="profile"
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
{selfUserStatus?.text || selfUserStatus?.emoji ? (
|
||||
<p className="mt-0.5 truncate text-xs text-sidebar-foreground/50">
|
||||
{selfUserStatus.emoji ? (
|
||||
<span className="mr-1">{selfUserStatus.emoji}</span>
|
||||
) : null}
|
||||
{selfUserStatus.text}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
@@ -41,6 +41,7 @@ const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
|
||||
type WorkspaceSwitcherProps = {
|
||||
activeWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
variant?: "sidebar" | "profile";
|
||||
onSwitchWorkspace: (id: string) => void;
|
||||
onAddWorkspace: () => void;
|
||||
onUpdateWorkspace: (
|
||||
@@ -53,6 +54,7 @@ type WorkspaceSwitcherProps = {
|
||||
export function WorkspaceSwitcher({
|
||||
activeWorkspace,
|
||||
workspaces,
|
||||
variant = "sidebar",
|
||||
onSwitchWorkspace,
|
||||
onAddWorkspace,
|
||||
onUpdateWorkspace,
|
||||
@@ -65,102 +67,145 @@ export function WorkspaceSwitcher({
|
||||
const degraded = isRelayConnectionDegraded(connectionState);
|
||||
const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
|
||||
|
||||
const triggerContent = (
|
||||
<>
|
||||
{degraded ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-hidden="false"
|
||||
className={
|
||||
variant === "profile"
|
||||
? "flex h-5 w-5 shrink-0 animate-pulse items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-destructive"
|
||||
: "flex h-5 w-5 shrink-0 animate-pulse items-center justify-center text-destructive"
|
||||
}
|
||||
data-testid="relay-connection-warning"
|
||||
role="img"
|
||||
>
|
||||
<WifiOff
|
||||
className={variant === "profile" ? "h-3 w-3" : "h-4 w-4"}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={variant === "profile" ? "top" : "bottom"}>
|
||||
{connectionLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
variant === "profile"
|
||||
? "flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-[10px] leading-none"
|
||||
: "flex h-5 w-5 shrink-0 items-center justify-center text-xs leading-none"
|
||||
}
|
||||
>
|
||||
🌱
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
degraded
|
||||
? "min-w-0 flex-1 truncate font-medium text-destructive animate-pulse"
|
||||
: "min-w-0 flex-1 truncate font-medium"
|
||||
}
|
||||
>
|
||||
{activeWorkspace?.name ?? "No workspace"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={
|
||||
variant === "profile"
|
||||
? "h-3 w-3 shrink-0 text-sidebar-foreground/45"
|
||||
: "h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const switcherDropdown = (
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{variant === "profile" ? (
|
||||
<button
|
||||
aria-label={
|
||||
degraded
|
||||
? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}`
|
||||
: "Switch workspace"
|
||||
}
|
||||
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md py-0.5 text-left text-xs text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground data-[state=open]:text-sidebar-foreground"
|
||||
data-testid="workspace-switcher"
|
||||
type="button"
|
||||
>
|
||||
{triggerContent}
|
||||
</button>
|
||||
) : (
|
||||
<SidebarMenuButton
|
||||
aria-label={
|
||||
degraded
|
||||
? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}`
|
||||
: undefined
|
||||
}
|
||||
className="h-auto gap-2 rounded-xl px-2.5 py-2 data-[state=open]:bg-sidebar-accent"
|
||||
data-testid="workspace-switcher"
|
||||
type="button"
|
||||
>
|
||||
{triggerContent}
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-[220px]"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
side={variant === "profile" ? "top" : "bottom"}
|
||||
sideOffset={4}
|
||||
>
|
||||
{workspaces.map((workspace) => (
|
||||
<DropdownMenuItem
|
||||
key={workspace.id}
|
||||
className="group flex items-center gap-2 pr-1"
|
||||
onSelect={() => {
|
||||
onSwitchWorkspace(workspace.id);
|
||||
}}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{activeWorkspace?.id === workspace.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{workspace.name}</span>
|
||||
<button
|
||||
aria-label={`Edit ${workspace.name}`}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
setEditingWorkspace(workspace);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onAddWorkspace}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Workspace</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
aria-label={
|
||||
degraded
|
||||
? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}`
|
||||
: undefined
|
||||
}
|
||||
className="h-auto gap-2 rounded-xl px-2.5 py-2 data-[state=open]:bg-sidebar-accent"
|
||||
data-testid="workspace-switcher"
|
||||
type="button"
|
||||
>
|
||||
{degraded ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-hidden="false"
|
||||
className="flex h-5 w-5 shrink-0 animate-pulse items-center justify-center text-destructive"
|
||||
data-testid="relay-connection-warning"
|
||||
role="img"
|
||||
>
|
||||
<WifiOff className="h-4 w-4" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{connectionLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs leading-none">
|
||||
🌱
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
degraded
|
||||
? "min-w-0 flex-1 truncate text-sm font-medium text-destructive animate-pulse"
|
||||
: "min-w-0 flex-1 truncate text-sm font-medium"
|
||||
}
|
||||
>
|
||||
{activeWorkspace?.name ?? "No workspace"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-[220px]"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
>
|
||||
{workspaces.map((workspace) => (
|
||||
<DropdownMenuItem
|
||||
key={workspace.id}
|
||||
className="group flex items-center gap-2 pr-1"
|
||||
onSelect={() => {
|
||||
onSwitchWorkspace(workspace.id);
|
||||
}}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{activeWorkspace?.id === workspace.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{workspace.name}
|
||||
</span>
|
||||
<button
|
||||
aria-label={`Edit ${workspace.name}`}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
setEditingWorkspace(workspace);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onAddWorkspace}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Workspace</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
{variant === "profile" ? (
|
||||
switcherDropdown
|
||||
) : (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>{switcherDropdown}</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)}
|
||||
|
||||
<EditWorkspaceDialog
|
||||
canRemove={workspaces.length > 1}
|
||||
|
||||
Reference in New Issue
Block a user