Improve desktop DM sidebar UX (#109)

This commit is contained in:
Wes
2026-03-18 16:01:58 -07:00
committed by GitHub
parent 334eee9c67
commit 4b7db1a9d0
15 changed files with 899 additions and 134 deletions

View File

@@ -0,0 +1,22 @@
use reqwest::Method;
use tauri::State;
use crate::{
app_state::AppState,
models::{ChannelInfo, OpenDmBody, OpenDmResponse},
relay::{build_authed_request, send_json_request},
};
#[tauri::command]
pub async fn open_dm(
pubkeys: Vec<String>,
state: State<'_, AppState>,
) -> Result<ChannelInfo, String> {
let request = build_authed_request(&state.http_client, Method::POST, "/api/dms", &state)?
.json(&OpenDmBody { pubkeys: &pubkeys });
let response: OpenDmResponse = send_json_request(request).await?;
let path = format!("/api/channels/{}", response.channel_id);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}

View File

@@ -2,6 +2,7 @@ mod agent_models;
mod agent_settings;
mod agents;
mod channels;
mod dms;
mod identity;
mod media;
mod messages;
@@ -12,6 +13,7 @@ pub use agent_models::*;
pub use agent_settings::*;
pub use agents::*;
pub use channels::*;
pub use dms::*;
pub use identity::*;
pub use media::*;
pub use messages::*;

View File

@@ -119,6 +119,7 @@ pub fn run() {
create_auth_event,
get_channels,
create_channel,
open_dm,
get_channel_details,
get_channel_members,
update_channel,

View File

@@ -122,6 +122,18 @@ pub struct CreateChannelBody<'a> {
pub description: Option<&'a str>,
}
#[derive(Serialize)]
pub struct OpenDmBody<'a> {
pub pubkeys: &'a [String],
}
#[derive(Serialize, Deserialize)]
pub struct OpenDmResponse {
pub channel_id: String,
pub created: bool,
pub participants: Vec<String>,
}
#[derive(Serialize)]
pub struct UpdateChannelBody<'a> {
#[serde(skip_serializing_if = "Option::is_none")]

View File

