Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export default defineConfig({
"**/reaction-order.spec.ts",
"**/reaction-names.spec.ts",
"**/inbox-reactions.spec.ts",
"**/inbox-edit.spec.ts",
"**/send-channel-binding.spec.ts",
"**/project-commit-detail.spec.ts",
"**/project-pr-review.spec.ts",
Expand Down
13 changes: 13 additions & 0 deletions desktop/src/features/home/lib/inboxViewHelpers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import { formatTimelineMessages } from "../../messages/lib/formatTimelineMessages.ts";
import { getConfigNudgeAuthorPubkey } from "../../messages/ui/configNudgeAuthPubkey.ts";
import {
findInboxItemByEventId,
getContextMessageDepth,
getReactionTargetId,
isInboxThreadContextEvent,
Expand All @@ -12,6 +13,18 @@ import {
toTimelineMessage,
} from "./inboxViewHelpers.ts";

// --- findInboxItemByEventId ---

test("findInboxItemByEventId finds representative and grouped event ids", () => {
const representative = { id: "latest", groupItems: [{ id: "older" }] };
const other = { id: "other", groupItems: [] };
const items = [representative, other];

assert.equal(findInboxItemByEventId(items, "latest"), representative);
assert.equal(findInboxItemByEventId(items, "older"), representative);
assert.equal(findInboxItemByEventId(items, "missing"), null);
});

// --- matchesInboxFilter ---

test("matchesInboxFilter returns true for the 'all' filter regardless of categories", () => {
Expand Down
14 changes: 14 additions & 0 deletions desktop/src/features/home/lib/inboxViewHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
formatInboxFullTimestamp,
type InboxContextMessage,
type InboxFilter,
type InboxItem,
} from "@/features/home/lib/inbox";
import {
getChannelIdFromTags,
Expand All @@ -21,6 +22,19 @@ function hasThreadReplyTags(tags: string[][]) {
return thread.parentId !== null && !isBroadcastReply(tags);
}

export function findInboxItemByEventId(
items: readonly InboxItem[],
eventId: string,
): InboxItem | null {
const direct = items.find((item) => item.id === eventId);
if (direct) return direct;
return (
items.find((item) =>
item.groupItems.some((groupItem) => groupItem.id === eventId),
) ?? null
);
}

export function matchesInboxFilter(
item: {
categories: readonly string[];
Expand Down
48 changes: 20 additions & 28 deletions desktop/src/features/home/ui/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagement
import {
type InboxFilter,
type InboxContextMessage,
type InboxItem,
type InboxReply,
buildInboxItems,
formatInboxFullTimestamp,
getInboxConversationId,
} from "@/features/home/lib/inbox";
import { useInboxSelectionAnchor } from "@/features/home/useInboxSelectionAnchor";
import { useInboxEditMessage } from "@/features/home/useInboxEditMessage";
import {
findInboxItemByEventId,
getReactionTargetId,
matchesInboxFilter,
toInboxContextMessage,
Expand Down Expand Up @@ -82,27 +83,6 @@ const INBOX_SEARCH_KEYS = [
"profileView",
] as const;

/**
* Finds the InboxItem whose stable conversation contains the given event ID.
* Checks `item.id` (the current representative/latest event) first, then
* falls back to `item.groupItems` so that a deep-linked or URL-anchored event
* that is no longer the representative still resolves to its row.
*/
function findItemByEventId(
items: readonly InboxItem[],
eventId: string,
): InboxItem | null {
// Fast path: representative event matches (the common case).
const direct = items.find((item) => item.id === eventId);
if (direct) return direct;
// Slow path: event is a non-representative group member (e.g. original
// mention that was later superseded by a newer reply as the representative).
return (
items.find((item) => item.groupItems.some((gi) => gi.id === eventId)) ??
null
);
}

type HomeViewProps = {
feed?: HomeFeedResponse;
isLoading?: boolean;
Expand Down Expand Up @@ -190,6 +170,7 @@ export function HomeView({
);
const { goChannel } = useAppNavigation();
const openDmMutation = useOpenDmMutation();
const openDm = openDmMutation.mutateAsync;
// handleUserSelectItem: explicit selection — only patches the URL.
// No local setSelectedEventId call; the URL patch triggers a TanStack Router
// navigation which updates urlSelectedItemId, which becomes selectedEventId
Expand Down Expand Up @@ -240,10 +221,10 @@ export function HomeView({
const [isSendingReply, setIsSendingReply] = React.useState(false);
const handleOpenDm = React.useCallback(
async (pubkeys: string[]) => {
const dm = await openDmMutation.mutateAsync({ pubkeys });
const dm = await openDm({ pubkeys });
await goChannel(dm.id);
},
[goChannel, openDmMutation],
[goChannel, openDm],
);
const { activeReminderEventIds, openReminder } = useRemindLater();
const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState<
Expand Down Expand Up @@ -322,6 +303,10 @@ export function HomeView({
threadContextFeedItem,
channelMessages,
);
const { editMessage, isEditingMessage } = useInboxEditMessage(
selectedChannel,
threadContext.refreshStructuralEvents,
);

const feedProfilePubkeys = React.useMemo(
() => [
Expand Down Expand Up @@ -403,7 +388,7 @@ export function HomeView({
const selectedItemFromAll = React.useMemo(
() =>
selectedEventId
? (findItemByEventId(inboxItems, selectedEventId) ?? null)
? (findInboxItemByEventId(inboxItems, selectedEventId) ?? null)
: null,
[inboxItems, selectedEventId],
);
Expand Down Expand Up @@ -440,7 +425,7 @@ export function HomeView({
const selectedItem = React.useMemo(() => {
if (!selectedEventId) return null;
// Primary: find by event anchor in the filtered view.
const fromFiltered = findItemByEventId(filteredItems, selectedEventId);
const fromFiltered = findInboxItemByEventId(filteredItems, selectedEventId);
if (fromFiltered) return fromFiltered;
// Secondary: event anchor is in an unfiltered row (e.g., dismissed item).
if (selectedItemFromAll) return selectedItemFromAll;
Expand Down Expand Up @@ -491,7 +476,11 @@ export function HomeView({
? (feedProfiles?.[currentPubkey.toLowerCase()]?.avatarUrl ?? null)
: null;
const timelineMessages = formatTimelineMessages(
[...threadContext.events, ...reactionEvents],
[
...threadContext.events,
...threadContext.structuralEvents,
...reactionEvents,
],
selectedChannel,
currentPubkey,
currentUserAvatarUrl,
Expand Down Expand Up @@ -522,6 +511,7 @@ export function HomeView({
selectedItem,
threadContext.events,
threadContext.reactionEvents,
threadContext.structuralEvents,
]);
const selectedItemReplies = React.useMemo<InboxReply[]>(() => {
if (!selectedItem) return [];
Expand Down Expand Up @@ -808,6 +798,7 @@ export function HomeView({
currentPubkey={currentPubkey}
disabledReplyReason={disabledReplyReason}
isDeletingMessage={isDeletingMessage}
isEditingMessage={isEditingMessage}
isSendingReply={isSendingReply}
isSinglePanelView={isSinglePanelDetailView}
isThreadContextLoading={threadContext.isLoading}
Expand Down Expand Up @@ -845,6 +836,7 @@ export function HomeView({
handleCloseProfilePanel();
setManagedChannelId(channelId);
}}
onEditSave={editMessage}
onOpenContext={onOpenContext}
onSendReply={async ({
content,
Expand Down Expand Up @@ -899,7 +891,7 @@ export function HomeView({
id: result.eventId,
parentId: result.parentEventId,
rootId: result.rootEventId,
tags: emojiTags,
tags: [...imetaTags, ...emojiTags, ...mentionTags],
timeLabel: formatTime(result.createdAt),
};
setLocalRepliesByItemId((current) => ({
Expand Down
70 changes: 68 additions & 2 deletions desktop/src/features/home/ui/InboxDetailPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
isWithinGroupingWindow,
} from "@/features/messages/lib/messageGrouping";
import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys";
import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage";
import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
import { getThreadReference } from "@/features/messages/lib/threading";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
Expand Down Expand Up @@ -55,6 +57,7 @@ type InboxDetailPaneProps = {
canReply: boolean;
disabledReplyReason?: string | null;
isDeletingMessage?: boolean;
isEditingMessage?: boolean;
isSendingReply?: boolean;
isSinglePanelView?: boolean;
isThreadContextLoading?: boolean;
Expand Down Expand Up @@ -82,6 +85,12 @@ type InboxDetailPaneProps = {
latchedDefaultParentId?: string | null;
onBack?: () => void;
onDelete: () => void;
onEditSave: (input: {
content: string;
eventId: string;
mediaTags?: string[][];
mentionPubkeys?: string[];
}) => Promise<void>;
onManageChannel: (channelId: string) => void;
onOpenContext: (
channelId: string,
Expand All @@ -108,6 +117,7 @@ export function InboxDetailPane({
canReply,
disabledReplyReason,
isDeletingMessage = false,
isEditingMessage = false,
isSendingReply = false,
isSinglePanelView = false,
isThreadContextLoading = false,
Expand All @@ -122,6 +132,7 @@ export function InboxDetailPane({
latchedDefaultParentId = null,
onBack,
onDelete,
onEditSave,
onManageChannel,
onOpenContext,
onSendReply,
Expand All @@ -133,6 +144,7 @@ export function InboxDetailPane({
const contentRef = React.useRef<HTMLDivElement | null>(null);
const composerWrapperRef = React.useRef<HTMLDivElement | null>(null);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
const [isFocusHighlightVisible, setIsFocusHighlightVisible] =
React.useState(true);
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
Expand Down Expand Up @@ -230,6 +242,7 @@ export function InboxDetailPane({
React.useEffect(() => {
void conversationId;
setReplyTargetId(null);
setEditTargetId(null);
}, [conversationId]);

React.useEffect(() => {
Expand Down Expand Up @@ -348,6 +361,16 @@ export function InboxDetailPane({

const replyTarget =
displayMessages.find((message) => message.id === replyTargetId) ?? null;
const editTarget =
displayMessages.find((message) => message.id === editTargetId) ?? null;
const composerEditTarget = editTarget
? {
author: editTarget.authorLabel,
body: editTarget.content,
id: editTarget.id,
imetaMedia: imetaMediaFromTags(editTarget.tags),
}
: null;
// Explicit sub-message reply wins. Otherwise use the captured default parent
// (derived from the selected-event anchor at conversation entry), which does
// not change when a live incoming message advances the representative item.
Expand Down Expand Up @@ -377,6 +400,14 @@ export function InboxDetailPane({
setReplyTargetId((currentReplyTargetId) =>
currentReplyTargetId === message.id ? null : message.id,
);
setEditTargetId(null);
focusComposer();
};
const handleSelectEditTarget = (message: InboxDisplayMessage) => {
setEditTargetId((currentEditTargetId) =>
currentEditTargetId === message.id ? null : message.id,
);
setReplyTargetId(null);
focusComposer();
};

Expand Down Expand Up @@ -496,6 +527,24 @@ export function InboxDetailPane({
message.createdAt,
);

const canManageMessage = canManageMessageForCurrentUser(
{
id: message.id,
author: message.authorLabel,
body: message.content,
createdAt: message.createdAt,
depth: message.depth,
kind: message.kind,
pubkey: message.authorPubkey,
time: message.timeLabel ?? message.fullTimestampLabel,
},
currentPubkey,
profiles,
);

const canEditMessage =
channel?.archivedAt === null && canManageMessage;

return (
<InboxMessageRow
agentPubkeys={agentPubkeys}
Expand All @@ -506,6 +555,7 @@ export function InboxDetailPane({
isFocusHighlightVisible={isFocusHighlightVisible}
key={message.id}
message={message}
onEdit={canEditMessage ? handleSelectEditTarget : undefined}
onSelectReplyTarget={handleSelectReplyTarget}
onToggleReaction={onToggleReaction}
/>
Expand Down Expand Up @@ -550,12 +600,28 @@ export function InboxDetailPane({
channelName={item.channelLabel ?? "channel"}
channelType={composerChannelType}
containerClassName="px-4 pb-4 sm:px-4"
disabled={!canReply}
disabled={!canReply && !composerEditTarget}
draftKey={`thread:${item.conversationId}`}
isSending={isSendingReply}
editTarget={composerEditTarget}
isSending={isSendingReply || isEditingMessage}
onCancelEdit={
composerEditTarget ? () => setEditTargetId(null) : undefined
}
onCancelReply={
composerReplyTarget ? () => setReplyTargetId(null) : undefined
}
onEditSave={async (content, mediaTags, mentionPubkeys) => {
if (!composerEditTarget) {
return;
}
await onEditSave({
content,
eventId: composerEditTarget.id,
mediaTags,
mentionPubkeys,
});
setEditTargetId(null);
}}
onSend={(content, mentionPubkeys, mediaTags) =>
onSendReply({
content,
Expand Down
5 changes: 4 additions & 1 deletion desktop/src/features/home/ui/InboxMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type InboxMessageRowProps = {
isFirst?: boolean;
isFocusHighlightVisible: boolean;
message: InboxDisplayMessage;
onEdit?: (message: InboxDisplayMessage) => void;
onSelectReplyTarget: (message: InboxDisplayMessage) => void;
onToggleReaction?: (
message: TimelineMessage,
Expand All @@ -46,6 +47,7 @@ export function InboxMessageRow({
isFirst = false,
isFocusHighlightVisible,
message,
onEdit,
onSelectReplyTarget,
onToggleReaction,
}: InboxMessageRowProps) {
Expand Down Expand Up @@ -112,7 +114,7 @@ export function InboxMessageRow({
: "home-inbox-context-message"
}
>
{canReply || canToggleReactions ? (
{canReply || canToggleReactions || onEdit ? (
<div
className={cn(
"absolute right-2 top-1 z-10",
Expand All @@ -122,6 +124,7 @@ export function InboxMessageRow({
<MessageActionBar
channelId={channelId}
message={timelineMessage}
onEdit={onEdit ? () => onEdit(message) : undefined}
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined
}
Expand Down
Loading
Loading