From c2fafabf88eef4f93b4aba8c390dd95c901a7596 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Mon, 31 Aug 2026 20:16:00 -0300 Subject: [PATCH 1/2] fix: reactions not displaying on thread main message --- app/lib/methods/loadThreadMessages.test.ts | 172 +++++++++++++++++++++ app/lib/methods/loadThreadMessages.ts | 41 ++++- 2 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 app/lib/methods/loadThreadMessages.test.ts diff --git a/app/lib/methods/loadThreadMessages.test.ts b/app/lib/methods/loadThreadMessages.test.ts new file mode 100644 index 00000000000..110c39e7ce2 --- /dev/null +++ b/app/lib/methods/loadThreadMessages.test.ts @@ -0,0 +1,172 @@ +import { loadThreadMessages } from './loadThreadMessages'; +import database from '../database'; +import { getThreadById } from '../database/services/Thread'; +import { Encryption } from '../encryption'; +import sdk from '../services/sdk'; + +jest.mock('../services/sdk', () => ({ + __esModule: true, + default: { methodCallWrapper: jest.fn() } +})); + +jest.mock('../database', () => ({ + __esModule: true, + default: { active: {} } +})); + +jest.mock('../database/services/Thread', () => ({ + getThreadById: jest.fn() +})); + +jest.mock('../encryption', () => ({ + Encryption: { decryptMessages: jest.fn((messages: any) => Promise.resolve(messages)) } +})); + +jest.mock('./helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('@nozbe/watermelondb/RawRecord', () => ({ + sanitizedRaw: jest.fn((raw: any) => raw) +})); + +jest.mock('ejson', () => ({ + __esModule: true, + default: { fromJSONValue: (value: any) => value } +})); + +const mockedMethodCall = sdk.methodCallWrapper as jest.MockedFunction; +const mockedGetThreadById = getThreadById as jest.MockedFunction; + +const TMID = 'PARENT_ID'; +const RID = 'ROOM_ID'; + +const buildParent = (updatedAt: Date, reactions: any) => ({ + _id: TMID, + rid: RID, + msg: 'parent', + tlm: new Date(), + tcount: 1, + _updatedAt: updatedAt, + reactions +}); + +const buildReply = () => ({ _id: 'REPLY_ID', rid: RID, tmid: TMID, msg: 'reply', _updatedAt: new Date() }); + +let batched: any[] = []; +let threadsCreated: any[] = []; + +const setupDatabase = () => { + batched = []; + threadsCreated = []; + const threadsCollection = { + schema: {}, + prepareCreate: jest.fn((fn: any) => { + const record: any = {}; + fn(record); + threadsCreated.push(record); + return record; + }) + }; + const threadMessagesCollection = { + schema: {}, + query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })), + prepareCreate: jest.fn((fn: any) => { + const record: any = {}; + fn(record); + return record; + }) + }; + (database as any).active = { + get: jest.fn((table: string) => (table === 'threads' ? threadsCollection : threadMessagesCollection)), + write: jest.fn((fn: any) => fn()), + batch: jest.fn((records: any[]) => { + batched = records; + }) + }; +}; + +describe('loadThreadMessages', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDatabase(); + }); + + it('creates the threads record from the parent returned by getThreadMessages', async () => { + const parent = buildParent(new Date('2026-01-02'), [{ emoji: ':thumbsup:', usernames: ['rocket.cat'] }]); + mockedMethodCall.mockResolvedValue([parent, buildReply()] as any); + mockedGetThreadById.mockResolvedValue(null); + + await loadThreadMessages({ tmid: TMID, rid: RID }); + + expect(threadsCreated).toHaveLength(1); + expect(threadsCreated[0].reactions).toEqual(parent.reactions); + expect(batched).toContain(threadsCreated[0]); + }); + + it('updates a stale threads record so newer reactions reach the UI', async () => { + const parent = buildParent(new Date('2026-01-02'), [{ emoji: ':thumbsup:', usernames: ['rocket.cat'] }]); + mockedMethodCall.mockResolvedValue([parent, buildReply()] as any); + + const updated: any = {}; + const threadRecord = { + id: TMID, + _updatedAt: new Date('2026-01-01'), + prepareUpdate: jest.fn((fn: any) => { + fn(updated); + return updated; + }) + }; + mockedGetThreadById.mockResolvedValue(threadRecord as any); + + await loadThreadMessages({ tmid: TMID, rid: RID }); + + expect(threadRecord.prepareUpdate).toHaveBeenCalled(); + expect(updated.reactions).toEqual(parent.reactions); + expect(batched).toContain(updated); + }); + + it('leaves an up-to-date threads record untouched', async () => { + mockedMethodCall.mockResolvedValue([buildParent(new Date('2026-01-01'), []), buildReply()] as any); + + const threadRecord = { id: TMID, _updatedAt: new Date('2026-01-01'), prepareUpdate: jest.fn() }; + mockedGetThreadById.mockResolvedValue(threadRecord as any); + + await loadThreadMessages({ tmid: TMID, rid: RID }); + + expect(threadRecord.prepareUpdate).not.toHaveBeenCalled(); + expect(threadsCreated).toHaveLength(0); + }); + + it('does not write the parent into thread_messages', async () => { + mockedMethodCall.mockResolvedValue([buildParent(new Date('2026-01-02'), []), buildReply()] as any); + mockedGetThreadById.mockResolvedValue(null); + + await loadThreadMessages({ tmid: TMID, rid: RID }); + + const threadMessageRecords = batched.filter(r => !threadsCreated.includes(r)); + expect(threadMessageRecords).toHaveLength(1); + expect(threadMessageRecords[0]._id).toBe('REPLY_ID'); + }); + + it('still resolves when the server returns no parent', async () => { + mockedMethodCall.mockResolvedValue([buildReply()] as any); + mockedGetThreadById.mockResolvedValue(null); + + await loadThreadMessages({ tmid: TMID, rid: RID }); + + expect(mockedGetThreadById).not.toHaveBeenCalled(); + expect(threadsCreated).toHaveLength(0); + }); + + it('decrypts the parent along with the replies', async () => { + const parent = buildParent(new Date('2026-01-02'), []); + mockedMethodCall.mockResolvedValue([parent, buildReply()] as any); + mockedGetThreadById.mockResolvedValue(null); + + await loadThreadMessages({ tmid: TMID, rid: RID }); + + expect(Encryption.decryptMessages).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ _id: TMID })])); + }); +}); diff --git a/app/lib/methods/loadThreadMessages.ts b/app/lib/methods/loadThreadMessages.ts index 10e568e356b..86c9623532f 100644 --- a/app/lib/methods/loadThreadMessages.ts +++ b/app/lib/methods/loadThreadMessages.ts @@ -1,4 +1,4 @@ -import { Q } from '@nozbe/watermelondb'; +import { type Model, Q } from '@nozbe/watermelondb'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import EJSON from 'ejson'; @@ -7,7 +7,8 @@ import log from './helpers/log'; import { Encryption } from '../encryption'; import protectedFunction from './helpers/protectedFunction'; import buildMessage from './helpers/buildMessage'; -import { type TThreadMessageModel } from '../../definitions'; +import { type TThreadMessageModel, type TThreadModel } from '../../definitions'; +import { getThreadById } from '../database/services/Thread'; import sdk from '../services/sdk'; async function load({ tmid }: { tmid: string }) { @@ -23,14 +24,44 @@ async function load({ tmid }: { tmid: string }) { } } +// The only refresh the threads record gets on open, so updates missed by the room stream reach the UI. +async function prepareThreadUpsert(threadParent: TThreadModel | undefined, rid: string): Promise { + if (!threadParent) { + return null; + } + const threadsCollection = database.active.get('threads'); + const threadRecord = await getThreadById(threadParent._id); + if (!threadRecord) { + return threadsCollection.prepareCreate( + protectedFunction((t: TThreadModel) => { + t._raw = sanitizedRaw({ id: threadParent._id }, threadsCollection.schema); + Object.assign(t, threadParent); + if (t.subscription) { + t.subscription.id = rid; + } + }) + ); + } + if (threadRecord._updatedAt < threadParent._updatedAt) { + return threadRecord.prepareUpdate( + protectedFunction((t: TThreadModel) => { + Object.assign(t, threadParent); + }) + ); + } + return null; +} + export function loadThreadMessages({ tmid, rid }: { tmid: string; rid: string }) { return new Promise(async (resolve, reject) => { try { let data = await load({ tmid }); if (data && data.length) { try { - data = data.filter((m: TThreadMessageModel) => m.tmid).map((m: TThreadMessageModel) => buildMessage(m)); + data = data.map((m: TThreadMessageModel) => buildMessage(m)); data = await Encryption.decryptMessages(data); + const threadParent = data.find((m: TThreadMessageModel) => m._id === tmid); + data = data.filter((m: TThreadMessageModel) => m.tmid); const db = database.active; const threadMessagesCollection = db.get('thread_messages'); const allThreadMessagesRecords = await threadMessagesCollection.query(Q.where('rid', tmid)).fetch(); @@ -72,8 +103,10 @@ export function loadThreadMessages({ tmid, rid }: { tmid: string; rid: string }) ); }); + const threadToUpsert = await prepareThreadUpsert(threadParent, rid); + await db.write(async () => { - await db.batch([...threadMessagesToCreate, ...threadMessagesToUpdate]); + await db.batch([threadToUpsert, ...threadMessagesToCreate, ...threadMessagesToUpdate].filter(Boolean) as Model[]); }); } catch (e) { log(e); From e04131c95a1b04bcb3a9de08cd7a5e4f7d16c830 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 2 Sep 2026 18:39:05 -0300 Subject: [PATCH 2/2] fix: guard loadThreadMessages against null messages from getThreadMessages --- app/lib/methods/loadThreadMessages.test.ts | 38 ++++++++++++++++++++-- app/lib/methods/loadThreadMessages.ts | 7 ++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/app/lib/methods/loadThreadMessages.test.ts b/app/lib/methods/loadThreadMessages.test.ts index 110c39e7ce2..1b7ea037fe3 100644 --- a/app/lib/methods/loadThreadMessages.test.ts +++ b/app/lib/methods/loadThreadMessages.test.ts @@ -1,4 +1,5 @@ import { loadThreadMessages } from './loadThreadMessages'; +import { type IReaction } from '../../definitions'; import database from '../database'; import { getThreadById } from '../database/services/Thread'; import { Encryption } from '../encryption'; @@ -42,7 +43,25 @@ const mockedGetThreadById = getThreadById as jest.MockedFunction ({ +interface IParentFixture { + _id: string; + rid: string; + msg: string; + tlm: Date; + tcount: number; + _updatedAt: Date; + reactions: Partial[]; +} + +interface IReplyFixture { + _id: string; + rid: string; + tmid: string; + msg: string; + _updatedAt: Date; +} + +const buildParent = (updatedAt: Date, reactions: Partial[]): IParentFixture => ({ _id: TMID, rid: RID, msg: 'parent', @@ -52,12 +71,12 @@ const buildParent = (updatedAt: Date, reactions: any) => ({ reactions }); -const buildReply = () => ({ _id: 'REPLY_ID', rid: RID, tmid: TMID, msg: 'reply', _updatedAt: new Date() }); +const buildReply = (): IReplyFixture => ({ _id: 'REPLY_ID', rid: RID, tmid: TMID, msg: 'reply', _updatedAt: new Date() }); let batched: any[] = []; let threadsCreated: any[] = []; -const setupDatabase = () => { +const setupDatabase = (): void => { batched = []; threadsCreated = []; const threadsCollection = { @@ -160,6 +179,19 @@ describe('loadThreadMessages', () => { expect(threadsCreated).toHaveLength(0); }); + it('drops messages buildMessage could not normalize before decrypting', async () => { + mockedMethodCall.mockResolvedValue([buildParent(new Date('2026-01-02'), []), null, buildReply()] as any); + mockedGetThreadById.mockResolvedValue(null); + + await loadThreadMessages({ tmid: TMID, rid: RID }); + + expect(Encryption.decryptMessages).toHaveBeenCalledWith([ + expect.objectContaining({ _id: TMID }), + expect.objectContaining({ _id: 'REPLY_ID' }) + ]); + expect(threadsCreated).toHaveLength(1); + }); + it('decrypts the parent along with the replies', async () => { const parent = buildParent(new Date('2026-01-02'), []); mockedMethodCall.mockResolvedValue([parent, buildReply()] as any); diff --git a/app/lib/methods/loadThreadMessages.ts b/app/lib/methods/loadThreadMessages.ts index 86c9623532f..acf4d8770ee 100644 --- a/app/lib/methods/loadThreadMessages.ts +++ b/app/lib/methods/loadThreadMessages.ts @@ -52,13 +52,16 @@ async function prepareThreadUpsert(threadParent: TThreadModel | undefined, rid: return null; } -export function loadThreadMessages({ tmid, rid }: { tmid: string; rid: string }) { +export function loadThreadMessages({ tmid, rid }: { tmid: string; rid: string }): Promise { return new Promise(async (resolve, reject) => { try { let data = await load({ tmid }); if (data && data.length) { try { - data = data.map((m: TThreadMessageModel) => buildMessage(m)); + data = data + .filter(Boolean) + .map((m: TThreadMessageModel) => buildMessage(m)) + .filter((m: TThreadMessageModel | null): m is TThreadMessageModel => !!m); data = await Encryption.decryptMessages(data); const threadParent = data.find((m: TThreadMessageModel) => m._id === tmid); data = data.filter((m: TThreadMessageModel) => m.tmid);