feat: encrypt-on-send and decrypt-at-ingest for 2-party DMs (Phase 1b FE)

Buzz DM bodies were sent plaintext from the desktop client; the relay's
ciphertext latch now requires NIP-44 v2 for every content kind in a latched
DM. The renderer (formatTimelineMessages) is synchronous and reads
event.content directly, so decryption cannot live there — it happens at the
async cache-population boundary (Option A), keeping the cache plaintext-only
so dedup, overlays, and the renderer all see plaintext for free.

Encrypts the body once before the REST/WS branch on send and in the edit
mutation, scoped to channelType==dm with exactly one non-self participant.
The WS send path overrides the returned event content back to plaintext so
the optimistic-match re-key compares plaintext on both sides. The
fail-visible placeholder substitutes only when valid v2 ciphertext fails to
decrypt — legacy plaintext is shape-checked and passed through untouched.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Will Pfleger
2026-06-25 17:55:44 -04:00
parent 556496ae50
commit b7a24f561a
8 changed files with 478 additions and 36 deletions
@@ -172,12 +172,18 @@ export function ChannelScreen({
: current;
});
}, [activeChannelId, openThreadHeadId]);
const messagesQuery = useChannelMessagesQuery(activeChannel);
useChannelSubscription(activeChannel);
const messagesQuery = useChannelMessagesQuery(activeChannel, currentPubkey);
useChannelSubscription(activeChannel, currentPubkey);
const { fetchOlder, hasOlderMessages, isFetchingOlder } =
useFetchOlderMessages(activeChannel);
// Newest top-level message only: opening a channel should clear the timeline
// without clearing unread thread replies.
useFetchOlderMessages(activeChannel, currentPubkey);
// Newest TOP-LEVEL message only. The channel read-marker must clear the
// channel timeline without clearing its threads (NIP-RS Option 1): thread
// replies are kind-9 channel events, so taking the last message outright
// would advance the channel frontier past unread replies and the hierarchical
// effective(thread) = max(thread, channel) would silently clear every thread
// badge on channel entry. Scanning from the end for the last message with no
// reply tag keeps the frontier at the last top-level message, leaving thread
// badges intact until the thread itself is read.
const latestActiveMessage = React.useMemo(() => {
const messages = messagesQuery.data;
if (!messages) return null;
@@ -238,7 +244,10 @@ export function ChannelScreen({
);
const toggleReactionMutation = useToggleReactionMutation();
const deleteMessageMutation = useDeleteMessageMutation(activeChannel);
const editMessageMutation = useEditMessageMutation(activeChannel);
const editMessageMutation = useEditMessageMutation(
activeChannel,
currentPubkey,
);
const joinChannelMutation = useJoinChannelMutation(activeChannelId);
const resolvedMessages = React.useMemo(() => {
const currentMessages = messagesQuery.data ?? [];
+4 -1
View File
@@ -185,7 +185,10 @@ export function HomeView({
homeInboxWidthPx > 0 &&
homeInboxWidthPx < THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX;
const channelMessagesQuery = useChannelMessagesQuery(selectedChannel);
const channelMessagesQuery = useChannelMessagesQuery(
selectedChannel,
currentPubkey,
);
const toggleReactionMutation = useToggleReactionMutation();
const channelMessages = channelMessagesQuery.data;
const threadContext = useInboxThreadContext(
+77 -20
View File
@@ -24,6 +24,7 @@ import {
addReaction,
deleteMessage,
editMessage,
nip44EncryptToPeer,
removeReaction,
sendChannelMessage,
} from "@/shared/api/tauri";
@@ -32,6 +33,10 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
// from the on-render overlay.
import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs";
import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill";
import {
dmPeerPubkey,
makeDmIngestDecryptor,
} from "@/features/messages/lib/dmCrypto";
import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages";
import {
MIN_TOP_LEVEL_ROWS_PER_FETCH,
@@ -168,9 +173,13 @@ function createOptimisticMessage(
};
}
export function useChannelMessagesQuery(channel: Channel | null) {
export function useChannelMessagesQuery(
channel: Channel | null,
selfPubkey?: string,
) {
const queryClient = useQueryClient();
const queryKey = channelMessagesKey(channel?.id ?? "none");
const decryptIngested = makeDmIngestDecryptor(channel, selfPubkey);
return useQuery({
enabled: channel !== null && channel.channelType !== "forum",
@@ -181,9 +190,11 @@ export function useChannelMessagesQuery(channel: Channel | null) {
throw new Error("No channel selected.");
}
const history = await relayClient.fetchChannelHistory(
channel.id,
CHANNEL_HISTORY_LIMIT,
const history = await decryptIngested(
await relayClient.fetchChannelHistory(
channel.id,
CHANNEL_HISTORY_LIMIT,
),
);
const currentMessages =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
@@ -194,7 +205,12 @@ export function useChannelMessagesQuery(channel: Channel | null) {
// Paint messages immediately; backfill their reactions/edits/deletions
// by `#e` in the background (it self-merges into the same cache key).
void backfillAuxForMessages(queryClient, channel.id, history);
void backfillAuxForMessages(
queryClient,
channel.id,
history,
decryptIngested,
);
// Seed the cache, then — only if the cold window renders thinner than a
// normal scroll page — top it up to the same visible-row floor. A
@@ -208,6 +224,7 @@ export function useChannelMessagesQuery(channel: Channel | null) {
queryClient,
channel.id,
() => true,
decryptIngested,
);
}
return queryClient.getQueryData<RelayEvent[]>(queryKey) ?? mergedHistory;
@@ -217,18 +234,21 @@ export function useChannelMessagesQuery(channel: Channel | null) {
});
}
export function useChannelSubscription(channel: Channel | null) {
export function useChannelSubscription(
channel: Channel | null,
selfPubkey?: string,
) {
const queryClient = useQueryClient();
const channelId = channel?.id ?? null;
const channelType = channel?.channelType ?? null;
const decryptIngested = makeDmIngestDecryptor(channel, selfPubkey);
const syncLatestHistory = useEffectEvent(async () => {
if (!channelId) {
return;
}
const history = await relayClient.fetchChannelHistory(
channelId,
CHANNEL_HISTORY_LIMIT,
const history = await decryptIngested(
await relayClient.fetchChannelHistory(channelId, CHANNEL_HISTORY_LIMIT),
);
queryClient.setQueryData<RelayEvent[]>(
@@ -236,22 +256,29 @@ export function useChannelSubscription(channel: Channel | null) {
(current = []) => mergeTimelineHistoryMessages(current, history),
);
void backfillAuxForMessages(queryClient, channelId, history);
void backfillAuxForMessages(
queryClient,
channelId,
history,
decryptIngested,
);
});
const appendMessage = useEffectEvent((event: RelayEvent) => {
const appendMessage = useEffectEvent(async (event: RelayEvent) => {
if (!channelId) {
return;
}
const [decrypted] = await decryptIngested([event]);
queryClient.setQueryData<RelayEvent[]>(
channelMessagesKey(channelId),
(current = []) => mergeTimelineCacheMessages(current, event),
(current = []) => mergeTimelineCacheMessages(current, decrypted),
);
if (event.kind === KIND_SYSTEM_MESSAGE) {
if (decrypted.kind === KIND_SYSTEM_MESSAGE) {
try {
const payload = JSON.parse(event.content) as { type?: string };
const payload = JSON.parse(decrypted.content) as { type?: string };
if (
payload.type === "member_joined" ||
payload.type === "member_left" ||
@@ -293,7 +320,9 @@ export function useChannelSubscription(channel: Channel | null) {
relayClient
.subscribeToChannel(channelId, (event) => {
if (!isDisposed) {
appendMessage(event);
void appendMessage(event).catch((error) => {
console.error("Failed to append channel message", channelId, error);
});
}
})
.then((dispose) => {
@@ -358,6 +387,16 @@ export function useSendMessageMutation(
throw new Error("No identity available for sending messages.");
}
// Encrypt the body once, before the REST/WS branch, when this is a
// 2-party DM — both transports send `wireContent`. The plaintext `content`
// is preserved for the optimistic cache copy and the success re-key, so
// the cache holds plaintext (matching the decrypt-at-ingest funnel) while
// the wire and relay only ever see ciphertext.
const peerPubkey = dmPeerPubkey(channel, identity.pubkey);
const wireContent = peerPubkey
? await nip44EncryptToPeer(peerPubkey, content.trim())
: content;
// `mediaTags` arrives as the merged outgoing tag set (imeta + NIP-30
// emoji). Split it so each kind goes to its own validated Tauri arg —
// emoji tags must NOT ride the imeta-only `media` channel (that gate
@@ -378,7 +417,7 @@ export function useSendMessageMutation(
) ?? [];
const result = await sendChannelMessage(
channel.id,
content,
wireContent,
parentEventId ?? null,
imetaTags,
mentionPubkeys,
@@ -429,12 +468,18 @@ export function useSendMessageMutation(
};
}
return relayClient.sendMessage(
const result = await relayClient.sendMessage(
channel.id,
content,
wireContent,
mentionPubkeys ?? [],
mentionTags,
);
// For a DM, `result.content` is the ciphertext the relay stored. Override
// it with the plaintext so `onSuccess` re-keys the optimistic copy to a
// plaintext-bodied event — matching the cache invariant the REST branch
// already upholds (it synthesizes `content: content.trim()`). A no-op
// outside DMs, where `wireContent === content`.
return peerPubkey ? { ...result, content: content.trim() } : result;
},
onMutate: async ({ content, mentionPubkeys, parentEventId, mediaTags }) => {
if (!channel || !identity || channel.channelType === "forum") {
@@ -538,7 +583,10 @@ export function useDeleteMessageMutation(channel: Channel | null) {
});
}
export function useEditMessageMutation(channel: Channel | null) {
export function useEditMessageMutation(
channel: Channel | null,
selfPubkey?: string,
) {
const queryClient = useQueryClient();
return useMutation<
@@ -555,13 +603,22 @@ export function useEditMessageMutation(channel: Channel | null) {
throw new Error("No channel selected.");
}
// Encrypt the edit body for a 2-party DM so the relay's ciphertext gate
// accepts it (a plaintext kind-40003 into a latched DM is rejected). The
// plaintext `content` flows on to the `onSuccess` cache update, keeping
// the cache plaintext-only like the send path and decrypt-at-ingest.
const peerPubkey = dmPeerPubkey(channel, selfPubkey);
const wireContent = peerPubkey
? await nip44EncryptToPeer(peerPubkey, content)
: content;
// `mediaTags` arrives as the merged outgoing set (imeta + NIP-30 emoji).
// Split so each rides its own validated Tauri arg — emoji tags must NOT
// go through the imeta-only `mediaTags` channel (the Rust `imeta_tags`
// guard rejects any non-imeta prefix), mirroring the send path.
const { mediaTags: imetaTags, emojiTags } = splitOutgoingTags(mediaTags);
await editMessage(channel.id, eventId, content, imetaTags, emojiTags);
await editMessage(channel.id, eventId, wireContent, imetaTags, emojiTags);
},
onSuccess: (_data, { eventId, content, mediaTags }) => {
if (!channel) {
@@ -84,6 +84,9 @@ export async function backfillAuxForMessages(
queryClient: QueryClient,
channelId: string,
historyEvents: RelayEvent[],
decryptBatch: (events: RelayEvent[]) => Promise<RelayEvent[]> = async (
events,
) => events,
): Promise<void> {
const messageIds = collectMessageIdsForAuxBackfill(historyEvents);
if (messageIds.length === 0) {
@@ -109,8 +112,13 @@ export async function backfillAuxForMessages(
return;
}
// Edit events (kind 40003) carry an encrypted DM body the renderer overlays
// onto the original message — decrypt them before they reach the cache so
// the overlay shows plaintext. A no-op outside 2-party DMs.
const decryptedAuxEvents = await decryptBatch(mergedAuxEvents);
queryClient.setQueryData<RelayEvent[]>(cacheKey, (current = []) =>
sortMessages([...current, ...mergedAuxEvents]),
sortMessages([...current, ...decryptedAuxEvents]),
);
} catch (error) {
console.error(
@@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
UNDECRYPTABLE_DM_PLACEHOLDER,
decryptIngestedContent,
dmPeerPubkey,
looksLikeNip44V2,
makeDmIngestDecryptor,
} from "./dmCrypto.ts";
import {
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_EDIT,
} from "@/shared/constants/kinds";
// base64(0x02 + 98 zero bytes) — 99 decoded bytes, the minimal valid NIP-44 v2
// envelope (1 version + 32 nonce + 32 MAC + 34 ciphertext floor). 132 chars.
const VALID_V2 =
"AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
// Same length and alphabet, but first decoded byte is 0x00, not 0x02.
const WRONG_VERSION =
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
// ── looksLikeNip44V2 — mirrors relay validate_nip44_v2 envelope check ─────────
test("looksLikeNip44V2 accepts a minimal valid v2 envelope", () => {
assert.equal(looksLikeNip44V2(VALID_V2), true);
});
test("looksLikeNip44V2 rejects content with wrong version prefix", () => {
assert.equal(looksLikeNip44V2(WRONG_VERSION), false);
});
test("looksLikeNip44V2 rejects content shorter than 99 decoded bytes", () => {
assert.equal(looksLikeNip44V2("AgAAAAAAAAAAAAA="), false);
});
test("looksLikeNip44V2 rejects empty content", () => {
assert.equal(looksLikeNip44V2(""), false);
});
test("looksLikeNip44V2 rejects plaintext that isn't base64-shaped", () => {
assert.equal(looksLikeNip44V2("hey, lunch at noon?"), false);
});
test("looksLikeNip44V2 rejects base64 with a non-trailing pad char", () => {
// '=' before the last two positions is malformed padding.
const bad = `Ag==${VALID_V2.slice(4)}`;
assert.equal(looksLikeNip44V2(bad), false);
});
// ── AC1: decryptIngestedContent — valid-v2-undecryptable vs malformed ─────────
const peer = "a".repeat(64);
const throwingDecrypt = async () => {
throw new Error("nip44 decrypt failed: hmac mismatch");
};
const okDecrypt = async (_peer, ciphertext) =>
`decrypted(${ciphertext.slice(0, 4)})`;
test("decryptIngestedContent substitutes placeholder when valid v2 ciphertext fails to decrypt", async () => {
const body = await decryptIngestedContent(
{ kind: KIND_STREAM_MESSAGE, content: VALID_V2 },
peer,
throwingDecrypt,
);
assert.equal(body, UNDECRYPTABLE_DM_PLACEHOLDER);
});
test("decryptIngestedContent passes legacy plaintext through unchanged on decrypt failure", async () => {
// The critical distinction: malformed / never-encrypted content must NOT
// become the placeholder. It is not v2-shaped, so decrypt is never attempted.
const body = await decryptIngestedContent(
{ kind: KIND_STREAM_MESSAGE, content: "hey, lunch at noon?" },
peer,
throwingDecrypt,
);
assert.equal(body, "hey, lunch at noon?");
});
test("decryptIngestedContent returns plaintext when valid v2 ciphertext decrypts", async () => {
const body = await decryptIngestedContent(
{ kind: KIND_STREAM_MESSAGE, content: VALID_V2 },
peer,
okDecrypt,
);
assert.equal(body, "decrypted(AgAA)");
});
test("decryptIngestedContent decrypts edit-kind content (AC2 ingest side)", async () => {
const body = await decryptIngestedContent(
{ kind: KIND_STREAM_MESSAGE_EDIT, content: VALID_V2 },
peer,
okDecrypt,
);
assert.equal(body, "decrypted(AgAA)");
});
test("decryptIngestedContent skips decrypt when there is no DM peer", async () => {
const body = await decryptIngestedContent(
{ kind: KIND_STREAM_MESSAGE, content: VALID_V2 },
null,
throwingDecrypt,
);
assert.equal(body, VALID_V2);
});
test("decryptIngestedContent does not decrypt non-content kinds (system messages)", async () => {
const KIND_SYSTEM_MESSAGE = 40099;
const body = await decryptIngestedContent(
{ kind: KIND_SYSTEM_MESSAGE, content: VALID_V2 },
peer,
throwingDecrypt,
);
assert.equal(body, VALID_V2);
});
// ── AC3: dmPeerPubkey — peer = participants minus self, 2-party only ──────────
test("dmPeerPubkey returns the single non-self participant in a 2-party DM", () => {
const channel = { channelType: "dm", participantPubkeys: ["SELF", "PEER"] };
assert.equal(dmPeerPubkey(channel, "self"), "PEER");
});
test("dmPeerPubkey is case-insensitive on self matching", () => {
const channel = { channelType: "dm", participantPubkeys: ["AbC", "def"] };
assert.equal(dmPeerPubkey(channel, "abc"), "def");
});
test("dmPeerPubkey returns null for a group DM with more than one peer", () => {
const channel = {
channelType: "dm",
participantPubkeys: ["self", "p1", "p2"],
};
assert.equal(dmPeerPubkey(channel, "self"), null);
});
test("dmPeerPubkey returns null for non-DM channels", () => {
const channel = {
channelType: "stream",
participantPubkeys: ["self", "peer"],
};
assert.equal(dmPeerPubkey(channel, "self"), null);
});
test("dmPeerPubkey returns null when self pubkey is unknown", () => {
const channel = { channelType: "dm", participantPubkeys: ["a", "b"] };
assert.equal(dmPeerPubkey(channel, undefined), null);
});
// ── makeDmIngestDecryptor — no-op outside a 2-party DM ────────────────────────
test("makeDmIngestDecryptor returns events untouched outside a 2-party DM", async () => {
const events = [
{ kind: KIND_STREAM_MESSAGE, content: VALID_V2 },
{ kind: KIND_STREAM_MESSAGE, content: "plaintext" },
];
// A stream channel has no DM peer, so the decryptor must be a pure identity
// pass-through — it never touches the IPC primitive, so this stays
// deterministic without a Tauri mock.
const decrypt = makeDmIngestDecryptor(
{ channelType: "stream", participantPubkeys: ["self", "peer"] },
"self",
);
const result = await decrypt(events);
assert.equal(result, events);
});
test("makeDmIngestDecryptor returns events untouched when channel is null", async () => {
const events = [{ kind: KIND_STREAM_MESSAGE, content: VALID_V2 }];
const decrypt = makeDmIngestDecryptor(null, "self");
const result = await decrypt(events);
assert.equal(result, events);
});
@@ -0,0 +1,171 @@
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_EDIT,
} from "@/shared/constants/kinds";
import { nip44DecryptFromPeer } from "@/shared/api/tauri";
import type { Channel, RelayEvent } from "@/shared/api/types";
/**
* Body shown when a DM message is valid NIP-44 v2 ciphertext we cannot decrypt
* (peer used a key/version we can't read) — fail-visible, never blank or
* garbled. Substituted ONLY for undecryptable valid-v2 content; malformed or
* legacy-plaintext content is passed through untouched (see
* `decryptIngestedContent`).
*/
export const UNDECRYPTABLE_DM_PLACEHOLDER =
"[encrypted message — update your client to read it]";
/**
* Kinds that carry a user-authored free-text body in a DM and must therefore be
* encrypted on send and decrypted at ingest. System messages, reactions, and
* deletions carry no peer-encrypted body and are excluded.
*/
function isDmContentKind(kind: number): boolean {
return kind === KIND_STREAM_MESSAGE || kind === KIND_STREAM_MESSAGE_EDIT;
}
/**
* The single NIP-44 peer for a 2-party DM: the one participant that isn't us.
* Returns null when the channel isn't a 2-party DM (open/stream channels,
* self-only, or group DMs with >1 other participant), which is exactly the
* scope where FE peer crypto applies — callers skip encrypt/decrypt on null.
*/
export function dmPeerPubkey(
channel: Pick<Channel, "channelType" | "participantPubkeys">,
selfPubkey: string | undefined,
): string | null {
if (channel.channelType !== "dm" || !selfPubkey) {
return null;
}
const self = normalizePubkey(selfPubkey);
const peers = channel.participantPubkeys.filter(
(pubkey) => normalizePubkey(pubkey) !== self,
);
return peers.length === 1 ? peers[0] : null;
}
/**
* Whether `content` is a syntactically plausible NIP-44 v2 ciphertext payload.
*
* Mirrors the relay's `buzz_core::observer::validate_nip44_v2` envelope check
* exactly so the FE's "this IS encrypted, we just can't read it" judgement
* matches the boundary the relay enforces:
* - standard base64 alphabet, padding only at the end, length a multiple of 4
* - decoded length >= 99 bytes (1 version + 32 nonce + 32 MAC + >=34 ciphertext)
* - first decoded byte is 0x02 (NIP-44 version 2)
*
* It is an envelope check, not decryption: a `true` result means the content is
* shaped like v2 ciphertext, so a decrypt failure is "valid ciphertext we can't
* read" (→ placeholder) rather than "this was never encrypted" (→ pass through).
*/
export function looksLikeNip44V2(content: string): boolean {
const len = content.length;
if (len === 0 || len % 4 !== 0) {
return false;
}
let padCount = 0;
for (let i = 0; i < len; i++) {
const c = content[i];
if (c === "=") {
// Padding is only legal in the final two positions.
if (i < len - 2) {
return false;
}
padCount++;
if (padCount > 2) {
return false;
}
} else if (/[A-Za-z0-9+/]/.test(c)) {
// A base64 char after padding has begun is malformed.
if (padCount > 0) {
return false;
}
} else {
return false;
}
}
const decodedLen = (len / 4) * 3 - padCount;
if (decodedLen < 99) {
return false;
}
const b64Val = (c: string): number => {
const code = c.charCodeAt(0);
if (code >= 65 && code <= 90) return code - 65; // A-Z
if (code >= 97 && code <= 122) return code - 97 + 26; // a-z
if (code >= 48 && code <= 57) return code - 48 + 52; // 0-9
if (c === "+") return 62;
if (c === "/") return 63;
return -1;
};
const firstByte = (b64Val(content[0]) << 2) | (b64Val(content[1]) >> 4);
return firstByte === 0x02;
}
/**
* Decrypt one ingested DM event's content to plaintext for the cache.
*
* Decrypt is attempted only for 2-party-DM content kinds whose content is
* shaped like NIP-44 v2 ciphertext (`looksLikeNip44V2`). Everything else —
* non-DM channels, system/reaction/deletion kinds, and legacy plaintext that
* predates encryption — is returned unchanged.
*
* When the content IS valid v2 ciphertext but `decrypt` throws (peer key/version
* we can't read), the fail-visible placeholder is substituted. This is the AC1
* distinction: a decrypt failure on valid-v2 content shows the placeholder,
* while content that was never v2-shaped is passed through as-is.
*/
export async function decryptIngestedContent(
event: Pick<RelayEvent, "kind" | "content">,
peerPubkey: string | null,
decrypt: (peerPubkey: string, ciphertext: string) => Promise<string>,
): Promise<string> {
if (
peerPubkey === null ||
!isDmContentKind(event.kind) ||
!looksLikeNip44V2(event.content)
) {
return event.content;
}
try {
return await decrypt(peerPubkey, event.content);
} catch {
return UNDECRYPTABLE_DM_PLACEHOLDER;
}
}
/**
* Build the decrypt-at-ingest mapper for a channel: it decrypts encrypted
* 2-party-DM bodies to plaintext (via the real Tauri NIP-44 peer primitive) and
* leaves everything else — non-DM channels, non-content kinds, legacy plaintext
* — untouched. Returns a no-op identity mapper outside a 2-party DM, so every
* ingest site can call it unconditionally without branching on channel type.
*
* Callers pass this to the cache-population paths (history fetch, scrollback,
* aux backfill, live append) so the cache only ever holds plaintext content.
*/
export function makeDmIngestDecryptor(
channel: Pick<Channel, "channelType" | "participantPubkeys"> | null,
selfPubkey: string | undefined,
): (events: RelayEvent[]) => Promise<RelayEvent[]> {
const peerPubkey = channel ? dmPeerPubkey(channel, selfPubkey) : null;
if (peerPubkey === null) {
return (events) => Promise.resolve(events);
}
return (events) =>
Promise.all(
events.map(async (event) => {
const content = await decryptIngestedContent(
event,
peerPubkey,
nip44DecryptFromPeer,
);
return content === event.content ? event : { ...event, content };
}),
);
}
@@ -57,6 +57,9 @@ export async function pageOlderMessagesUntilRowFloor(
queryClient: QueryClient,
channelId: string,
shouldContinue: () => boolean,
decryptBatch: (events: RelayEvent[]) => Promise<RelayEvent[]> = async (
events,
) => events,
): Promise<PageOlderResult> {
const queryKey = channelMessagesKey(channelId);
const baseline = queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
@@ -78,10 +81,12 @@ export async function pageOlderMessagesUntilRowFloor(
// sortMessages dedupes by id. Subtracting 1 risks skipping same-second
// messages.
const oldestTimestamp = before[0].created_at;
const olderMessages = await relayClient.fetchChannelHistoryBefore(
channelId,
oldestTimestamp,
OLDER_MESSAGES_BATCH_SIZE,
const olderMessages = await decryptBatch(
await relayClient.fetchChannelHistoryBefore(
channelId,
oldestTimestamp,
OLDER_MESSAGES_BATCH_SIZE,
),
);
batchesFetched += 1;
@@ -100,7 +105,12 @@ export async function pageOlderMessagesUntilRowFloor(
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
mergeTimelineHistoryMessages(current, olderMessages),
);
void backfillAuxForMessages(queryClient, channelId, olderMessages);
void backfillAuxForMessages(
queryClient,
channelId,
olderMessages,
decryptBatch,
);
}
// Progress guard, not exhaustion: if the oldest timestamp didn't move back
@@ -1,13 +1,21 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
import { makeDmIngestDecryptor } from "@/features/messages/lib/dmCrypto";
import { pageOlderMessagesUntilRowFloor } from "@/features/messages/lib/pageOlderMessages";
import type { Channel, RelayEvent } from "@/shared/api/types";
export function useFetchOlderMessages(channel: Channel | null) {
export function useFetchOlderMessages(
channel: Channel | null,
selfPubkey?: string,
) {
const queryClient = useQueryClient();
const channelId = channel?.id ?? null;
const decryptIngested = useMemo(
() => makeDmIngestDecryptor(channel, selfPubkey),
[channel, selfPubkey],
);
const [isFetchingOlder, setIsFetchingOlder] = useState(false);
const [hasOlderMessages, setHasOlderMessages] = useState(true);
const isFetchingOlderRef = useRef(false);
@@ -45,6 +53,7 @@ export function useFetchOlderMessages(channel: Channel | null) {
queryClient,
channelId,
() => previousChannelIdRef.current === channelId,
decryptIngested,
);
if (!more) {
hasOlderMessagesRef.current = false;
@@ -56,7 +65,7 @@ export function useFetchOlderMessages(channel: Channel | null) {
isFetchingOlderRef.current = false;
setIsFetchingOlder(false);
}
}, [channelId, queryClient]);
}, [channelId, queryClient, decryptIngested]);
return { fetchOlder, isFetchingOlder, hasOlderMessages };
}