From 9767712c7a310261e0c7d712476c716f6dcb1c84 Mon Sep 17 00:00:00 2001 From: Deepak Bhagat Date: Wed, 21 Jan 2026 18:13:37 +0530 Subject: [PATCH] feat: Add star icon display for starred messages - Create Starred component to display star icon when message is starred - Add starred prop to RightIcons component and interface - Pass starred prop through Message, User, and MessageContainer components - Implement optimistic updates for star action to show immediate UI feedback - Add starred to optimisticUpdates helper to prevent server race conditions - Update room.ts updateMessage to prioritize recent optimistic starred updates - Add starred to IMessageContent interface - Refactor handleStar to accept message object and implement optimistic updates with error rollback --- app/containers/MessageActions/index.tsx | 48 +++++++++++++++++-- .../message/Components/RightIcons/Starred.tsx | 13 +++++ .../message/Components/RightIcons/index.tsx | 6 ++- app/containers/message/Message.tsx | 1 + app/containers/message/User.tsx | 2 + app/containers/message/index.tsx | 4 +- app/containers/message/interfaces.ts | 1 + app/lib/methods/helpers/optimisticUpdates.ts | 43 +++++++++++++++++ app/lib/methods/subscriptions/room.ts | 23 ++++++++- 9 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 app/containers/message/Components/RightIcons/Starred.tsx create mode 100644 app/lib/methods/helpers/optimisticUpdates.ts diff --git a/app/containers/MessageActions/index.tsx b/app/containers/MessageActions/index.tsx index 466a70ce510..6496acbccfd 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'; @@ -306,12 +309,47 @@ const MessageActions = React.memo( } }; - const handleStar = async (messageId: string, starred: boolean) => { - logEvent(starred ? events.ROOM_MSG_ACTION_UNSTAR : events.ROOM_MSG_ACTION_STAR); + const handleStar = async (message: TAnyMessageModel) => { + const currentStarred = Boolean(message.starred); + const willStar = !currentStarred; + logEvent(events.ROOM_MSG_ACTION_STAR); + try { - await toggleStarMessage(messageId, starred); - EventEmitter.emit(LISTENER, { message: starred ? I18n.t('Message_unstarred') : I18n.t('Message_starred') }); + const db = database.active; + const messageRecord = await getMessageById(message.id); + if (messageRecord) { + await db.write(async () => { + await messageRecord.update( + protectedFunction((m: any) => { + m.starred = willStar; + }) + ); + }); + registerOptimisticUpdate(message.id, { starred: willStar }); + } + } catch (optimisticError) { + // Do nothing + } + + try { + await toggleStarMessage(message.id, currentStarred); + EventEmitter.emit(LISTENER, { message: willStar ? I18n.t('Message_starred') : I18n.t('Message_unstarred') }); } 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.starred = currentStarred; + }) + ); + }); + } + } catch (revertError) { + // Do nothing + } logEvent(events.ROOM_MSG_ACTION_STAR_F); log(e); } @@ -506,7 +544,7 @@ const MessageActions = React.memo( options.push({ title: I18n.t(message.starred ? 'Unstar' : 'Star'), icon: message.starred ? 'star-filled' : 'star', - onPress: () => handleStar(message.id, message.starred || false) + onPress: () => handleStar(message) }); } diff --git a/app/containers/message/Components/RightIcons/Starred.tsx b/app/containers/message/Components/RightIcons/Starred.tsx new file mode 100644 index 00000000000..62787be3f4b --- /dev/null +++ b/app/containers/message/Components/RightIcons/Starred.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import { CustomIcon } from '../../../CustomIcon'; +import styles from '../../styles'; + +const Starred = ({ starred, testID }: { starred?: boolean; testID?: string }): React.ReactElement | null => { + 'use memo'; + + if (starred) return ; + return null; +}; + +export default Starred; diff --git a/app/containers/message/Components/RightIcons/index.tsx b/app/containers/message/Components/RightIcons/index.tsx index 6421f0c1ed8..a544a3b6c0c 100644 --- a/app/containers/message/Components/RightIcons/index.tsx +++ b/app/containers/message/Components/RightIcons/index.tsx @@ -7,6 +7,7 @@ import Encrypted from './Encrypted'; import MessageError from './MessageError'; import Pinned from './Pinned'; import ReadReceipt from './ReadReceipt'; +import Starred from './Starred'; import Translated from './Translated'; const styles = StyleSheet.create({ @@ -24,6 +25,7 @@ interface IRightIcons { hasError: boolean; isTranslated: boolean; pinned?: boolean; + starred?: boolean; } const RightIcons = ({ @@ -34,13 +36,15 @@ const RightIcons = ({ isReadReceiptEnabled, unread, isTranslated, - pinned + pinned, + starred }: IRightIcons): React.ReactElement => { 'use memo'; return ( + diff --git a/app/containers/message/Message.tsx b/app/containers/message/Message.tsx index ce32c546e6a..3d79828a22d 100644 --- a/app/containers/message/Message.tsx +++ b/app/containers/message/Message.tsx @@ -204,6 +204,7 @@ const Message = React.memo((props: IMessageTouchable & IMessage) => { isReadReceiptEnabled={props.isReadReceiptEnabled} unread={props.unread} pinned={props.pinned} + starred={props.starred} isTranslated={props.isTranslated} /> ) : null} diff --git a/app/containers/message/User.tsx b/app/containers/message/User.tsx index f24a65d849f..70ba0284888 100644 --- a/app/containers/message/User.tsx +++ b/app/containers/message/User.tsx @@ -62,6 +62,7 @@ interface IMessageUser { isReadReceiptEnabled?: boolean; unread?: boolean; pinned?: boolean; + starred?: boolean; isTranslated: boolean; } @@ -129,6 +130,7 @@ const User = React.memo( isReadReceiptEnabled={props.isReadReceiptEnabled} unread={props.unread} pinned={props.pinned} + starred={props.starred} isTranslated={isTranslated} /> diff --git a/app/containers/message/index.tsx b/app/containers/message/index.tsx index 31522bd7f5c..d6bcb541bbe 100644 --- a/app/containers/message/index.tsx +++ b/app/containers/message/index.tsx @@ -410,7 +410,8 @@ class MessageContainer extends React.Component diff --git a/app/containers/message/interfaces.ts b/app/containers/message/interfaces.ts index 8523f7836b3..a64afd73945 100644 --- a/app/containers/message/interfaces.ts +++ b/app/containers/message/interfaces.ts @@ -71,6 +71,7 @@ export interface IMessageContent { isHeader: boolean; isTranslated: boolean; pinned?: boolean; + starred?: boolean; } export interface IMessageEmoji { diff --git a/app/lib/methods/helpers/optimisticUpdates.ts b/app/lib/methods/helpers/optimisticUpdates.ts new file mode 100644 index 00000000000..ce30502c241 --- /dev/null +++ b/app/lib/methods/helpers/optimisticUpdates.ts @@ -0,0 +1,43 @@ +interface IOptimisticUpdate { + pinned?: boolean; + starred?: boolean; + timestamp: number; +} + +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; starred?: boolean }) => { + cleanupStaleUpdates(); + optimisticUpdates.set(messageId, { + ...update, + timestamp: Date.now() + }); +}; + +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; + return age < maxAge; +}; diff --git a/app/lib/methods/subscriptions/room.ts b/app/lib/methods/subscriptions/room.ts index a0ce1310b24..6710069a12f 100644 --- a/app/lib/methods/subscriptions/room.ts +++ b/app/lib/methods/subscriptions/room.ts @@ -29,6 +29,7 @@ import { readMessages } from '../readMessages'; import { loadMissedMessages } from '../loadMissedMessages'; import { updateLastOpen } from '../updateLastOpen'; import markMessagesRead from '../helpers/markMessagesRead'; +import { getOptimisticUpdate, isRecentOptimisticUpdate } from '../helpers/optimisticUpdates'; export default class RoomSubscription { private rid: string; @@ -281,7 +282,27 @@ 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); + + const { pinned: _pinned, starred: _starred, ...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 (message.starred !== undefined) { + if (isRecentOptimistic && optimisticUpdate?.starred !== undefined) { + m.starred = optimisticUpdate.starred; + } else { + m.starred = message.starred; + } + } }) ) );