diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index cb4c468801..82deb4ac73 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -12,6 +12,9 @@ import { useIdentityQuery } from "@/shared/api/hooks"; type HomeRouteSearch = { item?: string; + profile?: string; + profileTab?: string; + profileView?: string; }; function validateHomeSearch(search: Record): HomeRouteSearch { @@ -20,6 +23,18 @@ function validateHomeSearch(search: Record): HomeRouteSearch { typeof search.item === "string" && search.item.length > 0 ? search.item : undefined, + profile: + typeof search.profile === "string" && search.profile.length > 0 + ? search.profile + : undefined, + profileTab: + typeof search.profileTab === "string" && search.profileTab.length > 0 + ? search.profileTab + : undefined, + profileView: + typeof search.profileView === "string" && search.profileView.length > 0 + ? search.profileView + : undefined, }; } diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 1afae6f642..ae6ab7c162 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -60,6 +60,7 @@ export type InboxReply = { reactions?: TimelineReaction[]; rootId?: string | null; tags?: string[][]; + timeLabel?: string; }; export type InboxContextMessage = InboxReply & { diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index bea5a40a39..02141fe53c 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -2,7 +2,8 @@ import * as React from "react"; import { RefreshCcw } from "lucide-react"; import { useAppShell } from "@/app/AppShellContext"; -import { useChannelsQuery } from "@/features/channels/hooks"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; import { @@ -19,6 +20,15 @@ import { } from "@/features/home/lib/inboxViewHelpers"; import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState"; import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; +import { + type ProfilePanelTab, + type ProfilePanelView, + UserProfilePanel, +} from "@/features/profile/ui/UserProfilePanel"; +import { + profilePanelTabFromSearch, + profilePanelViewFromSearch, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { INBOX_COLUMN_MIN_WIDTH_PX, INBOX_SINGLE_COLUMN_BREAKPOINT_PX, @@ -35,6 +45,7 @@ import { collectMessageMentionPubkeys, formatTimelineMessages, } from "@/features/messages/lib/formatTimelineMessages"; +import { formatTime } from "@/features/messages/lib/dateFormatters"; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { getThreadReference } from "@/features/messages/lib/threading"; import { useUsersBatchQuery } from "@/features/profile/hooks"; @@ -55,9 +66,15 @@ import { useElementWidth } from "@/shared/hooks/use-mobile"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel"; import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; +import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { Button } from "@/shared/ui/button"; -const INBOX_SEARCH_KEYS = ["item"] as const; +const INBOX_SEARCH_KEYS = [ + "item", + "profile", + "profileTab", + "profileView", +] as const; type HomeViewProps = { feed?: HomeFeedResponse; @@ -102,9 +119,21 @@ export function HomeView({ // FeedItem selection model, so reload while in Reminders mode keeps a stale // `?item=` unconsumed and does not snap back to a feed-item detail view. const urlSelectedItemId = isMessagesMode ? inboxSearchValues.item : null; + const profilePanelPubkey = inboxSearchValues.profile; + const profilePanelTab = profilePanelTabFromSearch( + inboxSearchValues.profileTab, + ); + const profilePanelView = profilePanelViewFromSearch( + inboxSearchValues.profileView, + ); const [selectedItemId, setSelectedItemId] = React.useState( urlSelectedItemId, ); + const [managedChannelId, setManagedChannelId] = React.useState( + null, + ); + const { goChannel } = useAppNavigation(); + const openDmMutation = useOpenDmMutation(); React.useEffect(() => { setSelectedItemId(urlSelectedItemId); }, [urlSelectedItemId]); @@ -115,10 +144,48 @@ export function HomeView({ }, [applyInboxSearchPatch], ); + const handleOpenProfilePanel = React.useCallback( + (pubkey: string) => { + setManagedChannelId(null); + applyInboxSearchPatch({ + profile: pubkey, + profileTab: null, + profileView: null, + }); + }, + [applyInboxSearchPatch], + ); + const handleCloseProfilePanel = React.useCallback(() => { + applyInboxSearchPatch({ + profile: null, + profileTab: null, + profileView: null, + }); + }, [applyInboxSearchPatch]); + const handleProfilePanelViewChange = React.useCallback( + (view: ProfilePanelView, options?: { replace?: boolean }) => + applyInboxSearchPatch( + { profileView: view === "summary" ? null : view }, + options, + ), + [applyInboxSearchPatch], + ); + const handleProfilePanelTabChange = React.useCallback( + (tab: ProfilePanelTab, options?: { replace?: boolean }) => + applyInboxSearchPatch( + { profileTab: tab === "info" ? null : tab }, + options, + ), + [applyInboxSearchPatch], + ); const [isDeletingMessage, setIsDeletingMessage] = React.useState(false); const [isSendingReply, setIsSendingReply] = React.useState(false); - const [managedChannelId, setManagedChannelId] = React.useState( - null, + const handleOpenDm = React.useCallback( + async (pubkeys: string[]) => { + const dm = await openDmMutation.mutateAsync({ pubkeys }); + await goChannel(dm.id); + }, + [goChannel, openDmMutation], ); const { activeReminderEventIds, openReminder } = useRemindLater(); const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState< @@ -179,8 +246,10 @@ export function HomeView({ return channels.find((channel) => channel.id === managedChannelId) ?? null; }, [channels, managedChannelId]); const isChannelManagementOpen = managedChannel !== null; - const isSinglePanelChannelManagementView = - isChannelManagementOpen && + const hasAuxiliaryPane = + isChannelManagementOpen || profilePanelPubkey !== null; + const isSinglePanelAuxiliaryView = + hasAuxiliaryPane && homeInboxWidthPx > 0 && homeInboxWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX; @@ -308,6 +377,7 @@ export function HomeView({ resolveMentionNames(message.tags ?? [], feedProfiles) ?? [], reactions: message.reactions, tags: message.tags, + timeLabel: message.time, }; }); }, [ @@ -420,14 +490,13 @@ export function HomeView({ isMessagesMode && isNarrowHomeViewport && selectedItemId !== null && - !isSinglePanelChannelManagementView; - const showListPane = - !isSinglePanelDetailView && !isSinglePanelChannelManagementView; + !isSinglePanelAuxiliaryView; + const showListPane = !isSinglePanelDetailView && !isSinglePanelAuxiliaryView; const showDetailPane = isMessagesMode && - !isSinglePanelChannelManagementView && + !isSinglePanelAuxiliaryView && (!isNarrowHomeViewport || isSinglePanelDetailView); - const channelManagementWidthPx = isSinglePanelChannelManagementView + const auxiliaryPaneWidthPx = isSinglePanelAuxiliaryView ? homeInboxWidthPx : threadPanelWidthPx; const maxEffectiveInboxListWidthPx = @@ -436,7 +505,7 @@ export function HomeView({ INBOX_COLUMN_MIN_WIDTH_PX, homeInboxWidthPx - INBOX_COLUMN_MIN_WIDTH_PX - - (isChannelManagementOpen ? channelManagementWidthPx : 0), + (hasAuxiliaryPane ? auxiliaryPaneWidthPx : 0), ) : undefined; const effectiveInboxListWidthPx = @@ -448,245 +517,280 @@ export function HomeView({ : inboxListWidthPx; return ( -
-
- {showListPane ? ( - { - const channelId = item.item.channelId; - if (!channelId) { - return; - } - onOpenContext( - channelId, - item.id, - getThreadReference(item.item.tags).rootId, - ); - }} - onRemindLater={(item) => { - const channelId = item.item.channelId; - if (!channelId) { - return; - } - openReminder({ - authorPubkey: item.item.pubkey, - channelId, - eventId: item.id, - preview: item.preview.slice(0, 100), - }); - }} - onSelect={(itemId) => { - handleUserSelectItem(itemId); - markItemRead(itemId); - }} - onUnreadOnlyChange={setUnreadOnly} - reminderPubkey={currentPubkey} - selectedId={selectedItemId} - showRightDivider={showListPane && showDetailPane} - unreadOnly={unreadOnly} - /> - ) : null} - - + {showListPane ? ( + { + const channelId = item.item.channelId; + if (!channelId) { + return; + } + onOpenContext( + channelId, + item.id, + getThreadReference(item.item.tags).rootId, + ); + }} + onRemindLater={(item) => { + const channelId = item.item.channelId; + if (!channelId) { + return; + } + openReminder({ + authorPubkey: item.item.pubkey, + channelId, + eventId: item.id, + preview: item.preview.slice(0, 100), + }); + }} + onSelect={(itemId) => { + handleUserSelectItem(itemId); + markItemRead(itemId); + }} + onUnreadOnlyChange={setUnreadOnly} + reminderPubkey={currentPubkey} + selectedId={selectedItemId} + showRightDivider={showListPane && showDetailPane} + unreadOnly={unreadOnly} + /> + ) : null} - {showDetailPane ? ( - { - handleUserSelectItem(null); - } - : undefined + data-testid="home-inbox-list-resize-handle" + onDoubleClick={ + canResetInboxListWidth ? handleInboxListWidthReset : undefined } - onDelete={() => { - if (!selectedItem || !canDelete) { - return; - } - const channelId = selectedItem.item.channelId; - if (!channelId) { - return; - } + onPointerDown={handleInboxListResizeStart} + style={{ left: `${effectiveInboxListWidthPx}px` }} + title={ + canResetInboxListWidth + ? "Drag to resize. Double-click to reset width." + : "Drag to resize." + } + type="button" + > + + - setIsDeletingMessage(true); - void deleteMessage(channelId, selectedItem.id) - .then(() => { - onRefresh(); - }) - .finally(() => { - setIsDeletingMessage(false); - }); - }} - onOpenChannel={setManagedChannelId} - onSendReply={async ({ - content, - mediaTags, - mentionPubkeys, - parentEventId, - }) => { - const channelId = selectedItem?.item.channelId; - if (!selectedItem || !channelId || !canReply) { - throw new Error("Replies are not available for this item."); + {showDetailPane ? ( + { + handleUserSelectItem(null); + } + : undefined } + onDelete={() => { + if (!selectedItem || !canDelete) { + return; + } + const channelId = selectedItem.item.channelId; + if (!channelId) { + return; + } - const itemToReply = selectedItem; - setIsSendingReply(true); - try { - const { - mediaTags: imetaTags, - emojiTags, - mentionTags, - } = splitOutgoingTags(mediaTags); - const result = await sendChannelMessage( - channelId, - content, - parentEventId, - imetaTags, - mentionPubkeys, - undefined, - emojiTags, - mentionTags, - ); - const authorPubkey = currentPubkey ?? itemToReply.item.pubkey; - const reply: InboxReply = { - authorLabel: currentPubkey - ? resolveUserLabel({ - currentPubkey, - profiles: feedProfiles, - pubkey: authorPubkey, - }) - : "You", - authorPubkey, - avatarUrl: - currentPubkey && feedProfiles - ? (feedProfiles[currentPubkey.trim().toLowerCase()] - ?.avatarUrl ?? null) - : null, - content, - depth: result.depth, - fullTimestampLabel: formatInboxFullTimestamp( - result.createdAt, - ), - id: result.eventId, - parentId: result.parentEventId, - rootId: result.rootEventId, - tags: emojiTags, - }; - setLocalRepliesByItemId((current) => ({ - ...current, - [itemToReply.id]: [...(current[itemToReply.id] ?? []), reply], - })); - onRefresh(); - } finally { - setIsSendingReply(false); - } - }} - onToggleReaction={ - canReact - ? async (message, emoji, remove) => { - await toggleReactionMutation.mutateAsync({ - emoji, - eventId: message.id, - remove, - }); - await channelMessagesQuery.refetch(); + setIsDeletingMessage(true); + void deleteMessage(channelId, selectedItem.id) + .then(() => { onRefresh(); - } - : undefined - } - replies={selectedItemReplies} - /> - ) : null} - {isChannelManagementOpen ? ( - - { - if (!nextOpen) { - setManagedChannelId(null); + }) + .finally(() => { + setIsDeletingMessage(false); + }); + }} + onOpenChannel={(channelId) => { + handleCloseProfilePanel(); + setManagedChannelId(channelId); + }} + onSendReply={async ({ + content, + mediaTags, + mentionPubkeys, + parentEventId, + }) => { + const channelId = selectedItem?.item.channelId; + if (!selectedItem || !channelId || !canReply) { + throw new Error("Replies are not available for this item."); + } + + const itemToReply = selectedItem; + setIsSendingReply(true); + try { + const { + mediaTags: imetaTags, + emojiTags, + mentionTags, + } = splitOutgoingTags(mediaTags); + const result = await sendChannelMessage( + channelId, + content, + parentEventId, + imetaTags, + mentionPubkeys, + undefined, + emojiTags, + mentionTags, + ); + const authorPubkey = currentPubkey ?? itemToReply.item.pubkey; + const reply: InboxReply = { + authorLabel: currentPubkey + ? resolveUserLabel({ + currentPubkey, + profiles: feedProfiles, + pubkey: authorPubkey, + }) + : "You", + authorPubkey, + avatarUrl: + currentPubkey && feedProfiles + ? (feedProfiles[currentPubkey.trim().toLowerCase()] + ?.avatarUrl ?? null) + : null, + content, + depth: result.depth, + fullTimestampLabel: formatInboxFullTimestamp( + result.createdAt, + ), + id: result.eventId, + parentId: result.parentEventId, + rootId: result.rootEventId, + tags: emojiTags, + timeLabel: formatTime(result.createdAt), + }; + setLocalRepliesByItemId((current) => ({ + ...current, + [itemToReply.id]: [ + ...(current[itemToReply.id] ?? []), + reply, + ], + })); + onRefresh(); + } finally { + setIsSendingReply(false); } }} - open={true} + onToggleReaction={ + canReact + ? async (message, emoji, remove) => { + await toggleReactionMutation.mutateAsync({ + emoji, + eventId: message.id, + remove, + }); + await channelMessagesQuery.refetch(); + onRefresh(); + } + : undefined + } + replies={selectedItemReplies} /> - - ) : null} + ) : null} + {profilePanelPubkey ? ( + + + + ) : isChannelManagementOpen ? ( + + { + if (!nextOpen) { + setManagedChannelId(null); + } + }} + open={true} + /> + + ) : null} +
- + ); } diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index aa179e711c..daf0b8f502 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -13,6 +13,8 @@ import { InboxMessageRow, } from "@/features/home/ui/InboxMessageRow"; import type { TimelineMessage } from "@/features/messages/types"; +import { formatTime } from "@/features/messages/lib/dateFormatters"; +import { hasSameMessageAuthor } from "@/features/messages/lib/messageGrouping"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { UpdateIndicator } from "@/features/settings/UpdateIndicator"; import type { Channel } from "@/shared/api/types"; @@ -195,6 +197,7 @@ export function InboxDetailPane({ id: item.id, isSelected: true, mentionNames: item.mentionNames, + timeLabel: formatTime(item.item.createdAt), }, ...pendingReplyMessages, ]; @@ -320,22 +323,33 @@ export function InboxDetailPane({ className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-32" >
- {displayMessages.map((message, index) => ( - - {index === 1 ? ( -
- ) : null} - - - ))} + {displayMessages.map((message, index) => { + const isAfterSeparator = index === 1; + const isContinuation = + !isAfterSeparator && + hasSameMessageAuthor( + { pubkey: displayMessages[index - 1]?.authorPubkey }, + { pubkey: message.authorPubkey }, + ); + + return ( + + {isAfterSeparator ? ( +
+ ) : null} + + + ); + })}
diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 52faa09fdf..6acf8c2ed8 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -163,7 +163,16 @@ export function InboxListPane({ const rowHighlightColor = isSelected ? "color-mix(in srgb, hsl(var(--background)) 70%, hsl(var(--muted)) 30%)" : "color-mix(in srgb, hsl(var(--background)) 75%, hsl(var(--muted)) 25%)"; - + const handleRowContentClick = (event: React.MouseEvent) => { + const target = event.target; + if ( + target instanceof Element && + target.closest("[data-inbox-profile-trigger]") + ) { + return; + } + onSelect(item.id); + }; const row = (
+ + {/* biome-ignore lint/a11y: The sibling full-row button provides keyboard/screen-reader row activation; this wrapper delegates pointer selection while allowing nested profile triggers. */} +
+
+
- +
- +
{isDone ? ( diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 7bf9a2de76..84195d475a 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import type { InboxContextMessage } from "@/features/home/lib/inbox"; +import { formatTimeWithoutDayPeriod } from "@/features/messages/lib/dateFormatters"; import type { TimelineMessage } from "@/features/messages/types"; import { MessageActionBar } from "@/features/messages/ui/MessageActionBar"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; @@ -27,7 +28,7 @@ function toTimelineMessage(message: InboxDisplayMessage): TimelineMessage { pubkey: message.authorPubkey, reactions: message.reactions ?? [], tags: message.tags, - time: message.fullTimestampLabel, + time: message.timeLabel ?? message.fullTimestampLabel, }; } @@ -36,6 +37,7 @@ type InboxMessageRowProps = { canReply: boolean; /** Channel UUID for "Copy link" — passed straight through to MessageActionBar. */ channelId?: string | null; + isContinuation?: boolean; isFocusHighlightVisible: boolean; message: InboxDisplayMessage; onSelectReplyTarget: (message: InboxDisplayMessage) => void; @@ -50,6 +52,7 @@ export function InboxMessageRow({ agentPubkeys, canReply, channelId = null, + isContinuation = false, isFocusHighlightVisible, message, onSelectReplyTarget, @@ -76,14 +79,17 @@ export function InboxMessageRow({ const isAuthorAgent = agentPubkeys?.has(normalizePubkey(message.authorPubkey)) === true; const profileRole = isAuthorAgent ? "bot" : undefined; + const hoverTimestampLabel = formatTimeWithoutDayPeriod( + message.timeLabel ?? message.fullTimestampLabel, + ); return ( -
+
{message.isSelected ? (