diff --git a/broadcast-worker/handler.go b/broadcast-worker/handler.go
index a1f199c83..ec62f8148 100644
--- a/broadcast-worker/handler.go
+++ b/broadcast-worker/handler.go
@@ -39,10 +39,18 @@ type Handler struct {
pub Publisher
keyStore RoomKeyProvider
encrypt bool
+ encoder *roomcrypto.Encoder
}
func NewHandler(store Store, userStore userstore.UserStore, pub Publisher, keyStore RoomKeyProvider, encrypt bool) *Handler {
- return &Handler{store: store, userStore: userStore, pub: pub, keyStore: keyStore, encrypt: encrypt}
+ return &Handler{
+ store: store,
+ userStore: userStore,
+ pub: pub,
+ keyStore: keyStore,
+ encrypt: encrypt,
+ encoder: roomcrypto.NewEncoder(),
+ }
}
// HandleMessage processes a single MESSAGES_CANONICAL message payload.
@@ -209,7 +217,7 @@ func (h *Handler) encryptEditedContent(ctx context.Context, roomID string, edite
if err != nil {
return err
}
- encrypted, err := roomcrypto.Encode(edited.NewContent, key.KeyPair.PublicKey, key.Version)
+ encrypted, err := h.encoder.Encode(roomID, edited.NewContent, key.KeyPair.PrivateKey, key.Version)
if err != nil {
return fmt.Errorf("encrypt edit content for room %s: %w", roomID, err)
}
@@ -253,7 +261,7 @@ func (h *Handler) publishChannelEvent(ctx context.Context, meta roommetacache.Me
return err
}
- encrypted, err := roomcrypto.Encode(string(msgJSON), key.KeyPair.PublicKey, key.Version)
+ encrypted, err := h.encoder.Encode(meta.ID, string(msgJSON), key.KeyPair.PrivateKey, key.Version)
if err != nil {
return fmt.Errorf("encrypt message for room %s: %w", meta.ID, err)
}
diff --git a/broadcast-worker/handler_test.go b/broadcast-worker/handler_test.go
index aa9ea8b8e..1ac9cff63 100644
--- a/broadcast-worker/handler_test.go
+++ b/broadcast-worker/handler_test.go
@@ -658,7 +658,6 @@ func TestHandler_HandleMessage_ChannelRoom_Encryption(t *testing.T) {
var env roomcrypto.EncryptedMessage
require.NoError(t, json.Unmarshal(evt.EncryptedMessage, &env))
assert.Equal(t, key.Version, env.Version)
- assert.NotEmpty(t, env.EphemeralPublicKey)
assert.NotEmpty(t, env.Nonce)
assert.NotEmpty(t, env.Ciphertext)
diff --git a/broadcast-worker/testhelpers_test.go b/broadcast-worker/testhelpers_test.go
index ef8b8caee..d05b1d8c3 100644
--- a/broadcast-worker/testhelpers_test.go
+++ b/broadcast-worker/testhelpers_test.go
@@ -3,16 +3,12 @@ package main
import (
"crypto/aes"
"crypto/cipher"
- "crypto/ecdh"
"crypto/rand"
- "crypto/sha256"
"encoding/json"
"fmt"
- "io"
"testing"
"github.com/stretchr/testify/require"
- "golang.org/x/crypto/hkdf"
"github.com/hmchangw/chat/pkg/model"
"github.com/hmchangw/chat/pkg/roomcrypto"
@@ -21,36 +17,20 @@ import (
func testRoomKey(t *testing.T) *roomkeystore.VersionedKeyPair {
t.Helper()
- priv, err := ecdh.P256().GenerateKey(rand.Reader)
+ buf := make([]byte, 32)
+ _, err := rand.Read(buf)
require.NoError(t, err)
return &roomkeystore.VersionedKeyPair{
Version: 3,
KeyPair: roomkeystore.RoomKeyPair{
- PublicKey: priv.PublicKey().Bytes(),
- PrivateKey: priv.Bytes(),
+ PrivateKey: buf,
},
}
}
func decryptForTest(env *roomcrypto.EncryptedMessage, roomPrivateKey []byte) (string, error) {
- privKey, err := ecdh.P256().NewPrivateKey(roomPrivateKey)
- if err != nil {
- return "", fmt.Errorf("parse room private key: %w", err)
- }
- ephPubKey, err := ecdh.P256().NewPublicKey(env.EphemeralPublicKey)
- if err != nil {
- return "", fmt.Errorf("parse ephemeral public key: %w", err)
- }
- sharedSecret, err := privKey.ECDH(ephPubKey)
- if err != nil {
- return "", fmt.Errorf("ecdh: %w", err)
- }
- aesKey := make([]byte, 32)
- hkdfReader := hkdf.New(sha256.New, sharedSecret, nil, []byte("room-message-encryption"))
- if _, err := io.ReadFull(hkdfReader, aesKey); err != nil {
- return "", fmt.Errorf("hkdf: %w", err)
- }
- block, err := aes.NewCipher(aesKey)
+ // The room private key is used directly as the AES-256-GCM key (no HKDF step).
+ block, err := aes.NewCipher(roomPrivateKey)
if err != nil {
return "", fmt.Errorf("aes cipher: %w", err)
}
diff --git a/chat-frontend/scripts/gen-crypto-fixtures.go b/chat-frontend/scripts/gen-crypto-fixtures.go
new file mode 100644
index 000000000..f73b09ff8
--- /dev/null
+++ b/chat-frontend/scripts/gen-crypto-fixtures.go
@@ -0,0 +1,67 @@
+//go:build ignore
+
+// gen-crypto-fixtures generates a known-plaintext encrypted message for
+// chat-frontend's lib/roomcrypto round-trip tests. Run with:
+//
+// go run chat-frontend/scripts/gen-crypto-fixtures.go > chat-frontend/test/fixtures/encrypted-message.json
+//
+// Commit the output. The fixture exists to lock the cross-language wire
+// format: any change in the server encoder's AES-GCM parameters or wire
+// shape MUST update this fixture together with the corresponding TS
+// decoder.
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/hmchangw/chat/pkg/roomcrypto"
+)
+
+func main() {
+ // Deterministic private key so the fixture is stable across runs.
+ priv := make([]byte, 32)
+ for i := range priv {
+ priv[i] = byte(i + 1)
+ }
+ const plaintext = "fixture plaintext — encoded by the Go server, decoded by chat-frontend"
+ const version = 1
+ const roomID = "fixture-room"
+
+ // Construct an Encoder with a fixed-nonce reader so the ciphertext is
+ // reproducible. Twelve-byte zero nonce is fine for a fixture (it's a
+ // known-key test, not real traffic).
+ enc := roomcrypto.NewEncoder(roomcrypto.WithRand(zeroReader{}))
+ msg, err := enc.Encode(roomID, plaintext, priv, version)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "encode:", err)
+ os.Exit(1)
+ }
+
+ out := struct {
+ PrivateKey string `json:"privateKey"`
+ Plaintext string `json:"plaintext"`
+ Message *roomcrypto.EncryptedMessage `json:"message"`
+ }{
+ PrivateKey: base64.StdEncoding.EncodeToString(priv),
+ Plaintext: plaintext,
+ Message: msg,
+ }
+ data, err := json.MarshalIndent(out, "", " ")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "marshal:", err)
+ os.Exit(1)
+ }
+ fmt.Println(string(data))
+}
+
+type zeroReader struct{}
+
+func (zeroReader) Read(p []byte) (int, error) {
+ for i := range p {
+ p[i] = 0
+ }
+ return len(p), nil
+}
diff --git a/chat-frontend/src/App.jsx b/chat-frontend/src/App.jsx
index 38e1f661f..468c052c8 100644
--- a/chat-frontend/src/App.jsx
+++ b/chat-frontend/src/App.jsx
@@ -1,5 +1,6 @@
import { useEffect, useState, useCallback } from 'react'
import { NatsProvider, useNats } from '@/context/NatsContext'
+import { RoomKeysProvider } from '@/context/RoomKeysContext'
import { RoomEventsProvider } from '@/context/RoomEventsContext'
import { ThreadEventsProvider } from '@/context/ThreadEventsContext'
import LoginPage from '@/pages/LoginPage'
@@ -35,11 +36,13 @@ function AppContent() {
}
return (
-
-
-
-
-
+
+
+
+
+
+
+
)
}
diff --git a/chat-frontend/src/api/_transport/subjects.test.js b/chat-frontend/src/api/_transport/subjects.test.js
index 1424f2c07..ebc7b71d4 100644
--- a/chat-frontend/src/api/_transport/subjects.test.js
+++ b/chat-frontend/src/api/_transport/subjects.test.js
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
userRoomEvent,
+ userRoomKey,
roomEvent,
memberAdd,
memberRemove,
@@ -135,5 +136,11 @@ describe('subjects', () => {
'chat.user.alice.request.user.site-A.subscription.count'
)
})
+})
+describe('userRoomKey', () => {
+ it('builds the per-user room-key event subject', () => {
+ expect(userRoomKey('alice')).toBe('chat.user.alice.event.room.key')
+ })
})
+
diff --git a/chat-frontend/src/api/_transport/subjects.ts b/chat-frontend/src/api/_transport/subjects.ts
index 766af6400..6c44cbce0 100644
--- a/chat-frontend/src/api/_transport/subjects.ts
+++ b/chat-frontend/src/api/_transport/subjects.ts
@@ -56,6 +56,10 @@ export function userRoomEvent(account: string): string {
return `chat.user.${account}.event.room`
}
+export function userRoomKey(account: string): string {
+ return `chat.user.${account}.event.room.key`
+}
+
export function memberAdd(account: string, roomId: string, siteId: string): string {
return `chat.user.${account}.request.room.${roomId}.${siteId}.member.add`
}
diff --git a/chat-frontend/src/api/index.ts b/chat-frontend/src/api/index.ts
index 54dbc8861..e97dadfeb 100644
--- a/chat-frontend/src/api/index.ts
+++ b/chat-frontend/src/api/index.ts
@@ -32,6 +32,7 @@ export { searchRooms } from './searchRooms'
export { sendMessage } from './sendMessage'
export { subscribeToRoomEvents } from './subscribeToRoomEvents'
export { subscribeToRoomMetadataUpdates } from './subscribeToRoomMetadataUpdates'
+export { subscribeToRoomKeyEvents } from './subscribeToRoomKeyEvents'
export { subscribeToSubscriptionUpdates } from './subscribeToSubscriptionUpdates'
export { subscribeToUserRoomEvents } from './subscribeToUserRoomEvents'
export { updateMemberRole } from './updateMemberRole'
@@ -72,4 +73,5 @@ export type {
ChannelRef,
HistoryConfig,
HistoryMode,
+ RoomKeyEvent,
} from './types'
diff --git a/chat-frontend/src/api/subscribeToRoomKeyEvents/index.test.ts b/chat-frontend/src/api/subscribeToRoomKeyEvents/index.test.ts
new file mode 100644
index 000000000..9588c9ad6
--- /dev/null
+++ b/chat-frontend/src/api/subscribeToRoomKeyEvents/index.test.ts
@@ -0,0 +1,15 @@
+import { describe, it, expect, vi } from 'vitest'
+import { subscribeToRoomKeyEvents } from './index'
+
+describe('subscribeToRoomKeyEvents', () => {
+ it('subscribes to chat.user.{account}.event.room.key with the given callback', () => {
+ const subscribe = vi.fn().mockReturnValue({ unsubscribe: vi.fn() })
+ const nats = { subscribe, user: { account: 'alice', id: '' }, request: vi.fn(), publish: vi.fn(), requestWithAsyncResult: vi.fn(), connected: true, error: null }
+ const cb = vi.fn()
+
+ const sub = subscribeToRoomKeyEvents(nats as never, cb)
+
+ expect(subscribe).toHaveBeenCalledWith('chat.user.alice.event.room.key', cb)
+ expect(sub).toBeDefined()
+ })
+})
diff --git a/chat-frontend/src/api/subscribeToRoomKeyEvents/index.ts b/chat-frontend/src/api/subscribeToRoomKeyEvents/index.ts
new file mode 100644
index 000000000..22cd40658
--- /dev/null
+++ b/chat-frontend/src/api/subscribeToRoomKeyEvents/index.ts
@@ -0,0 +1,10 @@
+import { userRoomKey } from '../_transport/subjects'
+import type { Nats, NatsSubscription, SubscriptionCallback } from '../types'
+
+/** Subscribe to the calling user's room-key event stream. */
+export function subscribeToRoomKeyEvents(
+ { subscribe, user }: Pick,
+ callback: SubscriptionCallback,
+): NatsSubscription {
+ return subscribe(userRoomKey(user.account), callback)
+}
diff --git a/chat-frontend/src/api/types.ts b/chat-frontend/src/api/types.ts
index 21dfaf2b5..f25010520 100644
--- a/chat-frontend/src/api/types.ts
+++ b/chat-frontend/src/api/types.ts
@@ -262,6 +262,19 @@ export interface SubscriptionUpdateEvent {
timestamp: number
}
+/**
+ * Mirrors pkg/model.RoomKeyEvent — payload of
+ * chat.user.{account}.event.room.key. PrivateKey is base64-encoded on
+ * the wire (Go's encoding/json default for []byte). PublicKey is
+ * omitted from the client wire payload.
+ */
+export interface RoomKeyEvent {
+ roomId: string
+ version: number
+ privateKey: string // base64
+ timestamp: number
+}
+
/** Two-phase async-job result returned by `requestWithAsyncResult`. */
export interface AsyncJobResult {
requestId: string
diff --git a/chat-frontend/src/components/MainApp/MainApp.integration.test.jsx b/chat-frontend/src/components/MainApp/MainApp.integration.test.jsx
index c0ea51bde..c795f54f7 100644
--- a/chat-frontend/src/components/MainApp/MainApp.integration.test.jsx
+++ b/chat-frontend/src/components/MainApp/MainApp.integration.test.jsx
@@ -23,6 +23,12 @@ import MainApp from './MainApp'
// If this test passes but the user reports the panel doesn't appear, the
// remaining suspects are CSS clipping or browser-specific (nginx caching).
+// RoomEventsProvider calls useRoomKeys() internally. Stub it so this test
+// doesn't need a real RoomKeysProvider (which would try to connect to NATS).
+vi.mock('@/context/RoomKeysContext', () => ({
+ useRoomKeys: () => ({ decrypt: async () => null, hasKey: () => false }),
+}))
+
vi.mock('./AppHeader/AppHeader', () => ({
default: ({ onSelectRoom }) => (
diff --git a/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.test.jsx b/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.test.jsx
index bee3f59f6..aa9fd77e5 100644
--- a/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.test.jsx
+++ b/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.test.jsx
@@ -6,6 +6,14 @@ import { RoomEventsProvider, useRoomEvents, useRoomSummaries, useSidebarSections
import { BUFFER_MODE } from './reducer'
// jumpToMessage / resetToLiveTail tests — see suite below
+// RoomEventsContext now calls useRoomKeys() internally. Stub it out so tests
+// don't need a real RoomKeysProvider (which would try to connect to NATS and
+// fetch key material). The no-op decrypt matches the default used in
+// useRoomSubscriptions when no key is available.
+vi.mock('@/context/RoomKeysContext', () => ({
+ useRoomKeys: () => ({ decrypt: async () => null, hasKey: () => false }),
+}))
+
/** Turn an inline "room-shaped" fixture into a subscription record that
* the new bootstrap (3 subscription RPCs) returns. The real user-service
* embeds room metadata (userCount, lastMsgAt) inline on each subscription
diff --git a/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.tsx b/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.tsx
index 626ec0acc..bf0a28b99 100644
--- a/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.tsx
+++ b/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.tsx
@@ -9,6 +9,7 @@ import {
type ReactNode,
} from 'react'
import { useNats } from '@/context/NatsContext'
+import { useRoomKeys } from '@/context/RoomKeysContext'
import { BUFFER_MODE, initialState, roomEventsReducer } from './reducer'
import { useRoomSubscriptions } from './useRoomSubscriptions'
import { useUnreadCount as useUnreadCountQuery } from './useUnreadCount'
@@ -112,6 +113,7 @@ export function RoomEventsProvider({ children }: { children: ReactNode }) {
// where the NATS handshake has populated user/request/etc.
const nats = useNats() as unknown as Nats
const { user } = nats
+ const { decrypt } = useRoomKeys()
const [state, dispatch] = useReducer(roomEventsReducer, initialState) as unknown as [
RoomEventsState,
Dispatch<{ type: string; [k: string]: unknown }>,
@@ -147,6 +149,7 @@ export function RoomEventsProvider({ children }: { children: ReactNode }) {
stateRef,
threadReplyHandlerRef,
threadMessageMutationHandlerRef,
+ decrypt,
)
const loadHistory = useCallback(
diff --git a/chat-frontend/src/context/RoomEventsContext/reducer.test.js b/chat-frontend/src/context/RoomEventsContext/reducer.test.js
index 7eec28685..3e1be4c36 100644
--- a/chat-frontend/src/context/RoomEventsContext/reducer.test.js
+++ b/chat-frontend/src/context/RoomEventsContext/reducer.test.js
@@ -215,6 +215,32 @@ describe('roomEventsReducer: MESSAGE_RECEIVED', () => {
})
})
+ it('treats a successfully-decrypted MESSAGE_RECEIVED as a normal new-message event', () => {
+ // After Task 25, useRoomSubscriptions decrypts evt.encryptedMessage and
+ // dispatches with .message populated AND .encryptedMessage cleared.
+ // The reducer should see no difference between this and a plaintext event:
+ // no placeholder, content is the decoded plaintext, encrypted flag unset.
+ const event = newMessageEvent({
+ message: {
+ id: 'm-decoded',
+ roomId: 'a',
+ content: 'decrypted body',
+ createdAt: '2026-05-20T00:00:00Z',
+ sender: { account: 'bob', engName: 'Bob' },
+ },
+ lastMsgId: 'm-decoded',
+ encryptedMessage: undefined,
+ })
+ const next = roomEventsReducer(initialState, {
+ type: 'MESSAGE_RECEIVED',
+ event,
+ })
+ const inserted = next.roomState.a.messages.find((m) => m.id === 'm-decoded')
+ expect(inserted).toBeDefined()
+ expect(inserted.content).toBe('decrypted body')
+ expect(inserted.encrypted).toBeFalsy()
+ })
+
it('does not drop an event that has both message and encryptedMessage — plaintext wins', () => {
// Forward-compatible: if a future broadcaster sends both lanes (e.g.
// during a rollout), the plaintext path is authoritative.
diff --git a/chat-frontend/src/context/RoomEventsContext/useRoomSubscriptions.js b/chat-frontend/src/context/RoomEventsContext/useRoomSubscriptions.js
index ed0e42811..f267e3bbb 100644
--- a/chat-frontend/src/context/RoomEventsContext/useRoomSubscriptions.js
+++ b/chat-frontend/src/context/RoomEventsContext/useRoomSubscriptions.js
@@ -51,6 +51,11 @@ const MARK_READ_DEBOUNCE_MS = 500
* the hook reads `stateRef.current.activeRoomId` + `summaries` from
* inside long-lived subscription callbacks to decide whether to fire
* a `markRoomRead` RPC on incoming messages.
+ *
+ * @param {(input: { roomId: string; version: number; nonceB64: string; ciphertextB64: string }) => Promise} [decrypt]
+ * Room-message decryption function from RoomKeysContext. Defaults to a
+ * no-op that always returns null (pass-through: encrypted events reach
+ * the reducer's placeholder branch unchanged).
*/
export function useRoomSubscriptions(
nats,
@@ -58,6 +63,7 @@ export function useRoomSubscriptions(
stateRef,
threadReplyHandlerRef,
threadMessageMutationHandlerRef,
+ decrypt = async () => null,
) {
const { user } = nats
// Keep a live ref to `nats` so long-lived subscription callbacks
@@ -66,6 +72,11 @@ export function useRoomSubscriptions(
const natsRef = useRef(nats)
natsRef.current = nats
+ // Keep a live ref to `decrypt` so subscription callbacks always use
+ // the latest version without restarting the effect.
+ const decryptRef = useRef(decrypt)
+ decryptRef.current = decrypt
+
// Bumped on every login (re)cycle so the provider's async fetch
// callbacks can detect stale-generation dispatches.
const generationRef = useRef(0)
@@ -203,36 +214,116 @@ export function useRoomSubscriptions(
}
}
- const dmSub = subscribeToUserRoomEvents(liveNats, (evt) => {
- if (evt?.type === 'new_message') {
- safeDispatch({ type: 'MESSAGE_RECEIVED', event: evt })
- fanThreadReply(evt)
- // Thread replies don't advance the main-feed lastSeenAt.
- if (!evt.message?.threadParentMessageId) {
- scheduleMarkActiveRead(evt.roomId)
- }
+ // Per-room dispatch chains. Each entry is a Promise representing the
+ // most recent in-flight work for that room. New events for the same
+ // room chain off it via .then(fn, fn) so they observe the same order
+ // they arrived in even when some are encrypted (await deriveAesKey +
+ // GCM.open) and others are plaintext (synchronous). Without this,
+ // a plaintext mutation event can finalize before a prior encrypted
+ // new_message resolves, scrambling the message-list order.
+ const dispatchChains = new Map()
+ const enqueueByRoom = (roomId, work) => {
+ if (!roomId) {
+ work()
return
}
- handleMutationEvent(evt)
+ const prev = dispatchChains.get(roomId) ?? Promise.resolve()
+ const next = prev.then(work, work)
+ dispatchChains.set(roomId, next)
+ }
+
+ // Decrypt encrypted fields on an event, then call finalize(decoded).
+ // Handles two cases:
+ // 1. encryptedMessage (new_message with no plaintext body yet)
+ // 2. messageEdited.encryptedNewContent (edit events in encrypted rooms)
+ // Returns null on the key-not-yet-available path — the caller passes
+ // the event through unchanged and the reducer's placeholder branch
+ // handles the missing body gracefully.
+ const decryptAndDispatch = async (evt, finalize) => {
+ let decoded = evt
+ try {
+ // Handle encrypted full-message events.
+ if (decoded.encryptedMessage && !decoded.message) {
+ const enc = decoded.encryptedMessage
+ if (typeof enc.version === 'number' && enc.nonce && enc.ciphertext) {
+ const plaintext = await decryptRef.current({
+ roomId: decoded.roomId,
+ version: enc.version,
+ nonceB64: enc.nonce,
+ ciphertextB64: enc.ciphertext,
+ })
+ if (plaintext != null) {
+ const msg = JSON.parse(plaintext)
+ decoded = { ...decoded, message: msg, encryptedMessage: undefined }
+ }
+ }
+ }
+ // Handle encrypted message edits.
+ if (decoded.messageEdited && decoded.messageEdited.encryptedNewContent && !decoded.messageEdited.newContent) {
+ const enc = decoded.messageEdited.encryptedNewContent
+ if (typeof enc.version === 'number' && enc.nonce && enc.ciphertext) {
+ const plaintext = await decryptRef.current({
+ roomId: decoded.roomId,
+ version: enc.version,
+ nonceB64: enc.nonce,
+ ciphertextB64: enc.ciphertext,
+ })
+ if (plaintext != null) {
+ decoded = {
+ ...decoded,
+ messageEdited: { ...decoded.messageEdited, newContent: plaintext, encryptedNewContent: undefined },
+ }
+ }
+ }
+ }
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn('decryptAndDispatch failed; forwarding original event', err)
+ }
+ finalize(decoded)
+ }
+
+ const dmSub = subscribeToUserRoomEvents(liveNats, (evt) => {
+ enqueueByRoom(evt?.roomId, () => {
+ if (evt?.type === 'new_message') {
+ return decryptAndDispatch(evt, (decoded) => {
+ safeDispatch({ type: 'MESSAGE_RECEIVED', event: decoded })
+ fanThreadReply(decoded)
+ // Thread replies don't advance the main-feed lastSeenAt.
+ if (!decoded.message?.threadParentMessageId) {
+ scheduleMarkActiveRead(decoded.roomId)
+ }
+ })
+ }
+ handleMutationEvent(evt)
+ })
})
const openChannelSub = (roomId) => {
if (channelSubs.current.has(roomId)) return
const sub = subscribeToRoomEvents(natsRef.current, { roomId }, (evt) => {
- if (evt?.type === 'new_message') {
- const hasMention = (evt.mentions ?? []).some(
- (p) => p.account === user.account
- )
- const normalized = { ...evt, hasMention }
- safeDispatch({ type: 'MESSAGE_RECEIVED', event: normalized })
- fanThreadReply(normalized)
- // See dm path above — skip main-feed mark-read for thread replies.
- if (!evt.message?.threadParentMessageId) {
- scheduleMarkActiveRead(evt.roomId ?? roomId)
+ enqueueByRoom(evt?.roomId ?? roomId, () => {
+ if (evt?.type === 'new_message') {
+ return decryptAndDispatch(evt, (decoded) => {
+ const hasMention = (decoded.mentions ?? []).some(
+ (p) => p.account === user.account
+ )
+ const normalized = { ...decoded, hasMention }
+ safeDispatch({ type: 'MESSAGE_RECEIVED', event: normalized })
+ fanThreadReply(normalized)
+ // See dm path above — skip main-feed mark-read for thread replies.
+ if (!decoded.message?.threadParentMessageId) {
+ scheduleMarkActiveRead(decoded.roomId ?? roomId)
+ }
+ })
}
- return
- }
- handleMutationEvent(evt)
+ if (evt?.type === 'message_edited') {
+ return decryptAndDispatch(evt, (decoded) => {
+ handleMutationEvent(decoded)
+ })
+ }
+ handleMutationEvent(evt)
+ })
})
channelSubs.current.set(roomId, sub)
}
@@ -319,6 +410,7 @@ export function useRoomSubscriptions(
metaUpdate.unsubscribe()
for (const sub of channelSubs.current.values()) sub.unsubscribe()
channelSubs.current.clear()
+ dispatchChains.clear()
// Cancel any in-flight mark-read trailing timer so it doesn't
// fire after teardown (would `markRoomRead` against a dead nc).
if (markReadTimeoutRef.current) {
diff --git a/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.test.jsx b/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.test.jsx
new file mode 100644
index 000000000..86c973f15
--- /dev/null
+++ b/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.test.jsx
@@ -0,0 +1,75 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, waitFor, act } from '@testing-library/react'
+import { RoomKeysProvider, useRoomKeys } from './RoomKeysContext'
+
+vi.mock('@/api', () => ({
+ subscribeToRoomKeyEvents: vi.fn(),
+}))
+
+vi.mock('@/context/NatsContext', () => ({
+ useNats: () => ({
+ user: { account: 'alice', id: 'u1' },
+ request: vi.fn(),
+ subscribe: vi.fn(),
+ publish: vi.fn(),
+ requestWithAsyncResult: vi.fn(),
+ connected: true,
+ error: null,
+ }),
+}))
+
+import { subscribeToRoomKeyEvents } from '@/api'
+
+let lastDecryptHook = null
+
+function Probe() {
+ lastDecryptHook = useRoomKeys()
+ return null
+}
+
+describe('RoomKeysProvider', () => {
+ beforeEach(() => {
+ lastDecryptHook = null
+ vi.mocked(subscribeToRoomKeyEvents).mockReset()
+ })
+
+ it('dispatches KEY_RECEIVED for each live event', async () => {
+ let savedCb
+ vi.mocked(subscribeToRoomKeyEvents).mockImplementation((_n, cb) => {
+ savedCb = cb
+ return { unsubscribe: vi.fn() }
+ })
+
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => expect(savedCb).toBeDefined())
+
+ act(() => {
+ savedCb({
+ roomId: 'r2',
+ version: 3,
+ privateKey: btoa(String.fromCharCode(...new Uint8Array(32).fill(1))),
+ timestamp: Date.now(),
+ })
+ })
+
+ await waitFor(() => expect(lastDecryptHook?.hasKey('r2', 3)).toBe(true))
+ })
+
+ it('unsubscribes and clears state on unmount', async () => {
+ const unsub = { unsubscribe: vi.fn() }
+ vi.mocked(subscribeToRoomKeyEvents).mockReturnValue(unsub)
+
+ const { unmount } = render(
+
+
+ ,
+ )
+ unmount()
+ expect(unsub.unsubscribe).toHaveBeenCalled()
+ })
+})
diff --git a/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.tsx b/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.tsx
new file mode 100644
index 000000000..e8f954901
--- /dev/null
+++ b/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.tsx
@@ -0,0 +1,135 @@
+import { createContext, useCallback, useContext, useEffect, useReducer, useRef } from 'react'
+import { subscribeToRoomKeyEvents } from '@/api'
+import type { Nats, RoomKeyEvent } from '@/api'
+import { useNats } from '@/context/NatsContext'
+import { b64decode, importAesKey, decryptRoomMessage } from '@/lib/roomcrypto'
+import { bytesEqual, initialRoomKeysState, roomKeysReducer } from './reducer'
+
+type DecryptInput = {
+ roomId: string
+ version: number
+ nonceB64: string
+ ciphertextB64: string
+}
+
+type RoomKeysContextValue = {
+ hasKey(roomId: string, version: number): boolean
+ /** Returns null if the key is not (yet) known for that (roomId, version),
+ * or if decryption fails. */
+ decrypt(input: DecryptInput): Promise
+}
+
+const RoomKeysContext = createContext(null)
+
+export function useRoomKeys(): RoomKeysContextValue {
+ const ctx = useContext(RoomKeysContext)
+ if (!ctx) throw new Error('useRoomKeys called outside RoomKeysProvider')
+ return ctx
+}
+
+export function RoomKeysProvider({ children }: { children: React.ReactNode }) {
+ const [state, dispatch] = useReducer(roomKeysReducer, initialRoomKeysState)
+ // `useNats()` returns `never` to TS because NatsContext.jsx does
+ // `createContext(null)` without annotations. Cast here so downstream
+ // callbacks see the proper Nats interface — safe because the
+ // provider only renders inside the `connected` gate at App.jsx,
+ // where the NATS handshake has populated user/request/etc.
+ const nats = useNats() as unknown as Nats
+
+ // CryptoKey cache lives in a ref — imported lazily, not React state.
+ // Keyed by `${roomId}|${version}`.
+ const aesKeyCacheRef = useRef