From 334195ea02eb678e5c6263a64f5b1212bf4910ed Mon Sep 17 00:00:00 2001 From: Deepak Bhagat Date: Wed, 21 Jan 2026 17:09:49 +0530 Subject: [PATCH 1/2] fix: Pin icon not appearing immediately after pinning - Fix duplicate pin icon issue by removing redundant RightIcons rendering on header messages - Implement optimistic updates for pin action to show immediate UI feedback - Fix message color update when status changes from TEMP to SENT by adding isTemp to React.memo comparison - Add optimisticUpdates helper to prevent server race conditions from overwriting user actions --- app/containers/MessageActions/index.tsx | 40 +++++++++++++++++++- app/containers/message/Message.tsx | 16 +++++++- app/lib/methods/helpers/optimisticUpdates.ts | 26 +++++++++++++ app/lib/methods/subscriptions/room.ts | 27 ++++++++++++- 4 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 app/lib/methods/helpers/optimisticUpdates.ts diff --git a/app/containers/MessageActions/index.tsx b/app/containers/MessageActions/index.tsx index 466a70ce51..ea277978ff 100644 --- a/app/containers/MessageActions/index.tsx +++ b/app/containers/MessageActions/index.tsx @@ -6,6 +6,9 @@ import { connect } from 'react-redux'; import dayjs from '../../lib/dayjs'; import database from '../../lib/database'; import { getSubscriptionByRoomId } from '../../lib/database/services/Subscription'; +import { getMessageById } from '../../lib/database/services/Message'; +import protectedFunction from '../../lib/methods/helpers/protectedFunction'; +import { registerOptimisticUpdate } from '../../lib/methods/helpers/optimisticUpdates'; import I18n from '../../i18n'; import log, { logEvent } from '../../lib/methods/helpers/log'; import Navigation from '../../lib/navigation/appNavigation'; @@ -318,10 +321,45 @@ const MessageActions = React.memo( }; const handlePin = async (message: TAnyMessageModel) => { + const currentPinned = message.pinned as boolean; + const willPin = !currentPinned; logEvent(events.ROOM_MSG_ACTION_PIN); + try { - await togglePinMessage(message.id, message.pinned as boolean); // TODO: reevaluate `message.pinned` type on IMessage + const db = database.active; + const messageRecord = await getMessageById(message.id); + if (messageRecord) { + registerOptimisticUpdate(message.id, { pinned: willPin }); + await db.write(async () => { + await messageRecord.update( + protectedFunction((m: any) => { + m.pinned = willPin; + }) + ); + }); + } + } catch (optimisticError) { + // Do nothing + } + + try { + await togglePinMessage(message.id, currentPinned); // TODO: reevaluate `message.pinned` type on IMessage } catch (e) { + try { + const db = database.active; + const messageRecord = await getMessageById(message.id); + if (messageRecord) { + await db.write(async () => { + await messageRecord.update( + protectedFunction((m: any) => { + m.pinned = currentPinned; + }) + ); + }); + } + } catch (revertError) { + // Do nothing + } logEvent(events.ROOM_MSG_ACTION_PIN_F); log(e); } diff --git a/app/containers/message/Message.tsx b/app/containers/message/Message.tsx index ce32c546e6..25ef616d70 100644 --- a/app/containers/message/Message.tsx +++ b/app/containers/message/Message.tsx @@ -154,6 +154,8 @@ const Message = React.memo((props: IMessageTouchable & IMessage) => { : `${user} ${hour} ${translated} ${label}. ${encryptedMessageLabel} ${readReceipt}`; }; + const showRightIcons = !props.isHeader; + if (props.isThreadReply || props.isThreadSequential || props.isInfo || props.isIgnored) { const thread = props.isThreadReply ? : null; // Prevent misalignment of info when the font size is increased. @@ -195,7 +197,7 @@ const Message = React.memo((props: IMessageTouchable & IMessage) => { - {!props.isHeader ? ( + {showRightIcons ? ( { ); -}); + }, + (prevProps, nextProps) => + prevProps.pinned === nextProps.pinned && + prevProps.id === nextProps.id && + prevProps.msg === nextProps.msg && + prevProps.isEdited === nextProps.isEdited && + prevProps.hasError === nextProps.hasError && + prevProps.isTemp === nextProps.isTemp && + prevProps.isTranslated === nextProps.isTranslated && + prevProps.isHeader === nextProps.isHeader +); Message.displayName = 'Message'; const MessageTouchable = React.memo((props: IMessageTouchable & IMessage) => { diff --git a/app/lib/methods/helpers/optimisticUpdates.ts b/app/lib/methods/helpers/optimisticUpdates.ts new file mode 100644 index 0000000000..a6f94a74dc --- /dev/null +++ b/app/lib/methods/helpers/optimisticUpdates.ts @@ -0,0 +1,26 @@ +interface IOptimisticUpdate { + pinned?: boolean; + timestamp: number; +} + +const optimisticUpdates: Map = new Map(); + +export const registerOptimisticUpdate = (messageId: string, update: { pinned?: boolean }) => { + optimisticUpdates.set(messageId, { + ...update, + timestamp: Date.now() + }); +}; + +export const getOptimisticUpdate = (messageId: string): IOptimisticUpdate | undefined => optimisticUpdates.get(messageId); + +export const clearOptimisticUpdate = (messageId: string) => { + optimisticUpdates.delete(messageId); +}; + +export const isRecentOptimisticUpdate = (messageId: string, maxAge: number = 2000): boolean => { + const update = optimisticUpdates.get(messageId); + if (!update) return false; + const age = Date.now() - update.timestamp; + return age < maxAge; +}; diff --git a/app/lib/methods/subscriptions/room.ts b/app/lib/methods/subscriptions/room.ts index a0ce1310b2..e404278c36 100644 --- a/app/lib/methods/subscriptions/room.ts +++ b/app/lib/methods/subscriptions/room.ts @@ -6,6 +6,7 @@ import { Q } from '@nozbe/watermelondb'; import log from '../helpers/log'; import protectedFunction from '../helpers/protectedFunction'; import buildMessage from '../helpers/buildMessage'; +import { getOptimisticUpdate, isRecentOptimisticUpdate, clearOptimisticUpdate } from '../helpers/optimisticUpdates'; import database from '../../database'; import { getMessageById } from '../../database/services/Message'; import { getThreadById } from '../../database/services/Thread'; @@ -281,7 +282,31 @@ export default class RoomSubscription { batch.push( messageRecord.prepareUpdate( protectedFunction((m: TMessageModel) => { - Object.assign(m, message); + const optimisticUpdate = getOptimisticUpdate(message._id); + const isRecentOptimistic = isRecentOptimisticUpdate(message._id, 2000); + + if (message.pinned !== undefined) { + if (isRecentOptimistic && optimisticUpdate?.pinned !== undefined) { + m.pinned = optimisticUpdate.pinned; + } else { + m.pinned = message.pinned; + } + } + + const { pinned: _pinned, ...restMessage } = message; + Object.assign(m, restMessage); + + if (message.pinned !== undefined) { + if (isRecentOptimistic && optimisticUpdate?.pinned !== undefined) { + m.pinned = optimisticUpdate.pinned; + } else { + m.pinned = message.pinned; + } + } + + if (isRecentOptimistic) { + clearOptimisticUpdate(message._id); + } }) ) ); From 3415d94fbb4eadecf3d4971a445e659145142580 Mon Sep 17 00:00:00 2001 From: Deepak Bhagat Date: Wed, 21 Jan 2026 17:37:08 +0530 Subject: [PATCH 2/2] fix: Pin icon not appearing immediately after pinning - Fix duplicate pin icon issue by removing redundant RightIcons rendering on header messages - Implement optimistic updates for pin action to show immediate UI feedback - Fix message color update when status changes from TEMP to SENT by adding isTemp to React.memo comparison - Add optimisticUpdates helper to prevent server race conditions from overwriting user actions - Fix type coercion by using Boolean() instead of 'as boolean' assertion - Register optimistic update only after successful DB write to prevent inconsistency - Add automatic cleanup for stale optimistic updates to prevent memory leaks - Remove duplicate pinned assignment logic in updateMessage - Remove incomplete React.memo comparator to prevent stale UI --- app/containers/MessageActions/index.tsx | 4 ++-- app/containers/message/Message.tsx | 12 +----------- app/lib/methods/helpers/optimisticUpdates.ts | 18 +++++++++++++++++- app/lib/methods/subscriptions/room.ts | 8 -------- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/app/containers/MessageActions/index.tsx b/app/containers/MessageActions/index.tsx index ea277978ff..b5e80fbcc9 100644 --- a/app/containers/MessageActions/index.tsx +++ b/app/containers/MessageActions/index.tsx @@ -321,7 +321,7 @@ const MessageActions = React.memo( }; const handlePin = async (message: TAnyMessageModel) => { - const currentPinned = message.pinned as boolean; + const currentPinned = Boolean(message.pinned); const willPin = !currentPinned; logEvent(events.ROOM_MSG_ACTION_PIN); @@ -329,7 +329,6 @@ const MessageActions = React.memo( const db = database.active; const messageRecord = await getMessageById(message.id); if (messageRecord) { - registerOptimisticUpdate(message.id, { pinned: willPin }); await db.write(async () => { await messageRecord.update( protectedFunction((m: any) => { @@ -337,6 +336,7 @@ const MessageActions = React.memo( }) ); }); + registerOptimisticUpdate(message.id, { pinned: willPin }); } } catch (optimisticError) { // Do nothing diff --git a/app/containers/message/Message.tsx b/app/containers/message/Message.tsx index 25ef616d70..8f811872ca 100644 --- a/app/containers/message/Message.tsx +++ b/app/containers/message/Message.tsx @@ -213,17 +213,7 @@ const Message = React.memo((props: IMessageTouchable & IMessage) => { ); - }, - (prevProps, nextProps) => - prevProps.pinned === nextProps.pinned && - prevProps.id === nextProps.id && - prevProps.msg === nextProps.msg && - prevProps.isEdited === nextProps.isEdited && - prevProps.hasError === nextProps.hasError && - prevProps.isTemp === nextProps.isTemp && - prevProps.isTranslated === nextProps.isTranslated && - prevProps.isHeader === nextProps.isHeader -); +}); Message.displayName = 'Message'; const MessageTouchable = React.memo((props: IMessageTouchable & IMessage) => { diff --git a/app/lib/methods/helpers/optimisticUpdates.ts b/app/lib/methods/helpers/optimisticUpdates.ts index a6f94a74dc..4c7cf3e89c 100644 --- a/app/lib/methods/helpers/optimisticUpdates.ts +++ b/app/lib/methods/helpers/optimisticUpdates.ts @@ -4,21 +4,37 @@ interface IOptimisticUpdate { } const optimisticUpdates: Map = new Map(); +const CLEANUP_THRESHOLD = 10000; + +const cleanupStaleUpdates = (maxAge: number = CLEANUP_THRESHOLD) => { + const now = Date.now(); + for (const [messageId, update] of optimisticUpdates.entries()) { + const age = now - update.timestamp; + if (age >= maxAge) { + optimisticUpdates.delete(messageId); + } + } +}; export const registerOptimisticUpdate = (messageId: string, update: { pinned?: boolean }) => { + cleanupStaleUpdates(); optimisticUpdates.set(messageId, { ...update, timestamp: Date.now() }); }; -export const getOptimisticUpdate = (messageId: string): IOptimisticUpdate | undefined => optimisticUpdates.get(messageId); +export const getOptimisticUpdate = (messageId: string): IOptimisticUpdate | undefined => { + cleanupStaleUpdates(); + return optimisticUpdates.get(messageId); +}; export const clearOptimisticUpdate = (messageId: string) => { optimisticUpdates.delete(messageId); }; export const isRecentOptimisticUpdate = (messageId: string, maxAge: number = 2000): boolean => { + cleanupStaleUpdates(); const update = optimisticUpdates.get(messageId); if (!update) return false; const age = Date.now() - update.timestamp; diff --git a/app/lib/methods/subscriptions/room.ts b/app/lib/methods/subscriptions/room.ts index e404278c36..93083284c5 100644 --- a/app/lib/methods/subscriptions/room.ts +++ b/app/lib/methods/subscriptions/room.ts @@ -285,14 +285,6 @@ export default class RoomSubscription { const optimisticUpdate = getOptimisticUpdate(message._id); const isRecentOptimistic = isRecentOptimisticUpdate(message._id, 2000); - if (message.pinned !== undefined) { - if (isRecentOptimistic && optimisticUpdate?.pinned !== undefined) { - m.pinned = optimisticUpdate.pinned; - } else { - m.pinned = message.pinned; - } - } - const { pinned: _pinned, ...restMessage } = message; Object.assign(m, restMessage);