mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
Fix desktop DM and sidebar UI polish (#908)
This commit is contained in:
@@ -2,6 +2,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
|||||||
|
|
||||||
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
|
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
|
||||||
import type { Channel, SearchHit } from "@/shared/api/types";
|
import type { Channel, SearchHit } from "@/shared/api/types";
|
||||||
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { SidebarTrigger, useSidebar } from "@/shared/ui/sidebar";
|
import { SidebarTrigger, useSidebar } from "@/shared/ui/sidebar";
|
||||||
|
|
||||||
@@ -30,6 +31,43 @@ function GlobalTopDivider() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CenterColumnTopbarSearch({
|
||||||
|
channels,
|
||||||
|
currentPubkey,
|
||||||
|
onOpenChannel,
|
||||||
|
onOpenResult,
|
||||||
|
searchFocusRequest,
|
||||||
|
}: Pick<
|
||||||
|
AppTopChromeProps,
|
||||||
|
| "channels"
|
||||||
|
| "currentPubkey"
|
||||||
|
| "onOpenChannel"
|
||||||
|
| "onOpenResult"
|
||||||
|
| "searchFocusRequest"
|
||||||
|
>) {
|
||||||
|
const { isResizing, state } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none fixed right-0 top-[7px] z-[45] flex justify-center px-24",
|
||||||
|
!isResizing && "transition-[left] duration-200 ease-linear",
|
||||||
|
)}
|
||||||
|
data-testid="topbar-search-column"
|
||||||
|
style={{ left: state === "expanded" ? "var(--sidebar-width)" : 0 }}
|
||||||
|
>
|
||||||
|
<TopbarSearch
|
||||||
|
channels={channels}
|
||||||
|
className="pointer-events-auto w-[220px] max-w-full md:w-[300px] lg:w-[360px] xl:w-[420px] 2xl:w-[480px]"
|
||||||
|
currentPubkey={currentPubkey}
|
||||||
|
focusRequest={searchFocusRequest}
|
||||||
|
onOpenChannel={onOpenChannel}
|
||||||
|
onOpenResult={onOpenResult}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const TOP_CHROME_ICON_BUTTON_CLASS =
|
const TOP_CHROME_ICON_BUTTON_CLASS =
|
||||||
"size-6 rounded-[4px] p-1 text-muted-foreground/70 hover:bg-border/45 hover:text-foreground";
|
"size-6 rounded-[4px] p-1 text-muted-foreground/70 hover:bg-border/45 hover:text-foreground";
|
||||||
|
|
||||||
@@ -79,13 +117,12 @@ export function AppTopChrome({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{searchHidden ? null : (
|
{searchHidden ? null : (
|
||||||
<TopbarSearch
|
<CenterColumnTopbarSearch
|
||||||
channels={channels}
|
channels={channels}
|
||||||
className="fixed left-1/2 top-[7px] z-[45] block w-[220px] max-w-[calc(100vw-11rem)] -translate-x-1/2 md:w-[300px] md:max-w-[34vw] lg:w-[360px] lg:max-w-[38vw] xl:w-[420px] xl:max-w-[42vw] 2xl:w-[480px] 2xl:max-w-[44vw]"
|
|
||||||
currentPubkey={currentPubkey}
|
currentPubkey={currentPubkey}
|
||||||
focusRequest={searchFocusRequest}
|
|
||||||
onOpenChannel={onOpenChannel}
|
onOpenChannel={onOpenChannel}
|
||||||
onOpenResult={onOpenResult}
|
onOpenResult={onOpenResult}
|
||||||
|
searchFocusRequest={searchFocusRequest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import { Button } from "@/shared/ui/button";
|
|||||||
import type { useChannelFind } from "@/features/search/useChannelFind";
|
import type { useChannelFind } from "@/features/search/useChannelFind";
|
||||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||||
import type { TimelineMessage } from "@/features/messages/types";
|
import type { TimelineMessage } from "@/features/messages/types";
|
||||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
import {
|
||||||
|
resolveUserLabel,
|
||||||
|
type UserProfileLookup,
|
||||||
|
} from "@/features/profile/lib/identity";
|
||||||
import type { Channel } from "@/shared/api/types";
|
import type { Channel } from "@/shared/api/types";
|
||||||
|
|
||||||
type ChannelPaneProps = {
|
type ChannelPaneProps = {
|
||||||
@@ -278,6 +281,43 @@ export const ChannelPane = React.memo(function ChannelPane({
|
|||||||
}, [botTypingEntries, openThreadHeadId]);
|
}, [botTypingEntries, openThreadHeadId]);
|
||||||
const hasThreadComposerBotActivity =
|
const hasThreadComposerBotActivity =
|
||||||
threadComposerBotTypingPubkeys.length > 0;
|
threadComposerBotTypingPubkeys.length > 0;
|
||||||
|
const directMessageIntro = React.useMemo(() => {
|
||||||
|
if (activeChannel?.channelType !== "dm") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = activeChannel.participantPubkeys.map(
|
||||||
|
(pubkey, index) => ({
|
||||||
|
fallbackName: activeChannel.participants[index] ?? null,
|
||||||
|
pubkey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const otherParticipants = currentPubkey
|
||||||
|
? participants.filter(
|
||||||
|
(participant) =>
|
||||||
|
participant.pubkey.toLowerCase() !== currentPubkey.toLowerCase(),
|
||||||
|
)
|
||||||
|
: participants;
|
||||||
|
const [participant] =
|
||||||
|
otherParticipants.length > 0 ? otherParticipants : participants;
|
||||||
|
|
||||||
|
if (!participant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = profiles?.[participant.pubkey.toLowerCase()] ?? null;
|
||||||
|
const displayName = resolveUserLabel({
|
||||||
|
currentPubkey,
|
||||||
|
fallbackName: participant.fallbackName,
|
||||||
|
profiles,
|
||||||
|
pubkey: participant.pubkey,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
avatarUrl: profile?.avatarUrl ?? null,
|
||||||
|
displayName,
|
||||||
|
};
|
||||||
|
}, [activeChannel, currentPubkey, profiles]);
|
||||||
|
|
||||||
const selectedAgent = React.useMemo(
|
const selectedAgent = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -305,6 +345,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
|||||||
) : null}
|
) : null}
|
||||||
<MessageTimeline
|
<MessageTimeline
|
||||||
channelId={activeChannel?.id}
|
channelId={activeChannel?.id}
|
||||||
|
directMessageIntro={directMessageIntro}
|
||||||
scrollContainerRef={timelineScrollRef}
|
scrollContainerRef={timelineScrollRef}
|
||||||
currentPubkey={currentPubkey}
|
currentPubkey={currentPubkey}
|
||||||
fetchOlder={fetchOlder}
|
fetchOlder={fetchOlder}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export function ChannelScreen({
|
|||||||
}, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
|
}, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
|
||||||
const {
|
const {
|
||||||
activeChannelTitle,
|
activeChannelTitle,
|
||||||
|
activeDmAvatarUrl,
|
||||||
activeDmPresenceStatus,
|
activeDmPresenceStatus,
|
||||||
activeChannelEphemeralDisplay,
|
activeChannelEphemeralDisplay,
|
||||||
} = useActiveChannelHeader(activeChannel, currentPubkey);
|
} = useActiveChannelHeader(activeChannel, currentPubkey);
|
||||||
@@ -164,14 +165,22 @@ export function ChannelScreen({
|
|||||||
currentPubkey,
|
currentPubkey,
|
||||||
latestMessageEvent,
|
latestMessageEvent,
|
||||||
);
|
);
|
||||||
|
const activeDmParticipantPubkeys = React.useMemo(
|
||||||
|
() =>
|
||||||
|
activeChannel?.channelType === "dm"
|
||||||
|
? activeChannel.participantPubkeys
|
||||||
|
: [],
|
||||||
|
[activeChannel],
|
||||||
|
);
|
||||||
const messageProfilePubkeys = React.useMemo(
|
const messageProfilePubkeys = React.useMemo(
|
||||||
() => [
|
() => [
|
||||||
...new Set([
|
...new Set([
|
||||||
...messageAuthorPubkeys,
|
...messageAuthorPubkeys,
|
||||||
|
...activeDmParticipantPubkeys,
|
||||||
...typingEntries.map((entry) => entry.pubkey),
|
...typingEntries.map((entry) => entry.pubkey),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
[messageAuthorPubkeys, typingEntries],
|
[activeDmParticipantPubkeys, messageAuthorPubkeys, typingEntries],
|
||||||
);
|
);
|
||||||
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
|
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
|
||||||
enabled: messageProfilePubkeys.length > 0,
|
enabled: messageProfilePubkeys.length > 0,
|
||||||
@@ -473,6 +482,7 @@ export function ChannelScreen({
|
|||||||
activeChannelTitle={activeChannelTitle}
|
activeChannelTitle={activeChannelTitle}
|
||||||
actionsRightInset={headerActionsRightInset}
|
actionsRightInset={headerActionsRightInset}
|
||||||
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
|
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
|
||||||
|
activeDmAvatarUrl={activeDmAvatarUrl}
|
||||||
activeDmPresenceStatus={activeDmPresenceStatus}
|
activeDmPresenceStatus={activeDmPresenceStatus}
|
||||||
currentPubkey={currentPubkey}
|
currentPubkey={currentPubkey}
|
||||||
isJoining={joinChannelMutation.isPending}
|
isJoining={joinChannelMutation.isPending}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralC
|
|||||||
import { getChannelDescription } from "@/features/channels/lib/channelDescription";
|
import { getChannelDescription } from "@/features/channels/lib/channelDescription";
|
||||||
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
|
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
|
||||||
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
|
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
|
||||||
|
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import type { Channel, PresenceStatus } from "@/shared/api/types";
|
import type { Channel, PresenceStatus } from "@/shared/api/types";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ type ChannelScreenHeaderProps = {
|
|||||||
activeChannelTitle: string;
|
activeChannelTitle: string;
|
||||||
actionsRightInset?: string;
|
actionsRightInset?: string;
|
||||||
actionsVariant?: "inline" | "compact";
|
actionsVariant?: "inline" | "compact";
|
||||||
|
activeDmAvatarUrl: string | null;
|
||||||
activeDmPresenceStatus: PresenceStatus | null;
|
activeDmPresenceStatus: PresenceStatus | null;
|
||||||
currentPubkey?: string;
|
currentPubkey?: string;
|
||||||
isJoining?: boolean;
|
isJoining?: boolean;
|
||||||
@@ -29,6 +31,7 @@ export function ChannelScreenHeader({
|
|||||||
activeChannelTitle,
|
activeChannelTitle,
|
||||||
actionsRightInset,
|
actionsRightInset,
|
||||||
actionsVariant = "inline",
|
actionsVariant = "inline",
|
||||||
|
activeDmAvatarUrl,
|
||||||
activeDmPresenceStatus,
|
activeDmPresenceStatus,
|
||||||
currentPubkey,
|
currentPubkey,
|
||||||
isJoining = false,
|
isJoining = false,
|
||||||
@@ -78,6 +81,17 @@ export function ChannelScreenHeader({
|
|||||||
actionsRightInset={actionsRightInset}
|
actionsRightInset={actionsRightInset}
|
||||||
channelType={activeChannel?.channelType}
|
channelType={activeChannel?.channelType}
|
||||||
description={getChannelDescription(activeChannel)}
|
description={getChannelDescription(activeChannel)}
|
||||||
|
leadingContent={
|
||||||
|
activeChannel?.channelType === "dm" ? (
|
||||||
|
<ProfileAvatar
|
||||||
|
avatarUrl={activeDmAvatarUrl}
|
||||||
|
className="h-6 w-6 rounded-md text-[10px]"
|
||||||
|
iconClassName="h-3.5 w-3.5"
|
||||||
|
label={activeChannelTitle}
|
||||||
|
testId="chat-header-dm-avatar"
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
statusBadge={
|
statusBadge={
|
||||||
<ChannelHeaderStatusBadge
|
<ChannelHeaderStatusBadge
|
||||||
channelType={activeChannel?.channelType}
|
channelType={activeChannel?.channelType}
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ export function useActiveChannelHeader(
|
|||||||
activeDmParticipantPubkeys[0]?.toLowerCase()
|
activeDmParticipantPubkeys[0]?.toLowerCase()
|
||||||
] ?? null)
|
] ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
const activeDmAvatarUrl =
|
||||||
|
activeDmParticipantPubkeys.length > 0
|
||||||
|
? (activeDmProfilesQuery.data?.profiles?.[
|
||||||
|
activeDmParticipantPubkeys[0]?.toLowerCase()
|
||||||
|
]?.avatarUrl ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeChannelTitle: activeChannel
|
activeChannelTitle: activeChannel
|
||||||
@@ -42,6 +48,7 @@ export function useActiveChannelHeader(
|
|||||||
activeDmProfilesQuery.data?.profiles,
|
activeDmProfilesQuery.data?.profiles,
|
||||||
)
|
)
|
||||||
: "Channels",
|
: "Channels",
|
||||||
|
activeDmAvatarUrl,
|
||||||
activeDmPresenceStatus,
|
activeDmPresenceStatus,
|
||||||
activeChannelEphemeralDisplay,
|
activeChannelEphemeralDisplay,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type ChatHeaderProps = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
channelType?: ChannelType;
|
channelType?: ChannelType;
|
||||||
visibility?: ChannelVisibility;
|
visibility?: ChannelVisibility;
|
||||||
|
leadingContent?: React.ReactNode;
|
||||||
mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects";
|
mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects";
|
||||||
overlaysContent?: boolean;
|
overlaysContent?: boolean;
|
||||||
statusBadge?: React.ReactNode;
|
statusBadge?: React.ReactNode;
|
||||||
@@ -88,6 +89,7 @@ export function ChatHeader({
|
|||||||
description,
|
description,
|
||||||
channelType,
|
channelType,
|
||||||
visibility,
|
visibility,
|
||||||
|
leadingContent,
|
||||||
mode = "channel",
|
mode = "channel",
|
||||||
overlaysContent = false,
|
overlaysContent = false,
|
||||||
statusBadge,
|
statusBadge,
|
||||||
@@ -103,8 +105,10 @@ export function ChatHeader({
|
|||||||
const header = (
|
const header = (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto relative z-30 flex min-w-0 shrink-0 cursor-default select-none items-center gap-[10px] bg-transparent pl-[16px] pr-[8px] transition-[margin,padding] duration-200 ease-linear sm:pl-[24px] sm:pr-[12px]",
|
"pointer-events-auto relative z-30 flex min-w-0 shrink-0 cursor-default select-none items-center gap-[10px] bg-transparent pl-[16px] pr-[8px] transition-[margin,padding] duration-200 ease-linear sm:pr-[12px]",
|
||||||
density === "compact" ? "h-[32px] py-0" : "min-h-[44px] py-[6px]",
|
density === "compact"
|
||||||
|
? "h-[32px] py-0"
|
||||||
|
: "min-h-[44px] py-[6px] sm:pl-[24px]",
|
||||||
overlaysContent && !belowSystemChrome && "-mb-[44px]",
|
overlaysContent && !belowSystemChrome && "-mb-[44px]",
|
||||||
)}
|
)}
|
||||||
data-testid="chat-header"
|
data-testid="chat-header"
|
||||||
@@ -112,11 +116,13 @@ export function ChatHeader({
|
|||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-[4px]">
|
<div className="flex min-w-0 flex-wrap items-center gap-[4px]">
|
||||||
<ChannelIcon
|
{leadingContent ?? (
|
||||||
channelType={channelType}
|
<ChannelIcon
|
||||||
mode={mode}
|
channelType={channelType}
|
||||||
visibility={visibility}
|
mode={mode}
|
||||||
/>
|
visibility={visibility}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<h1
|
<h1
|
||||||
className="min-w-0 translate-y-px truncate text-base font-semibold leading-6 tracking-tight"
|
className="min-w-0 translate-y-px truncate text-base font-semibold leading-6 tracking-tight"
|
||||||
data-testid="chat-title"
|
data-testid="chat-title"
|
||||||
|
|||||||
@@ -141,15 +141,15 @@ export function CustomEmojiSettingsCard() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SettingsOptionGroup>
|
<SettingsOptionGroup>
|
||||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 text-sm">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-[1_1_22rem]">
|
||||||
<h4 className="text-sm font-medium">Upload an image</h4>
|
<h4 className="text-sm font-medium">Upload an image</h4>
|
||||||
<p className="text-sm font-normal text-muted-foreground">
|
<p className="text-sm font-normal text-muted-foreground">
|
||||||
Square images work best. GIF, PNG, JPEG, and WebP files are
|
Square images work best. GIF, PNG, JPEG, and WebP files are
|
||||||
supported.
|
supported.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 flex-[1_1_16rem] items-center gap-3">
|
||||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-md border bg-background">
|
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-md border bg-background">
|
||||||
{pendingUpload ? (
|
{pendingUpload ? (
|
||||||
<img
|
<img
|
||||||
@@ -163,9 +163,11 @@ export function CustomEmojiSettingsCard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<p className="max-w-48 truncate text-sm font-normal text-muted-foreground">
|
{pendingUpload?.filename ? (
|
||||||
{pendingUpload?.filename ?? "No image selected"}
|
<p className="max-w-full truncate text-sm font-normal text-muted-foreground">
|
||||||
</p>
|
{pendingUpload.filename}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="custom-emoji-upload"
|
data-testid="custom-emoji-upload"
|
||||||
@@ -183,15 +185,15 @@ export function CustomEmojiSettingsCard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3 px-4 py-3 text-sm">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-[1_1_22rem]">
|
||||||
<h4 className="text-sm font-medium">Give it a name</h4>
|
<h4 className="text-sm font-medium">Give it a name</h4>
|
||||||
<p className="text-sm font-normal text-muted-foreground">
|
<p className="text-sm font-normal text-muted-foreground">
|
||||||
This is what you’ll type to add this emoji to messages and
|
This is what you’ll type to add this emoji to messages and
|
||||||
reactions.
|
reactions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full min-w-0 max-w-sm space-y-2">
|
<div className="w-full min-w-0 max-w-sm flex-[1_1_20rem] space-y-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||||
:
|
:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export function HomeLoadingState() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<div className="grid h-full min-h-0 w-full lg:grid-cols-[320px_minmax(0,1fr)]">
|
<div className="grid h-full min-h-0 w-full lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
<div className="overflow-hidden border-r border-border/70 bg-background/60">
|
<div className="relative overflow-hidden bg-background/60 after:absolute after:bottom-0 after:right-0 after:top-10 after:w-px after:bg-border/70 after:content-['']">
|
||||||
<div className="border-b border-border/70 px-4 pb-4 pt-14">
|
<div className="border-b border-border/70 px-4 pb-4 pt-14">
|
||||||
<Skeleton className="h-4 w-20" />
|
<Skeleton className="h-4 w-20" />
|
||||||
<Skeleton className="mt-2 h-4 w-28" />
|
<Skeleton className="mt-2 h-4 w-28" />
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function InboxListPane({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60",
|
"relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60",
|
||||||
showRightDivider &&
|
showRightDivider &&
|
||||||
"after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-40 after:w-px after:bg-border/35 after:content-['']",
|
"after:pointer-events-none after:absolute after:bottom-0 after:right-0 after:top-10 after:z-40 after:w-px after:bg-border/35 after:content-['']",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TopChromeBackdrop className="h-[76px]" />
|
<TopChromeBackdrop className="h-[76px]" />
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { ArrowDown } from "lucide-react";
|
|||||||
|
|
||||||
import type { TimelineMessage } from "@/features/messages/types";
|
import type { TimelineMessage } from "@/features/messages/types";
|
||||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||||
|
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||||
|
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Separator } from "@/shared/ui/separator";
|
|
||||||
import { Spinner } from "@/shared/ui/spinner";
|
import { Spinner } from "@/shared/ui/spinner";
|
||||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||||
import { TimelineSkeleton } from "./TimelineSkeleton";
|
import { TimelineSkeleton } from "./TimelineSkeleton";
|
||||||
@@ -16,6 +17,10 @@ import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
|||||||
type MessageTimelineProps = {
|
type MessageTimelineProps = {
|
||||||
channelId?: string | null;
|
channelId?: string | null;
|
||||||
messages: TimelineMessage[];
|
messages: TimelineMessage[];
|
||||||
|
directMessageIntro?: {
|
||||||
|
avatarUrl: string | null;
|
||||||
|
displayName: string;
|
||||||
|
} | null;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
emptyTitle?: string;
|
emptyTitle?: string;
|
||||||
emptyDescription?: string;
|
emptyDescription?: string;
|
||||||
@@ -56,6 +61,7 @@ type MessageTimelineProps = {
|
|||||||
|
|
||||||
export const MessageTimeline = React.memo(function MessageTimeline({
|
export const MessageTimeline = React.memo(function MessageTimeline({
|
||||||
channelId,
|
channelId,
|
||||||
|
directMessageIntro = null,
|
||||||
messages,
|
messages,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
emptyTitle = "No messages yet",
|
emptyTitle = "No messages yet",
|
||||||
@@ -141,6 +147,16 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
|||||||
sentinelRef: topSentinelRef,
|
sentinelRef: topSentinelRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasConversationMessage = messages.some(
|
||||||
|
(message) => message.kind !== KIND_SYSTEM_MESSAGE,
|
||||||
|
);
|
||||||
|
const showDirectMessageIntro =
|
||||||
|
!isLoading && directMessageIntro !== null && !hasConversationMessage;
|
||||||
|
const showGenericEmpty =
|
||||||
|
!isLoading && messages.length === 0 && directMessageIntro === null;
|
||||||
|
const showMessageList =
|
||||||
|
!isLoading && messages.length > 0 && !showDirectMessageIntro;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
@@ -155,7 +171,10 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
|||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex w-full flex-col gap-2 pt-[92px]"
|
className={cn(
|
||||||
|
"flex w-full flex-col gap-2 pt-[92px]",
|
||||||
|
(showDirectMessageIntro || showGenericEmpty) && "min-h-full",
|
||||||
|
)}
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
>
|
>
|
||||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||||
@@ -166,22 +185,34 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!hasOlderMessages && !isLoading && messages.length > 0 ? (
|
{isLoading ? <TimelineSkeleton /> : null}
|
||||||
|
|
||||||
|
{showDirectMessageIntro ? (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 py-2"
|
className="mb-10 mt-auto flex w-full flex-col items-start text-left sm:-ml-2"
|
||||||
data-testid="message-timeline-beginning"
|
data-testid="message-dm-intro"
|
||||||
>
|
>
|
||||||
<Separator className="flex-1" />
|
<ProfileAvatar
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground/75">
|
avatarUrl={directMessageIntro.avatarUrl}
|
||||||
Beginning of conversation
|
className="h-[60px] w-[60px] text-base"
|
||||||
|
iconClassName="h-6 w-6"
|
||||||
|
label={directMessageIntro.displayName}
|
||||||
|
testId="message-dm-intro-avatar"
|
||||||
|
/>
|
||||||
|
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||||
|
{directMessageIntro.displayName}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
|
||||||
|
This is the beginning of your direct message with{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{directMessageIntro.displayName}
|
||||||
|
</span>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
<Separator className="flex-1" />
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isLoading ? <TimelineSkeleton /> : null}
|
{showGenericEmpty ? (
|
||||||
|
|
||||||
{!isLoading && messages.length === 0 ? (
|
|
||||||
<div
|
<div
|
||||||
className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
|
className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
|
||||||
data-testid="message-empty"
|
data-testid="message-empty"
|
||||||
@@ -195,7 +226,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isLoading && messages.length > 0 ? (
|
{showMessageList ? (
|
||||||
<TimelineMessageList
|
<TimelineMessageList
|
||||||
channelId={channelId}
|
channelId={channelId}
|
||||||
currentPubkey={currentPubkey}
|
currentPubkey={currentPubkey}
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ import { SidebarSection } from "@/features/sidebar/ui/SidebarSection";
|
|||||||
import {
|
import {
|
||||||
ChannelGroupSection,
|
ChannelGroupSection,
|
||||||
CustomChannelSection,
|
CustomChannelSection,
|
||||||
SECTION_ACTION_VISIBILITY_CLASS,
|
|
||||||
} from "@/features/sidebar/ui/CustomChannelSection";
|
} from "@/features/sidebar/ui/CustomChannelSection";
|
||||||
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
|
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
|
||||||
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
|
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
|
||||||
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
|
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
|
||||||
|
import { SECTION_ACTION_VISIBILITY_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles";
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
ChannelVisibility,
|
ChannelVisibility,
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ import {
|
|||||||
DroppableUngroupedBody,
|
DroppableUngroupedBody,
|
||||||
SortableSectionShell,
|
SortableSectionShell,
|
||||||
} from "@/features/sidebar/ui/SidebarDnd";
|
} from "@/features/sidebar/ui/SidebarDnd";
|
||||||
|
import {
|
||||||
|
SECTION_ACTION_VISIBILITY_CLASS,
|
||||||
|
SECTION_ICON_BUTTON_CLASS,
|
||||||
|
} from "@/features/sidebar/ui/sidebarSectionStyles";
|
||||||
import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
|
import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
|
||||||
import type { Channel } from "@/shared/api/types";
|
import type { Channel } from "@/shared/api/types";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
@@ -49,10 +53,6 @@ import { cn } from "@/shared/lib/cn";
|
|||||||
// Shared styles
|
// Shared styles
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const SECTION_ICON_BUTTON_CLASS =
|
|
||||||
"flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground/50 transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring [&>svg]:size-4 [&>svg]:shrink-0";
|
|
||||||
export const SECTION_ACTION_VISIBILITY_CLASS =
|
|
||||||
"opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100";
|
|
||||||
const SECTION_LABEL_BUTTON_CLASS =
|
const SECTION_LABEL_BUTTON_CLASS =
|
||||||
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
|
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
|
||||||
const SECTION_LABEL_CHEVRON_CLASS =
|
const SECTION_LABEL_CHEVRON_CLASS =
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuAction,
|
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/shared/ui/sidebar";
|
} from "@/shared/ui/sidebar";
|
||||||
@@ -37,6 +36,10 @@ const SECTION_LABEL_BUTTON_CLASS =
|
|||||||
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
|
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
|
||||||
const SECTION_LABEL_CHEVRON_CLASS =
|
const SECTION_LABEL_CHEVRON_CLASS =
|
||||||
"h-2.5 w-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity,transform] group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground";
|
"h-2.5 w-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity,transform] group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground";
|
||||||
|
const SIDEBAR_ROW_ACTION_VISIBILITY_CLASS =
|
||||||
|
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 md:opacity-0";
|
||||||
|
const SIDEBAR_ROW_ICON_ACTION_CLASS =
|
||||||
|
"flex size-6 items-center justify-center p-1 text-sidebar-foreground/45 transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring peer-data-[active=true]/menu-button:text-sidebar-active-foreground/75 peer-data-[active=true]/menu-button:hover:text-sidebar-active-foreground [&>svg]:size-4 [&>svg]:shrink-0";
|
||||||
|
|
||||||
export type SidebarDmParticipant = {
|
export type SidebarDmParticipant = {
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
@@ -168,6 +171,9 @@ export function ChannelMenuButton({
|
|||||||
return (
|
return (
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
className={cn(
|
className={cn(
|
||||||
|
isActive
|
||||||
|
? "group-hover/menu-item:bg-sidebar-active group-hover/menu-item:text-sidebar-active-foreground"
|
||||||
|
: "group-hover/menu-item:bg-sidebar-accent group-hover/menu-item:text-sidebar-accent-foreground",
|
||||||
!isActive &&
|
!isActive &&
|
||||||
hasUnread &&
|
hasUnread &&
|
||||||
"font-semibold text-sidebar-foreground hover:text-sidebar-foreground",
|
"font-semibold text-sidebar-foreground hover:text-sidebar-foreground",
|
||||||
@@ -326,15 +332,23 @@ export function SidebarSection({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{channel.channelType === "dm" && onHideDm ? (
|
{channel.channelType === "dm" && onHideDm ? (
|
||||||
<SidebarMenuAction
|
<button
|
||||||
aria-label="Close direct message"
|
aria-label="Close direct message"
|
||||||
className="right-0 top-1/2 h-8 w-8 -translate-y-1/2 rounded-lg border border-border/40 [&>svg]:size-5 peer-data-[size=default]/menu-button:top-1/2 peer-data-[size=lg]/menu-button:top-1/2 peer-data-[size=sm]/menu-button:top-1/2"
|
className={cn(
|
||||||
|
"absolute right-1 top-1/2 z-10 -translate-y-1/2 after:absolute after:-inset-2 after:md:hidden group-data-[collapsible=icon]:hidden",
|
||||||
|
SIDEBAR_ROW_ICON_ACTION_CLASS,
|
||||||
|
SIDEBAR_ROW_ACTION_VISIBILITY_CLASS,
|
||||||
|
)}
|
||||||
|
data-sidebar="menu-action"
|
||||||
data-testid={`hide-dm-${channel.name}`}
|
data-testid={`hide-dm-${channel.name}`}
|
||||||
onClick={() => onHideDm(channel.id)}
|
onClick={(event) => {
|
||||||
showOnHover
|
event.stopPropagation();
|
||||||
|
onHideDm(channel.id);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<X />
|
<X />
|
||||||
</SidebarMenuAction>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
5
desktop/src/features/sidebar/ui/sidebarSectionStyles.ts
Normal file
5
desktop/src/features/sidebar/ui/sidebarSectionStyles.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const SECTION_ICON_BUTTON_CLASS =
|
||||||
|
"flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground/50 transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring [&>svg]:size-4 [&>svg]:shrink-0";
|
||||||
|
|
||||||
|
export const SECTION_ACTION_VISIBILITY_CLASS =
|
||||||
|
"opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100";
|
||||||
Reference in New Issue
Block a user