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
7 changes: 6 additions & 1 deletion desktop/src/features/home/ui/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,11 @@ export function HomeView({
? (feedProfiles?.[currentPubkey.toLowerCase()]?.avatarUrl ?? null)
: null;
const timelineMessages = formatTimelineMessages(
[...threadContext.events, ...reactionEvents],
[
...threadContext.events,
...reactionEvents,
...threadContext.deletionEvents,
],
selectedChannel,
currentPubkey,
currentUserAvatarUrl,
Expand Down Expand Up @@ -522,6 +526,7 @@ export function HomeView({
selectedItem,
threadContext.events,
threadContext.reactionEvents,
threadContext.deletionEvents,
]);
const selectedItemReplies = React.useMemo<InboxReply[]>(() => {
if (!selectedItem) return [];
Expand Down
62 changes: 61 additions & 1 deletion desktop/src/features/home/useInboxThreadContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { isInboxThreadContextEvent } from "@/features/home/lib/inboxViewHelpers"
import { relayEventFromFeedItem } from "@/features/home/lib/inbox";
import { getThreadReference } from "@/features/messages/lib/threading";
import { relayClient } from "@/shared/api/relayClient";
import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters";
import {
buildChannelReactionAuxFilter,
buildChannelStructuralAuxFilter,
} from "@/shared/api/relayChannelFilters";
import { getEventById } from "@/shared/api/tauri";
import type { FeedItem, RelayEvent } from "@/shared/api/types";
import { HOME_MENTION_EVENT_KINDS } from "@/shared/constants/kinds";
Expand All @@ -14,6 +17,16 @@ type InboxThreadContextResult = {
isLoading: boolean;
/** kind:7 events referencing the context messages, fetched by `#e`. */
reactionEvents: RelayEvent[];
/**
* kind:5 / kind:9005 deletion (and kind:40003 edit) markers referencing the
* context messages, fetched by `#e`. The relay omits soft-deleted rows and
* never returns the deletion markers themselves through the content-kind
* descendant fetch, so a deleted message can survive in `events` via the
* latched feed item or the local channel-window cache. Feeding these markers
* into `formatTimelineMessages` lets its existing suppression pass drop the
* deleted rows — matching the channel timeline's structural-aux backfill.
*/
deletionEvents: RelayEvent[];
/** Re-fetch reaction events (e.g. after a toggle) without reloading context. */
refreshReactions: () => Promise<void>;
};
Expand Down Expand Up @@ -238,10 +251,57 @@ export function useInboxThreadContext(
}
}, [fetchReactions]);

// The relay never returns kind:5/9005 deletion markers through the
// content-kind descendant fetch above, and it omits soft-deleted rows — but
// a deleted message can still reach `events` via the latched feed item or the
// local channel-window cache. Fetch the structural markers (deletions + edits)
// by `#e` over the rendered context ids so `formatTimelineMessages` can apply
// them, exactly as the channel timeline does with its structural-aux backfill.
const [deletionEvents, setDeletionEvents] = React.useState<RelayEvent[]>([]);

const fetchDeletions = React.useCallback(async (): Promise<
RelayEvent[] | null
> => {
const eventIds = contextEventIdsKey ? contextEventIdsKey.split(",") : [];
if (!selectedChannelId || eventIds.length === 0) {
return [];
}

try {
return await relayClient.fetchAuxEventsByReference(
selectedChannelId,
eventIds,
buildChannelStructuralAuxFilter,
);
} catch (error) {
console.error(
"Failed to hydrate deletion markers for Inbox context messages",
selectedChannelId,
error,
);
return null;
}
}, [contextEventIdsKey, selectedChannelId]);

React.useEffect(() => {
let isCancelled = false;

void fetchDeletions().then((fetched) => {
if (!isCancelled && fetched !== null) {
setDeletionEvents(fetched);
}
});

return () => {
isCancelled = true;
};
}, [fetchDeletions]);

return {
events,
isLoading,
reactionEvents,
deletionEvents,
refreshReactions,
};
}
95 changes: 95 additions & 0 deletions desktop/tests/e2e/inbox-live-update.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1533,4 +1533,99 @@ test.describe("inbox stable-conversation regressions", () => {
// Drift compensation uses scrollBy — must NOT call scrollIntoView again.
expect(await getScrollIntoViewCount(page)).toBe(1);
});

// Regression: a deleted thread reply must NOT survive in the inbox detail
// pane. The relay omits soft-deleted rows AND never returns the kind:5/9005
// deletion marker through the content-kind descendant fetch, so the inbox
// could still render the deleted message re-injected from the latched feed
// item / local channel-window cache. `useInboxThreadContext` now backfills
// the structural (deletion) markers by `#e` and feeds them into
// `formatTimelineMessages`, which suppresses the deleted row — mirroring the
// channel timeline. Before the fix, "Secret reply to delete" stayed visible.
test("deleted thread reply is suppressed in the inbox detail pane", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(getListPane(page)).toBeVisible();
await waitForBridgeReady(page);

const { root, deletedReply } = await page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) throw new Error("Bridge helpers not ready");

const root = emit({
channelName: "general",
content: "Thread root that mentions me — the inbox item.",
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "d0".repeat(32),
});

const deletedReply = emit({
channelName: "general",
content: "Secret reply to delete.",
parentEventId: root.id,
pubkey: senderPubkey,
id: "d1".repeat(32),
});

const survivingReply = emit({
channelName: "general",
content: "Reply that stays visible.",
parentEventId: root.id,
pubkey: senderPubkey,
id: "d2".repeat(32),
});

// kind:9005 (Buzz-native) deletion marker referencing the reply. It has
// no channel content kind, so the content-kind descendant fetch skips
// it; the structural-aux backfill picks it up by `#e`.
emit({
channelName: "general",
content: "",
pubkey: senderPubkey,
kind: 9005,
extraTags: [["e", deletedReply.id]],
id: "d9".repeat(32),
});

push({
id: root.id,
kind: root.kind,
pubkey: root.pubkey,
content: root.content,
created_at: root.created_at,
channel_id: channelId,
channel_name: "general",
tags: root.tags,
category: "mention",
});

return { root, deletedReply, survivingReply };
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);

// Open the inbox item.
await page.getByTestId(`home-inbox-item-${root.id}`).click();
const detail = getDetailPane(page);

// The surviving reply must render — proves context loaded and the
// structural-aux fetch did not over-suppress.
await expect(detail).toContainText("Reply that stays visible.");

// The deleted reply must NOT render. Assert after the surviving reply is
// present so we know context hydration completed before checking absence.
await expect(detail).not.toContainText("Secret reply to delete.");
// Belt-and-suspenders: no row keyed by the deleted event id.
void deletedReply;
});
});