@@ -2,12 +2,14 @@ import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ChannelPane } from "@/app/ChannelPane";
import { useActiveChannelHeader } from "@/app/useActiveChannelHeader";
import { AgentsView } from "@/features/agents/ui/AgentsView";
import { ForumView } from "@/features/forum/ui/ForumView";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import {
channelsQueryKey,
useCreateChannelMutation,
useOpenDmMutation,
useChannelsQuery,
useSelectedChannel,
} from "@/features/channels/hooks";
@@ -31,10 +33,7 @@ import {
getChannelIdFromTags,
getThreadReference,
} from "@/features/messages/lib/threading";
import {
usePresenceQuery,
usePresenceSession,
} from "@/features/presence/hooks";
import { usePresenceSession } from "@/features/presence/hooks";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { useHomeFeedNotifications } from "@/features/notifications/hooks";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
@@ -45,7 +44,6 @@ import {
SettingsView,
type SettingsSection,
} from "@/features/settings/ui/SettingsView";
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { relayClient } from "@/shared/api/relayClient";
import { getEventById, joinChannel } from "@/shared/api/tauri";
@@ -101,6 +99,7 @@ export function AppShell() {
);
const createChannelMutation = useCreateChannelMutation();
const createForumMutation = useCreateChannelMutation();
const openDmMutation = useOpenDmMutation();
const activeChannel = selectedView === "channel" ? selectedChannel : null;
const activeChannelId = activeChannel?.id ?? null;
const messagesQuery = useChannelMessagesQuery(activeChannel);
@@ -115,34 +114,10 @@ export function AppShell() {
activeChannel,
activeReadAt,
);
const activeDmParticipantPubkeys = React.useMemo(() => {
if (!activeChannel || activeChannel.channelType !== "dm") {
return [];
}
const currentPubkey = identityQuery.data?.pubkey?.toLowerCase();
return activeChannel.participantPubkeys.filter(
(pubkey) => pubkey.toLowerCase() !== currentPubkey,
);
}, [activeChannel, identityQuery.data?.pubkey]);
const activeDmPresenceQuery = usePresenceQuery(activeDmParticipantPubkeys, {
enabled: activeDmParticipantPubkeys.length > 0,
});
const activeDmProfilesQuery = useUsersBatchQuery(activeDmParticipantPubkeys, {
enabled: activeDmParticipantPubkeys.length > 0,
});
const activeDmPresenceStatus =
activeDmParticipantPubkeys.length > 0
? activeDmPresenceQuery.data?.[
activeDmParticipantPubkeys[0]?.toLowerCase()
]
: null;
const activeChannelTitle = activeChannel
? resolveChannelDisplayLabel(
activeChannel,
identityQuery.data?.pubkey,
activeDmProfilesQuery.data?.profiles,
)
: "Channels";
const { activeChannelTitle, activeDmPresenceStatus } = useActiveChannelHeader(
activeChannel,
identityQuery.data?.pubkey,
);
const sendMessageMutation = useSendMessageMutation(
activeChannel,
identityQuery.data,
@@ -268,6 +243,15 @@ export function AppShell() {
},
[channels, queryClient, refetchChannels],
);
const openChannelView = React.useCallback(
(channelId: string) => {
React.startTransition(() => {
setSelectedChannelId(channelId);
setSelectedView("channel");
});
},
[setSelectedChannelId],
);
const handleOpenChannel = React.useCallback(
async (channelId: string) => {
@@ -278,15 +262,12 @@ export function AppShell() {
return;
}
React.startTransition(() => {
setSelectedChannelId(channel.id);
setSelectedView("channel");
});
openChannelView(channel.id);
} catch (error) {
console.error("Failed to open channel", channelId, error);
}
},
[resolveChannel, setSelectedChannelId],
[openChannelView, resolveChannel],
);
const handleBrowseChannelJoin = React.useCallback(
@@ -534,6 +515,7 @@ export function AppShell() {
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({ description, name }) => {
const createdChannel = await createChannelMutation.mutateAsync({
@@ -543,10 +525,7 @@ export function AppShell() {
visibility: "open",
});
React.startTransition(() => {
setSelectedChannelId(createdChannel.id);
setSelectedView("channel");
});
openChannelView(createdChannel.id);
}}
onCreateForum={async ({ description, name }) => {
const createdForum = await createForumMutation.mutateAsync({
@@ -556,10 +535,7 @@ export function AppShell() {
visibility: "open",
});
React.startTransition(() => {
setSelectedChannelId(createdForum.id);
setSelectedView("channel");
});
openChannelView(createdForum.id);
}}
onOpenBrowseChannels={() => {
setIsBrowseChannelsOpen(true);
@@ -569,6 +545,12 @@ export function AppShell() {
setIsSearchOpen(true);
void refetchChannels();
}}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
openChannelView(directMessage.id);
}}
onSelectAgents={() => {
React.startTransition(() => {
setSelectedView("agents");

View File

@@ -0,0 +1,44 @@
import * as React from "react";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
import type { Channel, PresenceStatus } from "@/shared/api/types";
export function useActiveChannelHeader(
activeChannel: Channel | null,
currentPubkey?: string,
) {
const activeDmParticipantPubkeys = React.useMemo(() => {
if (!activeChannel || activeChannel.channelType !== "dm") {
return [];
}
return activeChannel.participantPubkeys.filter(
(pubkey) => pubkey.toLowerCase() !== currentPubkey?.toLowerCase(),
);
}, [activeChannel, currentPubkey]);
const activeDmPresenceQuery = usePresenceQuery(activeDmParticipantPubkeys, {
enabled: activeDmParticipantPubkeys.length > 0,
});
const activeDmProfilesQuery = useUsersBatchQuery(activeDmParticipantPubkeys, {
enabled: activeDmParticipantPubkeys.length > 0,
});
const activeDmPresenceStatus: PresenceStatus | null =
activeDmParticipantPubkeys.length > 0
? (activeDmPresenceQuery.data?.[
activeDmParticipantPubkeys[0]?.toLowerCase()
] ?? null)
: null;
return {
activeChannelTitle: activeChannel
? resolveChannelDisplayLabel(
activeChannel,
currentPubkey,
activeDmProfilesQuery.data?.profiles,
)
: "Channels",
activeDmPresenceStatus,
};
}

View File

@@ -16,6 +16,7 @@ import {
getChannels,
joinChannel,
leaveChannel,
openDm,
removeChannelMember,
setChannelPurpose,
setChannelTopic,
@@ -27,6 +28,7 @@ import type {
Channel,
ChannelDetail,
CreateChannelInput,
OpenDmInput,
SetChannelPurposeInput,
SetChannelTopicInput,
UpdateChannelInput,
@@ -172,6 +174,25 @@ export function useCreateChannelMutation() {
});
}
export function useOpenDmMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: OpenDmInput) => openDm(input),
onSuccess: (openedChannel) => {
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current = []) =>
sortChannels([
...current.filter((channel) => channel.id !== openedChannel.id),
openedChannel,
]),
);
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
},
});
}
export function useChannelDetailsQuery(
channelId: string | null,
enabled = true,

View File

@@ -1,18 +1,17 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Bot, Compass, Home, Plus, Search } from "lucide-react";
import { Bot, Compass, Home, PenSquare, Plus, Search } from "lucide-react";
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import { getPresenceLabel } from "@/features/presence/lib/presence";
import { usePresenceQuery } from "@/features/presence/hooks";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
import { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata";
import {
ChannelMenuButton,
SidebarSection,
} from "@/features/sidebar/ui/SidebarSection";
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
import type { Channel, PresenceStatus, Profile } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
@@ -41,6 +40,7 @@ type AppSidebarProps = {
isLoading: boolean;
isCreatingChannel: boolean;
isCreatingForum: boolean;
isOpeningDm: boolean;
profile?: Profile;
selfPresenceStatus: PresenceStatus;
errorMessage?: string;
@@ -57,6 +57,7 @@ type AppSidebarProps = {
}) => Promise<void>;
onOpenBrowseChannels: () => void;
onOpenSearch: () => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onSelectAgents: () => void;
onSelectHome: () => void;
onSelectChannel: (channelId: string) => void;
@@ -321,6 +322,7 @@ export function AppSidebar({
isLoading,
isCreatingChannel,
isCreatingForum,
isOpeningDm,
profile,
selfPresenceStatus,
errorMessage,
@@ -331,6 +333,7 @@ export function AppSidebar({
onCreateForum,
onOpenBrowseChannels,
onOpenSearch,
onOpenDm,
onSelectAgents,
onSelectHome,
onSelectChannel,
@@ -339,6 +342,7 @@ export function AppSidebar({
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [isForumCreateOpen, setIsForumCreateOpen] = React.useState(false);
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
const [draftName, setDraftName] = React.useState("");
const [draftDescription, setDraftDescription] = React.useState("");
const [forumDraftName, setForumDraftName] = React.useState("");
@@ -360,54 +364,13 @@ export function AppSidebar({
const directMessages = channels.filter(
(channel) => channel.channelType === "dm",
);
const dmParticipantPubkeys = React.useMemo(
() =>
directMessages
.flatMap((channel) => channel.participantPubkeys)
.filter(
(pubkey) => pubkey.toLowerCase() !== currentPubkey?.toLowerCase(),
),
[currentPubkey, directMessages],
);
const dmPresenceQuery = usePresenceQuery(dmParticipantPubkeys, {
enabled: directMessages.length > 0,
});
const dmProfilesQuery = useUsersBatchQuery(dmParticipantPubkeys, {
enabled: directMessages.length > 0,
});
const dmPresenceByChannelId = React.useMemo(
() =>
Object.fromEntries(
directMessages.map((channel) => {
const otherParticipantPubkey = channel.participantPubkeys.find(
(pubkey) => pubkey.toLowerCase() !== currentPubkey?.toLowerCase(),
);
return [
channel.id,
otherParticipantPubkey
? (dmPresenceQuery.data?.[otherParticipantPubkey.toLowerCase()] ??
"offline")
: "offline",
];
}),
) satisfies Record<string, PresenceStatus>,
[currentPubkey, directMessages, dmPresenceQuery.data],
);
const dmChannelLabels = React.useMemo(
() =>
Object.fromEntries(
directMessages.map((channel) => [
channel.id,
resolveChannelDisplayLabel(
channel,
currentPubkey,
dmProfilesQuery.data?.profiles,
),
]),
),
[currentPubkey, directMessages, dmProfilesQuery.data],
);
const { dmChannelLabels, dmParticipantsByChannelId, dmPresenceByChannelId } =
useDmSidebarMetadata({
currentPubkey,
directMessages,
fallbackDisplayName,
profileDisplayName: profile?.displayName,
});
const managedAgentsQuery = useManagedAgentsQuery();
const totalAgentCount = managedAgentsQuery.data?.length ?? 0;
const shouldShowAgentCount =
@@ -669,6 +632,22 @@ export function AppSidebar({
unreadChannelIds={unreadChannelIds}
/>
<SidebarSection
action={
<SidebarGroupAction
aria-expanded={isNewDmOpen}
aria-label="Start a direct message"
className="top-3 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
data-testid="new-dm-trigger"
onClick={() => {
setIsNewDmOpen(true);
}}
type="button"
>
<PenSquare className="transition-transform" />
</SidebarGroupAction>
}
dmParticipantsByChannelId={dmParticipantsByChannelId}
emptyState="No direct messages yet."
isActiveChannel={selectedView === "channel"}
items={directMessages}
channelLabels={dmChannelLabels}
@@ -745,6 +724,14 @@ export function AppSidebar({
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<NewDirectMessageDialog
currentPubkey={currentPubkey}
isPending={isOpeningDm}
onOpenChange={setIsNewDmOpen}
onSubmit={onOpenDm}
open={isNewDmOpen}
/>
</Sidebar>
);
}

View File

@@ -0,0 +1,306 @@
import { Search, X } from "lucide-react";
import * as React from "react";
import { useUserSearchQuery } from "@/features/profile/hooks";
import { truncatePubkey } from "@/features/profile/lib/identity";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { UserSearchResult } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
function formatUserName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
truncatePubkey(user.pubkey)
);
}
function formatUserSecondary(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return truncatePubkey(user.pubkey);
}
export function NewDirectMessageDialog({
currentPubkey,
isPending,
onOpenChange,
onSubmit,
open,
}: {
currentPubkey?: string;
isPending: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (input: { pubkeys: string[] }) => Promise<void>;
open: boolean;
}) {
const [searchQuery, setSearchQuery] = React.useState("");
const [selectedUsers, setSelectedUsers] = React.useState<UserSearchResult[]>(
[],
);
const [submitErrorMessage, setSubmitErrorMessage] = React.useState<
string | null
>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const deferredSearchQuery = React.useDeferredValue(searchQuery.trim());
const hasReachedRecipientLimit = selectedUsers.length >= 8;
const selectedPubkeys = React.useMemo(
() => new Set(selectedUsers.map((user) => user.pubkey.toLowerCase())),
[selectedUsers],
);
const userSearchQuery = useUserSearchQuery(deferredSearchQuery, {
enabled:
open && deferredSearchQuery.length > 0 && !hasReachedRecipientLimit,
limit: 8,
});
const searchResults = React.useMemo(
() =>
(userSearchQuery.data ?? []).filter((user) => {
const normalizedPubkey = user.pubkey.toLowerCase();
return (
normalizedPubkey !== currentPubkey?.toLowerCase() &&
!selectedPubkeys.has(normalizedPubkey)
);
}),
[currentPubkey, selectedPubkeys, userSearchQuery.data],
);
React.useEffect(() => {
if (!open) {
setSearchQuery("");
setSelectedUsers([]);
setSubmitErrorMessage(null);
return;
}
searchInputRef.current?.focus();
}, [open]);
function handleSelectUser(user: UserSearchResult) {
if (hasReachedRecipientLimit) {
return;
}
setSelectedUsers((current) => {
if (current.some((candidate) => candidate.pubkey === user.pubkey)) {
return current;
}
return [...current, user];
});
setSearchQuery("");
setSubmitErrorMessage(null);
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xl" data-testid="new-dm-dialog">
<DialogHeader>
<DialogTitle>New direct message</DialogTitle>
<DialogDescription>
Pick 1 to 8 people. If the conversation already exists, Sprout will
reopen it.
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (selectedUsers.length === 0) {
return;
}
setSubmitErrorMessage(null);
void onSubmit({
pubkeys: selectedUsers.map((user) => user.pubkey),
})
.then(() => {
onOpenChange(false);
})
.catch((error) => {
setSubmitErrorMessage(
error instanceof Error
? error.message
: "Failed to open direct message.",
);
});
}}
>
<div className="overflow-hidden rounded-2xl border border-border/80 bg-muted/20">
<div className="flex items-center gap-2 px-3 py-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
className="h-auto border-0 bg-transparent px-0 py-0 shadow-none focus-visible:ring-0"
data-testid="new-dm-search"
disabled={isPending}
onChange={(event) => {
setSearchQuery(event.target.value);
setSubmitErrorMessage(null);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || searchResults.length === 0) {
return;
}
event.preventDefault();
handleSelectUser(searchResults[0]);
}}
placeholder="Search by name, NIP-05, or pubkey."
ref={searchInputRef}
value={searchQuery}
/>
</div>
{selectedUsers.length > 0 ? (
<div className="flex flex-wrap gap-2 border-t border-border/70 px-3 py-2">
{selectedUsers.map((user) => (
<div
className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/80 px-2.5 py-1 text-xs"
data-testid={`new-dm-selected-${user.pubkey}`}
key={user.pubkey}
>
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="h-5 w-5 rounded-full text-[10px] shadow-none"
iconClassName="h-3 w-3"
label={formatUserName(user)}
/>
<span className="font-medium">{formatUserName(user)}</span>
<button
aria-label={`Remove ${formatUserName(user)}`}
className="text-muted-foreground transition-colors hover:text-foreground"
disabled={isPending}
onClick={() => {
setSelectedUsers((current) =>
current.filter(
(candidate) => candidate.pubkey !== user.pubkey,
),
);
}}
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : null}
<div className="border-t border-border/70 px-2 py-2">
{hasReachedRecipientLimit ? (
<p
className="px-2 py-1 text-sm text-muted-foreground"
data-testid="new-dm-limit"
>
Direct messages support up to 9 people including you.
</p>
) : deferredSearchQuery.length === 0 ? (
<p
className="px-2 py-1 text-sm text-muted-foreground"
data-testid="new-dm-empty"
>
Search for someone to start a conversation.
</p>
) : userSearchQuery.isLoading ? (
<p className="px-2 py-1 text-sm text-muted-foreground">
Searching…
</p>
) : searchResults.length > 0 ? (
<div className="space-y-1">
{searchResults.map((user) => (
<button
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
data-testid={`new-dm-result-${user.pubkey}`}
key={user.pubkey}
onClick={() => {
handleSelectUser(user);
}}
type="button"
>
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="h-9 w-9 rounded-2xl text-xs shadow-none"
iconClassName="h-4 w-4"
label={formatUserName(user)}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{formatUserName(user)}
</p>
<p className="truncate text-xs text-muted-foreground">
{formatUserSecondary(user)}
</p>
</div>
<span className="text-xs text-muted-foreground">Add</span>
</button>
))}
</div>
) : (
<p className="px-2 py-1 text-sm text-muted-foreground">
No matching users.
</p>
)}
</div>
</div>
{userSearchQuery.error instanceof Error ? (
<p className="text-sm text-destructive">
{userSearchQuery.error.message}
</p>
) : null}
{submitErrorMessage ? (
<p className="text-sm text-destructive">{submitErrorMessage}</p>
) : null}
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{selectedUsers.length === 0
? "No recipients selected yet."
: `${selectedUsers.length} recipient${
selectedUsers.length === 1 ? "" : "s"
} selected.`}
</p>
<div className="flex items-center gap-2">
<Button
disabled={isPending}
onClick={() => onOpenChange(false)}
type="button"
variant="ghost"
>
Cancel
</Button>
<Button
data-testid="new-dm-submit"
disabled={isPending || selectedUsers.length === 0}
type="submit"
>
{isPending
? "Opening..."
: selectedUsers.length > 1
? "Start group DM"
: "Message"}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,5 +1,7 @@
import type * as React from "react";
import { CircleDot, FileText, Hash } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { Channel, PresenceStatus } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import {
@@ -13,11 +15,100 @@ import {
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
function SidebarChannelIcon({ channel }: { channel: Channel }) {
if (channel.channelType === "dm") {
export type SidebarDmParticipant = {
avatarUrl: string | null;
label: string;
pubkey: string;
};
function DmChannelIcon({
channelName,
isPair,
participants,
presenceStatus,
}: {
channelName: string;
isPair: boolean;
participants?: SidebarDmParticipant[];
presenceStatus?: PresenceStatus;
}) {
const primaryParticipant = participants?.[0];
const secondaryParticipant = participants?.[1];
if (!primaryParticipant) {
return <CircleDot className="h-4 w-4" />;
}
if (isPair || !secondaryParticipant) {
return (
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
<ProfileAvatar
avatarUrl={primaryParticipant.avatarUrl}
className="h-5 w-5 rounded-full border border-sidebar-border/80 bg-sidebar-accent/80 text-[9px] text-sidebar-foreground shadow-none"
iconClassName="h-3 w-3"
label={primaryParticipant.label}
/>
{presenceStatus ? (
<span className="absolute -bottom-0.5 -right-0.5 flex h-2.5 w-2.5 items-center justify-center rounded-full bg-sidebar">
<PresenceDot
className="h-1.5 w-1.5"
data-testid={`channel-presence-${channelName}`}
status={presenceStatus}
/>
</span>
) : null}
</span>
);
}
return (
<span className="relative flex h-5 w-7 shrink-0 items-center">
<ProfileAvatar
avatarUrl={primaryParticipant.avatarUrl}
className="absolute left-0 top-0 h-[18px] w-[18px] rounded-full border-2 border-sidebar bg-sidebar-accent/80 text-[8px] text-sidebar-foreground shadow-none"
iconClassName="h-2.5 w-2.5"
label={primaryParticipant.label}
/>
<ProfileAvatar
avatarUrl={secondaryParticipant.avatarUrl}
className="absolute bottom-0 right-0 h-[18px] w-[18px] rounded-full border-2 border-sidebar bg-sidebar-accent/80 text-[8px] text-sidebar-foreground shadow-none"
iconClassName="h-2.5 w-2.5"
label={secondaryParticipant.label}
/>
{participants && participants.length > 2 ? (
<span className="absolute -bottom-1 -right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-sidebar-primary px-1 text-[8px] font-semibold text-sidebar-primary-foreground">
{participants.length}
</span>
) : null}
</span>
);
}
function SidebarChannelIcon({
channel,
dmParticipants,
presenceStatus,
}: {
channel: Channel;
dmParticipants?: SidebarDmParticipant[];
presenceStatus?: PresenceStatus;
}) {
if (channel.channelType === "dm") {
return (
<DmChannelIcon
channelName={channel.name}
isPair={channel.participantPubkeys.length === 2}
participants={dmParticipants}
presenceStatus={
dmParticipants?.length === 1 ||
channel.participantPubkeys.length === 2
? presenceStatus
: undefined
}
/>
);
}
if (channel.channelType === "forum") {
return <FileText className="h-4 w-4" />;
}
@@ -30,6 +121,7 @@ export function ChannelMenuButton({
label,
isActive,
hasUnread,
dmParticipants,
presenceStatus,
onSelectChannel,
}: {
@@ -37,6 +129,7 @@ export function ChannelMenuButton({
label?: string;
isActive: boolean;
hasUnread: boolean;
dmParticipants?: SidebarDmParticipant[];
presenceStatus?: PresenceStatus;
onSelectChannel: (channelId: string) => void;
}) {
@@ -55,29 +148,27 @@ export function ChannelMenuButton({
tooltip={resolvedLabel}
type="button"
>
<SidebarChannelIcon channel={channel} />
<SidebarChannelIcon
channel={channel}
dmParticipants={dmParticipants}
presenceStatus={presenceStatus}
/>
<span className="min-w-0 flex-1 truncate">{resolvedLabel}</span>
<div className="ml-auto flex items-center gap-2">
{presenceStatus ? (
<PresenceDot
className="h-2 w-2"
data-testid={`channel-presence-${channel.name}`}
status={presenceStatus}
/>
) : null}
{hasUnread && !isActive ? (
<span
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary"
data-testid={`channel-unread-${channel.name}`}
/>
) : null}
</div>
{hasUnread && !isActive ? (
<span
aria-hidden="true"
className="ml-auto h-2.5 w-2.5 shrink-0 rounded-full bg-primary"
data-testid={`channel-unread-${channel.name}`}
/>
) : null}
</SidebarMenuButton>
);
}
export function SidebarSection({
action,
dmParticipantsByChannelId,
emptyState,
items,
channelLabels,
isActiveChannel,
@@ -88,6 +179,9 @@ export function SidebarSection({
unreadChannelIds,
onSelectChannel,
}: {
action?: React.ReactNode;
dmParticipantsByChannelId?: Record<string, SidebarDmParticipant[]>;
emptyState?: React.ReactNode;
items: Channel[];
channelLabels?: Record<string, string>;
isActiveChannel: boolean;
@@ -98,28 +192,39 @@ export function SidebarSection({
unreadChannelIds: Set<string>;
onSelectChannel: (channelId: string) => void;
}) {
if (items.length === 0) {
if (items.length === 0 && !action && !emptyState) {
return null;
}
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
{action}
<SidebarGroupContent>
<SidebarMenu data-testid={testId}>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={isActiveChannel && selectedChannelId === channel.id}
label={channelLabels?.[channel.id] ?? channel.name}
presenceStatus={presenceByChannelId?.[channel.id]}
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
{items.length > 0 ? (
<SidebarMenu data-testid={testId}>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<ChannelMenuButton
channel={channel}
dmParticipants={dmParticipantsByChannelId?.[channel.id]}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={isActiveChannel && selectedChannelId === channel.id}
label={channelLabels?.[channel.id] ?? channel.name}
presenceStatus={presenceByChannelId?.[channel.id]}
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
) : emptyState ? (
<div
className="px-2 py-1 text-sm text-sidebar-foreground/60"
data-testid={`${testId}-empty`}
>
{emptyState}
</div>
) : null}
</SidebarGroupContent>
</SidebarGroup>
);

View File

@@ -0,0 +1,144 @@
import * as React from "react";
import { usePresenceQuery } from "@/features/presence/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
import type { SidebarDmParticipant } from "@/features/sidebar/ui/SidebarSection";
import type { Channel, PresenceStatus } from "@/shared/api/types";
export function useDmSidebarMetadata({
currentPubkey,
directMessages,
fallbackDisplayName,
profileDisplayName,
}: {
currentPubkey?: string;
directMessages: Channel[];
fallbackDisplayName?: string;
profileDisplayName?: string | null;
}) {
const selfDmLabels = React.useMemo(
() =>
new Set(
[profileDisplayName, fallbackDisplayName]
.map((value) => value?.trim().toLowerCase())
.filter((value): value is string => Boolean(value)),
),
[fallbackDisplayName, profileDisplayName],
);
const dmParticipantPubkeys = React.useMemo(
() =>
directMessages.flatMap((channel) =>
channel.participantPubkeys.filter((pubkey, index) => {
const normalizedPubkey = pubkey.toLowerCase();
if (normalizedPubkey === currentPubkey?.toLowerCase()) {
return false;
}
const participantLabel =
channel.participants[index]?.trim().toLowerCase() ?? null;
return !participantLabel || !selfDmLabels.has(participantLabel);
}),
),
[currentPubkey, directMessages, selfDmLabels],
);
const dmPresenceQuery = usePresenceQuery(dmParticipantPubkeys, {
enabled: directMessages.length > 0,
});
const dmProfilesQuery = useUsersBatchQuery(dmParticipantPubkeys, {
enabled: directMessages.length > 0,
});
const dmProfiles = dmProfilesQuery.data?.profiles;
const dmPresenceByChannelId = React.useMemo(
() =>
Object.fromEntries(
directMessages.map((channel) => {
const otherParticipantPubkey = channel.participantPubkeys.find(
(pubkey, index) => {
const normalizedPubkey = pubkey.toLowerCase();
if (normalizedPubkey === currentPubkey?.toLowerCase()) {
return false;
}
const participantLabel =
channel.participants[index]?.trim().toLowerCase() ?? null;
return !participantLabel || !selfDmLabels.has(participantLabel);
},
);
return [
channel.id,
otherParticipantPubkey
? (dmPresenceQuery.data?.[otherParticipantPubkey.toLowerCase()] ??
"offline")
: "offline",
];
}),
) satisfies Record<string, PresenceStatus>,
[currentPubkey, directMessages, dmPresenceQuery.data, selfDmLabels],
);
const dmChannelLabels = React.useMemo(
() =>
Object.fromEntries(
directMessages.map((channel) => [
channel.id,
resolveChannelDisplayLabel(
channel,
currentPubkey,
dmProfilesQuery.data?.profiles,
),
]),
),
[currentPubkey, directMessages, dmProfilesQuery.data],
);
const dmParticipantsByChannelId = React.useMemo(
() =>
Object.fromEntries(
directMessages.map((channel) => {
const participants = channel.participantPubkeys.map(
(pubkey, index) => ({
fallbackName: channel.participants[index] ?? null,
pubkey,
}),
);
const otherParticipants = participants.filter((participant) => {
if (
participant.pubkey.toLowerCase() === currentPubkey?.toLowerCase()
) {
return false;
}
const participantLabel =
participant.fallbackName?.trim().toLowerCase() ?? null;
return !participantLabel || !selfDmLabels.has(participantLabel);
});
const visibleParticipants =
otherParticipants.length > 0 ? otherParticipants : participants;
return [
channel.id,
visibleParticipants.map((participant) => ({
avatarUrl:
dmProfiles?.[participant.pubkey.toLowerCase()]?.avatarUrl ??
null,
label: resolveUserLabel({
currentPubkey,
fallbackName: participant.fallbackName,
profiles: dmProfiles,
pubkey: participant.pubkey,
}),
pubkey: participant.pubkey,
})),
];
}),
) satisfies Record<string, SidebarDmParticipant[]>,
[currentPubkey, directMessages, dmProfiles, selfDmLabels],
);
return {
dmChannelLabels,
dmParticipantsByChannelId,
dmPresenceByChannelId,
};
}

View File

@@ -42,6 +42,7 @@ import type {
AcpProvider,
CommandAvailability,
ManagedAgentPrereqs,
OpenDmInput,
} from "@/shared/api/types";
type RawIdentity = {
@@ -510,8 +511,11 @@ export async function getChannels(): Promise<Channel[]> {
export async function createChannel(
input: CreateChannelInput,
): Promise<Channel> {
const channel = await invokeTauri<RawChannel>("create_channel", input);
return fromRawChannel(channel);
return fromRawChannel(await invokeTauri<RawChannel>("create_channel", input));
}
export async function openDm(input: OpenDmInput): Promise<Channel> {
return fromRawChannel(await invokeTauri<RawChannel>("open_dm", input));
}
export async function getChannelDetails(

View File

@@ -45,6 +45,10 @@ export type CreateChannelInput = {
description?: string;
};
export type OpenDmInput = {
pubkeys: string[];
};
export type UpdateChannelInput = {
channelId: string;
name?: string;

View File

@@ -72,6 +72,12 @@ type RawSetPresenceResponse = {
ttl_seconds: number;
};
type RawOpenDmResponse = {
channel_id: string;
created: boolean;
participants: string[];
};
type RawChannel = {
id: string;
name: string;
@@ -447,6 +453,33 @@ function touchMockChannel(channel: MockChannel) {
channel.updated_at = new Date().toISOString();
}
function normalizeParticipantPubkeys(pubkeys: string[]) {
return [...new Set(pubkeys.map((pubkey) => pubkey.toLowerCase()))].sort();
}
function findMockDmByParticipantPubkeys(pubkeys: string[]) {
const normalizedPubkeys = normalizeParticipantPubkeys(pubkeys);
return (
mockChannels.find((channel) => {
if (channel.channel_type !== "dm") {
return false;
}
const channelPubkeys = normalizeParticipantPubkeys(
channel.participant_pubkeys,
);
return (
channelPubkeys.length === normalizedPubkeys.length &&
channelPubkeys.every(
(pubkey, index) => pubkey === normalizedPubkeys[index],
)
);
}) ?? null
);
}
function getMockIdentity() {
return {
pubkey: MOCK_IDENTITY_PUBKEY,
@@ -1524,6 +1557,79 @@ async function handleCreateChannel(
});
}
async function handleOpenDm(
args: {
pubkeys: string[];
},
config: E2eConfig | undefined,
) {
const normalizedPubkeys = normalizeParticipantPubkeys(args.pubkeys);
if (normalizedPubkeys.length === 0) {
throw new Error("Select at least one person to start a DM.");
}
const currentPubkey = getMockMemberPubkey(config).toLowerCase();
const participantPubkeys = normalizeParticipantPubkeys([
currentPubkey,
...normalizedPubkeys.filter((pubkey) => pubkey !== currentPubkey),
]);
const existingChannel = findMockDmByParticipantPubkeys(participantPubkeys);
if (existingChannel) {
return toRawChannel(existingChannel);
}
const identity = getIdentity(config);
if (!identity) {
const members = participantPubkeys.map((pubkey) =>
createMockMember(pubkey, "member", 0),
);
const channel = createMockChannel({
id: crypto.randomUUID(),
name:
participantPubkeys.length === 2
? "DM"
: `Group DM (${participantPubkeys.length})`,
channel_type: "dm",
visibility: "private",
description: "Direct message conversation",
topic: null,
purpose: null,
last_message_at: null,
archived_at: null,
created_by: getMockMemberPubkey(config),
topic_set_by: null,
topic_set_at: null,
purpose_set_by: null,
purpose_set_at: null,
topic_required: false,
max_members: participantPubkeys.length,
nip29_group_id: null,
created_minutes_ago: 0,
updated_minutes_ago: 0,
members,
});
syncMockChannel(channel);
mockChannels.push(channel);
return toRawChannel(channel);
}
const response = await relayJsonRequest<RawOpenDmResponse>(
config,
"/api/dms",
{
method: "POST",
body: JSON.stringify({
pubkeys: normalizedPubkeys,
}),
},
);
return relayJsonRequest<RawChannel>(
config,
`/api/channels/${response.channel_id}`,
);
}
async function handleGetChannelDetails(
args: { channelId: string },
config: E2eConfig | undefined,
@@ -2938,6 +3044,11 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof handleCreateChannel>[0],
activeConfig,
);
case "open_dm":
return handleOpenDm(
payload as Parameters<typeof handleOpenDm>[0],
activeConfig,
);
case "get_channel_details":
return handleGetChannelDetails(
payload as Parameters<typeof handleGetChannelDetails>[0],

View File

@@ -73,6 +73,26 @@ test("shows presence in sidebar, DM header, and member list", async ({
await closeChannelManagement(page);
});
test("start a new direct message from the sidebar", async ({ page }) => {
await page.goto("/");
await page.getByTestId("new-dm-trigger").click();
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
await page.getByTestId("new-dm-search").fill("charlie");
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await expect(
page.getByTestId(`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`),
).toBeVisible();
await page.getByTestId("new-dm-submit").click();
await expect(page.getByTestId("dm-list")).toContainText("charlie");
await expect(page.getByTestId("chat-title")).toHaveText("charlie");
});
test("create stream with name and description", async ({ page }) => {
const channelName = `my-new-stream-${Date.now()}`;