Skip to content
Merged
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
6 changes: 6 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
useHideDmMutation,
useOpenDmMutation,
} from "@/features/channels/hooks";
import { useDmResurfaceFromMessages } from "@/features/channels/useDmResurfaceFromMessages";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications";
import { useFeedItemState } from "@/features/home/useFeedItemState";
Expand Down Expand Up @@ -505,6 +506,11 @@ export function AppShell() {
const { applyCanvas, applyAgents } = useApplyTemplate();
const openDmMutation = useOpenDmMutation();
const hideDmMutation = useHideDmMutation();
useDmResurfaceFromMessages({
pubkey: identityQuery.data?.pubkey,
relayUrl: communitiesHook.activeCommunity?.relayUrl,
reopen: openDmMutation.mutateAsync,
});
const {
browseDialogType,
openBrowseChannels: handleOpenBrowseChannels,
Expand Down
93 changes: 93 additions & 0 deletions desktop/src/features/channels/dmResurface.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
dmPeerPubkeysFromMembers,
isIncomingChannelMessageFromOther,
markHiddenDmFeedItems,
} from "./dmResurface.ts";

const SELF = "1".repeat(64);
const ALICE = "2".repeat(64);
const BOB = "3".repeat(64);

function item(overrides = {}) {
return {
id: "event-1",
kind: 9,
pubkey: ALICE,
content: "hello",
createdAt: 10,
channelId: "dm-1",
channelName: "",
tags: [
["h", "dm-1"],
["p", SELF],
["p", BOB],
],
category: "mention",
...overrides,
};
}

function relayEvent(overrides = {}) {
return {
id: "event-1",
kind: 40002,
pubkey: ALICE,
content: "hello",
created_at: 10,
tags: [
["h", "dm-1"],
["p", SELF],
["p", BOB],
],
sig: "",
...overrides,
};
}

test("DM resurface derives peers from authoritative membership", () => {
const members = [{ pubkey: SELF }, { pubkey: ALICE }, { pubkey: BOB }];
assert.deepEqual(dmPeerPubkeysFromMembers(members, SELF), [ALICE, BOB]);
assert.deepEqual(dmPeerPubkeysFromMembers([{ pubkey: ALICE }], SELF), []);
});

test("only external channel messages qualify, regardless of p tags", () => {
// #h-scoped delivery already guarantees relevance, so eligibility no longer
// requires a self `p` tag — an untagged DM from another sender still counts.
assert.equal(isIncomingChannelMessageFromOther(relayEvent(), SELF), true);
assert.equal(
isIncomingChannelMessageFromOther(relayEvent({ kind: 7 }), SELF),
false,
);
assert.equal(
isIncomingChannelMessageFromOther(relayEvent({ pubkey: SELF }), SELF),
false,
);
assert.equal(
isIncomingChannelMessageFromOther(relayEvent({ tags: [] }), SELF),
false,
);
assert.equal(
isIncomingChannelMessageFromOther(
relayEvent({ tags: [["h", "dm-1"]] }),
SELF,
),
true,
);
});

test("hidden feed items are projected as DMs for Inbox presentation", () => {
const feed = {
feed: {
mentions: [item()],
needsAction: [],
activity: [],
agentActivity: [],
},
meta: { since: 0, total: 1, generatedAt: 10 },
};
const marked = markHiddenDmFeedItems(feed, new Set(["dm-1"]));
assert.equal(marked.feed.mentions[0].channelType, "dm");
});
66 changes: 66 additions & 0 deletions desktop/src/features/channels/dmResurface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type {
ChannelMember,
FeedItem,
HomeFeedResponse,
RelayEvent,
} from "@/shared/api/types";
import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds";
import { normalizePubkey } from "@/shared/lib/pubkey";

const CHANNEL_MESSAGE_KINDS = new Set<number>(CHANNEL_MESSAGE_EVENT_KINDS);
const HEX_PUBKEY = /^[0-9a-f]{64}$/;

export function dmPeerPubkeysFromMembers(
members: readonly Pick<ChannelMember, "pubkey">[],
currentPubkey: string | undefined,
): string[] {
const self = normalizePubkey(currentPubkey ?? "");
const normalized = [
...new Set(members.map((member) => normalizePubkey(member.pubkey))),
].filter((pubkey) => HEX_PUBKEY.test(pubkey));
if (!HEX_PUBKEY.test(self) || !normalized.includes(self)) return [];
return normalized.filter((pubkey) => pubkey !== self);
}

// The resurface subscription is `#h`-scoped to the hidden-DM set, so the relay
// only delivers events already addressed to a hidden channel the reader belongs
// to. Eligibility therefore drops the `#p` requirement — an untagged DM (a CLI
// or agent send that omits participant `p` tags) still resurfaces the row.
export function isIncomingChannelMessageFromOther(
event: RelayEvent,
currentPubkey: string | undefined,
): boolean {
const self = normalizePubkey(currentPubkey ?? "");
return (
self.length > 0 &&
CHANNEL_MESSAGE_KINDS.has(event.kind) &&
relayEventChannelId(event) !== null &&
normalizePubkey(event.pubkey) !== self
);
}

export function relayEventChannelId(event: RelayEvent): string | null {
return event.tags.find((tag) => tag[0] === "h" && tag[1])?.[1] ?? null;
}

export function markHiddenDmFeedItems(
feed: HomeFeedResponse,
hiddenDmIds: ReadonlySet<string>,
): HomeFeedResponse {
if (hiddenDmIds.size === 0) return feed;

const mark = (item: FeedItem): FeedItem =>
item.channelId && hiddenDmIds.has(item.channelId)
? { ...item, channelType: "dm" }
: item;

return {
...feed,
feed: {
mentions: feed.feed.mentions.map(mark),
needsAction: feed.feed.needsAction.map(mark),
activity: feed.feed.activity.map(mark),
agentActivity: feed.feed.agentActivity.map(mark),
},
};
}
121 changes: 121 additions & 0 deletions desktop/src/features/channels/hiddenDmResurfaceAction.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import test from "node:test";

import { resurfaceHiddenDmMessage } from "./hiddenDmResurfaceAction.ts";

const SELF = "1".repeat(64);
const ALICE = "2".repeat(64);
const BOB = "3".repeat(64);

function event() {
return {
id: "event-1",
kind: 40002,
pubkey: ALICE,
content: "hello",
created_at: 10,
// Message p tags are intentionally incomplete for this group DM.
tags: [
["h", "hidden-dm"],
["p", SELF],
],
sig: "",
};
}

function member(pubkey) {
return {
pubkey,
role: "member",
isAgent: false,
joinedAt: "",
displayName: null,
};
}

test("reopens the source hidden group DM from authoritative membership", async () => {
const inputs = [];
assert.equal(
await resurfaceHiddenDmMessage({
event: event(),
expectedRelayUrl: "wss://relay.example",
expectedSignerPubkey: SELF,
hiddenDmIds: new Set(["hidden-dm"]),
fetchMembers: async () => [member(SELF), member(ALICE), member(BOB)],
isCurrent: () => true,
reopen: async (input) => {
inputs.push(input);
return { id: "hidden-dm" };
},
}),
true,
);
assert.deepEqual(inputs, [
{
pubkeys: [ALICE, BOB],
expectedRelayUrl: "wss://relay.example",
expectedSignerPubkey: SELF,
},
]);
});

test("ignores an event for a channel outside the hidden set", async () => {
let reopenCount = 0;
assert.equal(
await resurfaceHiddenDmMessage({
event: event(),
expectedRelayUrl: "wss://relay.example",
expectedSignerPubkey: SELF,
hiddenDmIds: new Set(["other-dm"]),
fetchMembers: async () => [member(SELF), member(ALICE)],
isCurrent: () => true,
reopen: async () => {
reopenCount += 1;
return { id: "hidden-dm" };
},
}),
false,
);
assert.equal(reopenCount, 0);
});

test("a suspended old-community read cannot reopen a DM", async () => {
let current = true;
let resume;
const members = new Promise((resolve) => {
resume = resolve;
});
let reopenCount = 0;
const result = resurfaceHiddenDmMessage({
event: event(),
expectedRelayUrl: "wss://old.example",
expectedSignerPubkey: SELF,
hiddenDmIds: new Set(["hidden-dm"]),
fetchMembers: async () => members,
isCurrent: () => current,
reopen: async () => {
reopenCount += 1;
return { id: "hidden-dm" };
},
});
await Promise.resolve();
current = false;
resume([member(SELF), member(ALICE)]);
assert.equal(await result, false);
assert.equal(reopenCount, 0);
});

test("rejects a reopen result for any channel other than the source", async () => {
await assert.rejects(
resurfaceHiddenDmMessage({
event: event(),
expectedRelayUrl: "wss://relay.example",
expectedSignerPubkey: SELF,
hiddenDmIds: new Set(["hidden-dm"]),
fetchMembers: async () => [member(SELF), member(ALICE)],
isCurrent: () => true,
reopen: async () => ({ id: "alternate-dm" }),
}),
/different DM conversation/,
);
});
49 changes: 49 additions & 0 deletions desktop/src/features/channels/hiddenDmResurfaceAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { ChannelMember, RelayEvent } from "@/shared/api/types";
import type { OpenDmInput } from "@/shared/api/tauriChannels";
import {
dmPeerPubkeysFromMembers,
isIncomingChannelMessageFromOther,
relayEventChannelId,
} from "./dmResurface";

type HiddenDmResurfaceActionOptions = {
event: RelayEvent;
expectedRelayUrl: string;
expectedSignerPubkey: string;
hiddenDmIds: ReadonlySet<string>;
fetchMembers: (channelId: string) => Promise<readonly ChannelMember[]>;
isCurrent: () => boolean;
reopen: (input: OpenDmInput) => Promise<{ id: string }>;
};

export async function resurfaceHiddenDmMessage({
event,
expectedRelayUrl,
expectedSignerPubkey,
hiddenDmIds,
fetchMembers,
isCurrent,
reopen,
}: HiddenDmResurfaceActionOptions): Promise<boolean> {
if (!isIncomingChannelMessageFromOther(event, expectedSignerPubkey)) {
return false;
}
const channelId = relayEventChannelId(event);
if (!channelId || !hiddenDmIds.has(channelId)) return false;

const members = await fetchMembers(channelId);
if (!isCurrent()) return false;
const pubkeys = dmPeerPubkeysFromMembers(members, expectedSignerPubkey);
if (pubkeys.length === 0) return false;

const opened = await reopen({
pubkeys,
expectedRelayUrl,
expectedSignerPubkey,
});
if (!isCurrent()) return false;
if (opened.id !== channelId) {
throw new Error("Relay reopened a different DM conversation.");
}
return true;
}
Loading
Loading