Skip to content

Commit fb8e682

Browse files
morgmartnpub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex
andauthored
Preserve persistent agent audience order (#1989)
Signed-off-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co>
1 parent 9b64974 commit fb8e682

17 files changed

Lines changed: 532 additions & 129 deletions

‎desktop/src/features/channels/ui/ChannelPane.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -757,7 +757,6 @@ export const ChannelPane = React.memo(function ChannelPane({
757757
<WelcomeComposerBanner state={welcomeComposerBannerState} />
758758
) : null}
759759
<MessageComposer
760-
audienceContext={{ type: "timeline" }}
761760
channelId={activeChannel?.id ?? null}
762761
channelName={activeChannel?.name ?? "channel"}
763762
channelType={activeChannel?.channelType ?? null}

‎desktop/src/features/home/ui/HomeView.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,7 @@ export function HomeView({
769769
item={selectedItem}
770770
latchedDefaultParentId={latchedDefaultParentId}
771771
messages={contextMessages}
772+
profiles={feedProfiles}
772773
selectedEventId={selectedEventId}
773774
onBack={
774775
isSinglePanelDetailView

‎desktop/src/features/home/ui/InboxDetailPane.tsx‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@ import {
1818
hasSameMessageAuthor,
1919
isWithinGroupingWindow,
2020
} from "@/features/messages/lib/messageGrouping";
21+
import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys";
2122
import { getThreadReference } from "@/features/messages/lib/threading";
23+
import { normalizePubkey } from "@/shared/lib/pubkey";
2224
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
2325
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
2426
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
25-
import type { Channel } from "@/shared/api/types";
27+
import type { Channel, UserProfileSummary } from "@/shared/api/types";
28+
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
2629
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
2730
import { cn } from "@/shared/lib/cn";
2831
import { Button } from "@/shared/ui/button";
@@ -56,6 +59,7 @@ type InboxDetailPaneProps = {
5659
isThreadContextLoading?: boolean;
5760
item: InboxItem | null;
5861
messages?: InboxContextMessage[];
62+
profiles?: Record<string, UserProfileSummary>;
5963
replies?: InboxReply[];
6064
channel: Channel | null;
6165
contextChannelName?: string | null;
@@ -108,6 +112,7 @@ export function InboxDetailPane({
108112
isThreadContextLoading = false,
109113
item,
110114
messages = [],
115+
profiles,
111116
replies = [],
112117
channel,
113118
contextChannelName = null,
@@ -138,6 +143,40 @@ export function InboxDetailPane({
138143
// Live arrivals rerun its layout compensation without changing the target.
139144

140145
const selectedMessage = messages.find((message) => message.isSelected);
146+
// A latest reply can represent an Inbox conversation. Resolve the actual
147+
// root from loaded context or the complete feed group; never treat an
148+
// unresolved root/profile lookup as an authoritative empty audience.
149+
const contextRoot = messages.find((message) => message.id === conversationId);
150+
const feedRoot = item
151+
? [item.item, ...item.groupItems].find(
152+
(groupItem) => groupItem.id === conversationId,
153+
)
154+
: undefined;
155+
const rootMessage = contextRoot
156+
? {
157+
authorPubkey: contextRoot.authorPubkey,
158+
content: contextRoot.content,
159+
mentionPubkeysByName: contextRoot.mentionPubkeysByName,
160+
}
161+
: feedRoot && profiles
162+
? {
163+
authorPubkey: feedRoot.pubkey,
164+
content: feedRoot.content,
165+
mentionPubkeysByName: resolveMentionProps(feedRoot.tags, profiles)
166+
.mentionPubkeysByName,
167+
}
168+
: null;
169+
const initialAgentPubkeys = rootMessage
170+
? currentPubkey &&
171+
normalizePubkey(rootMessage.authorPubkey) ===
172+
normalizePubkey(currentPubkey)
173+
? orderMentionPubkeysByText(
174+
rootMessage.content,
175+
rootMessage.mentionPubkeysByName,
176+
(pubkey) => agentPubkeys?.has(pubkey) === true,
177+
)
178+
: []
179+
: undefined;
141180
const pendingReplyMessages: InboxDisplayMessage[] = replies.map((reply) => ({
142181
...reply,
143182
depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1,
@@ -475,6 +514,7 @@ export function InboxDetailPane({
475514
audienceContext={{
476515
type: "thread",
477516
threadRootId: item.conversationId,
517+
initialAgentPubkeys,
478518
}}
479519
channelId={item.item.channelId}
480520
channelName={item.channelLabel ?? "channel"}

‎desktop/src/features/messages/lib/hasMention.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ function escapeRegExp(str: string): string {
1717
*
1818
* Exported separately so it can be unit-tested without importing React.
1919
*/
20-
export function hasMention(text: string, name: string): boolean {
20+
export function getMentionOffset(text: string, name: string): number | null {
2121
const escaped = escapeRegExp(name);
2222
const pattern = new RegExp(
23-
`(?:^|\\s|\\(|[*_]{1,3}|\\|\\|)@${escaped}(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
23+
`(^|\\s|\\(|[*_]{1,3}|\\|\\|)(@${escaped})(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
2424
"i",
2525
);
26-
return pattern.test(text);
26+
const match = pattern.exec(text);
27+
return match ? match.index + match[1].length : null;
28+
}
29+
30+
export function hasMention(text: string, name: string): boolean {
31+
return getMentionOffset(text, name) !== null;
2732
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { orderMentionPubkeysByText } from "./orderMentionPubkeys.ts";
5+
6+
const AGENT_A = "a".repeat(64);
7+
const AGENT_B = "b".repeat(64);
8+
9+
test("orders eligible mention pubkeys by authored text instead of map insertion", () => {
10+
const ordered = orderMentionPubkeysByText(
11+
"@Vogue please pair with @Morgarita",
12+
{ morgarita: AGENT_A, vogue: AGENT_B },
13+
() => true,
14+
);
15+
16+
assert.deepEqual(ordered, [AGENT_B, AGENT_A]);
17+
});
18+
19+
test("dedupes aliases at their earliest authored position", () => {
20+
const ordered = orderMentionPubkeysByText(
21+
"@Morg please pair with @Vogue and @Morgarita",
22+
{ morgarita: AGENT_A, vogue: AGENT_B, morg: AGENT_A },
23+
() => true,
24+
);
25+
26+
assert.deepEqual(ordered, [AGENT_A, AGENT_B]);
27+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { getMentionOffset } from "@/features/messages/lib/hasMention";
2+
import { normalizePubkey } from "@/shared/lib/pubkey";
3+
4+
export function orderMentionPubkeysByText(
5+
text: string,
6+
mentionPubkeysByName: Readonly<Record<string, string>> | undefined,
7+
isEligible: (pubkey: string) => boolean,
8+
): string[] {
9+
if (!mentionPubkeysByName) return [];
10+
11+
const earliestOffsetByPubkey = new Map<string, number>();
12+
for (const [name, pubkey] of Object.entries(mentionPubkeysByName)) {
13+
const normalized = normalizePubkey(pubkey);
14+
const offset = getMentionOffset(text, name);
15+
if (offset === null || !isEligible(normalized)) continue;
16+
17+
const previousOffset = earliestOffsetByPubkey.get(normalized);
18+
if (previousOffset === undefined || offset < previousOffset) {
19+
earliestOffsetByPubkey.set(normalized, offset);
20+
}
21+
}
22+
23+
return [...earliestOffsetByPubkey.entries()]
24+
.sort(([leftPubkey, leftOffset], [rightPubkey, rightOffset]) =>
25+
leftOffset === rightOffset
26+
? leftPubkey.localeCompare(rightPubkey)
27+
: leftOffset - rightOffset,
28+
)
29+
.map(([pubkey]) => pubkey);
30+
}

‎desktop/src/features/messages/lib/persistentAgentAudience.test.mjs‎

Lines changed: 42 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -32,42 +32,43 @@ function savedAudiences() {
3232

3333
test("conversation scopes isolate identities, channels, and threads", async () => {
3434
const store = await loadStore();
35-
const channelA = store.getPersistentAgentAudienceScope({
36-
ownerPubkey: ownerA,
37-
channelId: "channel-a",
38-
});
39-
const channelB = store.getPersistentAgentAudienceScope({
40-
ownerPubkey: ownerA,
41-
channelId: "channel-b",
42-
});
43-
const threadA1 = store.getPersistentAgentAudienceScope({
44-
ownerPubkey: ownerA,
45-
channelId: "channel-a",
46-
threadRootId: "root-1",
47-
});
48-
const threadA2 = store.getPersistentAgentAudienceScope({
49-
ownerPubkey: ownerA,
50-
channelId: "channel-a",
51-
threadRootId: "root-2",
52-
});
53-
const otherIdentity = store.getPersistentAgentAudienceScope({
54-
ownerPubkey: ownerB,
55-
channelId: "channel-a",
56-
});
57-
58-
for (const scope of [channelA, channelB, threadA1, threadA2, otherIdentity]) {
35+
const scopes = [
36+
store.getPersistentAgentAudienceScope({
37+
ownerPubkey: ownerA,
38+
channelId: "channel-a",
39+
threadRootId: "root-1",
40+
}),
41+
store.getPersistentAgentAudienceScope({
42+
ownerPubkey: ownerA,
43+
channelId: "channel-a",
44+
threadRootId: "root-2",
45+
}),
46+
store.getPersistentAgentAudienceScope({
47+
ownerPubkey: ownerA,
48+
channelId: "channel-b",
49+
threadRootId: "root-1",
50+
}),
51+
store.getPersistentAgentAudienceScope({
52+
ownerPubkey: ownerB,
53+
channelId: "channel-a",
54+
threadRootId: "root-1",
55+
}),
56+
];
57+
58+
for (const scope of scopes) {
5959
assert.ok(scope);
6060
store.setPersistentAgentAudience(scope, [agentA]);
6161
}
6262

63-
assert.equal(new Set(Object.keys(savedAudiences())).size, 5);
63+
assert.equal(new Set(Object.keys(savedAudiences())).size, 4);
6464
});
6565

6666
test("successful fast send promotes without a persisted draft key", async () => {
6767
const store = await loadStore(1);
6868
const scope = store.getPersistentAgentAudienceScope({
6969
ownerPubkey: ownerA,
7070
channelId: "channel-a",
71+
threadRootId: "root",
7172
});
7273
store.setPersistentAgentAudienceEnabled(true);
7374

@@ -206,42 +207,26 @@ test("new recipients retain explicit mention order", async () => {
206207
assert.deepEqual(savedAudiences(), { [scope]: [agentB, agentA] });
207208
});
208209

209-
test("first new-message send resolves its destination after capturing generation", async () => {
210+
test("timeline scope is intentionally unsupported", async () => {
210211
const store = await loadStore(7);
211-
const capturedGeneration = store.getPersistentAgentAudienceGeneration();
212-
store.setPersistentAgentAudienceEnabled(true);
213-
const scope = store.getPersistentAgentAudienceScope({
214-
ownerPubkey: ownerA,
215-
channelId: "resolved-dm",
216-
});
217-
218-
store.promotePersistentAgentAudience({
219-
expectedGeneration: capturedGeneration,
220-
expectedRevision: null,
221-
scope,
222-
explicitAgentPubkeys: [agentA],
223-
});
224-
225-
assert.deepEqual(savedAudiences(), { [scope]: [agentA] });
212+
assert.equal(
213+
store.getPersistentAgentAudienceScope({
214+
ownerPubkey: ownerA,
215+
channelId: "channel-a",
216+
}),
217+
null,
218+
);
226219
});
227220

228-
test("disable during new-message destination preparation invalidates promotion", async () => {
229-
const store = await loadStore(8);
230-
store.setPersistentAgentAudienceEnabled(true);
231-
const capturedGeneration = store.getPersistentAgentAudienceGeneration();
232-
store.setPersistentAgentAudienceEnabled(false);
221+
test("thread root audience initializes once and explicit clear wins on reopen", async () => {
222+
const store = await loadStore(10);
223+
const scope = `${ownerA}:channel-a:thread:root`;
233224
store.setPersistentAgentAudienceEnabled(true);
234-
const scope = store.getPersistentAgentAudienceScope({
235-
ownerPubkey: ownerA,
236-
channelId: "resolved-dm",
237-
});
238225

239-
store.promotePersistentAgentAudience({
240-
expectedGeneration: capturedGeneration,
241-
expectedRevision: null,
242-
scope,
243-
explicitAgentPubkeys: [agentA],
244-
});
226+
store.initializePersistentAgentAudience(scope, [agentB, agentA]);
227+
assert.deepEqual(savedAudiences(), { [scope]: [agentB, agentA] });
245228

246-
assert.deepEqual(savedAudiences(), {});
229+
store.setPersistentAgentAudience(scope, []);
230+
store.initializePersistentAgentAudience(scope, [agentA]);
231+
assert.deepEqual(savedAudiences(), { [scope]: [] });
247232
});

‎desktop/src/features/messages/lib/persistentAgentAudience.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ export function getPersistentAgentAudienceScope({
113113
}: PersistentAgentAudienceScopeInput): string | null {
114114
const owner = ownerPubkey.trim().toLowerCase();
115115
if (!/^[0-9a-f]{64}$/.test(owner) || !channelId) return null;
116-
const conversation = threadRootId ? `thread:${threadRootId}` : "timeline";
117-
return `${owner}:${channelId}:${conversation}`;
116+
if (!threadRootId) return null;
117+
return `${owner}:${channelId}:thread:${threadRootId}`;
118118
}
119119

120120
export function getPersistentAgentAudienceGeneration(): number {
@@ -125,6 +125,14 @@ export function getPersistentAgentAudienceRevision(scope: string): number {
125125
return revisions.get(scope) ?? defaultRevision;
126126
}
127127

128+
export function initializePersistentAgentAudience(
129+
scope: string,
130+
pubkeys: Iterable<string>,
131+
): void {
132+
if (!enabled || !scope || Object.hasOwn(audiences, scope)) return;
133+
setPersistentAgentAudience(scope, pubkeys);
134+
}
135+
128136
export function setPersistentAgentAudience(
129137
scope: string,
130138
pubkeys: Iterable<string>,
@@ -208,6 +216,7 @@ export function usePersistentAgentAudience(scope: string | null): {
208216
promotePubkeys: typeof promotePersistentAgentAudience;
209217
removePubkey: (pubkey: string) => void;
210218
clear: () => void;
219+
initialize: (pubkeys: Iterable<string>) => void;
211220
} {
212221
const state = React.useSyncExternalStore(
213222
subscribe,
@@ -232,5 +241,9 @@ export function usePersistentAgentAudience(scope: string | null): {
232241
() => setPersistentAgentAudience(resolvedScope, []),
233242
[resolvedScope],
234243
),
244+
initialize: React.useCallback(
245+
(pubkeys) => initializePersistentAgentAudience(resolvedScope, pubkeys),
246+
[resolvedScope],
247+
),
235248
};
236249
}

0 commit comments

Comments
 (0)