From b49464c7b79d70662b206a6d3a9ad01b1a28efb7 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Tue, 25 Aug 2026 16:07:10 -0300 Subject: [PATCH 1/4] feat: implement endpoint to get the messageId --- app/definitions/rest/v1/chat.ts | 5 ++ app/lib/methods/getMessageIdByFileId.ts | 84 +++++++++++++++++++++++++ app/lib/services/restApi.ts | 4 ++ app/views/MessagesView/index.tsx | 40 ++++++++++-- 4 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 app/lib/methods/getMessageIdByFileId.ts diff --git a/app/definitions/rest/v1/chat.ts b/app/definitions/rest/v1/chat.ts index f611c829423..b89ba13173a 100644 --- a/app/definitions/rest/v1/chat.ts +++ b/app/definitions/rest/v1/chat.ts @@ -8,6 +8,11 @@ export type ChatEndpoints = { message: IMessage; }; }; + 'chat.getMessageByFileId': { + GET: (params: { fileId: string }) => { + message: IMessage; + }; + }; 'chat.followMessage': { POST: (params: { mid: IMessage['_id'] }) => void; }; diff --git a/app/lib/methods/getMessageIdByFileId.ts b/app/lib/methods/getMessageIdByFileId.ts new file mode 100644 index 00000000000..73b38ff3a17 --- /dev/null +++ b/app/lib/methods/getMessageIdByFileId.ts @@ -0,0 +1,84 @@ +import { Q } from '@nozbe/watermelondb'; + +import { type IMessage } from '../../definitions'; +import database from '../database'; +import { sanitizeLikeString } from '../database/utils'; +import log from './helpers/log'; +import { type RoomTypes, roomTypeToApiType } from './roomTypeToApiType'; +import sdk from '../services/sdk'; + +// `rooms.files` returns upload records: `_id` is the FILE id, with no link back to its message. +const HISTORY_SKEW_MS = 5000; +const HISTORY_COUNT = 5; + +const carriesFile = (message: IMessage, fileId: string): boolean => + message.file?._id === fileId || !!message.files?.some(file => file._id === fileId); + +// `attachments` is raw JSON in a text column embedding `/file-upload//`. +const findLocally = async (rid: string, fileId: string): Promise => { + const likeString = sanitizeLikeString(fileId); + if (!likeString) { + return null; + } + const messages = await database.active + .get('messages') + .query(Q.where('rid', rid), Q.where('attachments', Q.like(`%${likeString}%`)), Q.take(1)) + .fetch(); + return messages.length ? messages[0].id : null; +}; + +const findOnServer = async (rid: string, t: RoomTypes, fileId: string, uploadedAt: string | Date): Promise => { + const uploadedAtMs = new Date(uploadedAt).getTime(); + if (!Number.isFinite(uploadedAtMs)) { + return null; + } + const params = { + roomId: rid, + latest: new Date(uploadedAtMs + HISTORY_SKEW_MS).toISOString(), + count: HISTORY_COUNT, + showThreadMessages: true + }; + + // sdk.get is typed per endpoint, so the api type branches rather than interpolates. + let data; + switch (roomTypeToApiType(t)) { + case 'channels': + data = await sdk.get('channels.history', params); + break; + case 'groups': + data = await sdk.get('groups.history', params); + break; + case 'im': + data = await sdk.get('im.history', params); + break; + default: + return null; + } + + if (!data?.success) { + return null; + } + return (data.messages as IMessage[])?.find(message => carriesFile(message, fileId))?._id ?? null; +}; + +// Message id that carries the given upload, or null when it can't be resolved. +export const getMessageIdByFileId = async ({ + rid, + t, + fileId, + uploadedAt +}: { + rid: string; + t: RoomTypes; + fileId: string; + uploadedAt: string | Date; +}): Promise => { + try { + return (await findLocally(rid, fileId)) ?? (await findOnServer(rid, t, fileId, uploadedAt)); + } catch (e) { + log(e); + return null; + } +}; + +export default getMessageIdByFileId; diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index c6f087b8209..af651cdc1de 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -786,6 +786,10 @@ export const getFiles = (roomId: string, type: SubscriptionType, offset: number) }); }; +export const getMessageByFileId = (fileId: string) => + // RC 8.10.0 + sdk.get('chat.getMessageByFileId', { fileId }); + export const getMessages = ({ roomId, type, diff --git a/app/views/MessagesView/index.tsx b/app/views/MessagesView/index.tsx index 4175ab9141a..9e140b50c99 100644 --- a/app/views/MessagesView/index.tsx +++ b/app/views/MessagesView/index.tsx @@ -31,7 +31,15 @@ import { type TAnyMessageModel, type IUrl } from '../../definitions'; -import { getFiles, getMessages, getPinnedMessages, togglePinMessage, toggleStarMessage } from '../../lib/services/restApi'; +import { + getFiles, + getMessageByFileId, + getMessages, + getPinnedMessages, + togglePinMessage, + toggleStarMessage +} from '../../lib/services/restApi'; +import { compareServerVersion, showErrorAlert } from '../../lib/methods/helpers'; import { type TNavigation } from '../../stacks/stackType'; import AudioManager from '../../lib/methods/AudioManager'; import { Encryption } from '../../lib/encryption'; @@ -54,6 +62,7 @@ interface IMessagesViewProps { showActionSheet: (params: { options: string[]; hasCancel: boolean }) => void; isMasterDetail: boolean; insets: EdgeInsets; + serverVersion: string | null; } interface IMessagesViewState { @@ -136,11 +145,33 @@ class MessagesView extends Component { navigation.navigate('RoomInfoView', navParam); }; + // A Files row is an upload record, not a message: its `_id` is the file id. + resolveMessageId = async (item: IMessage): Promise => { + const { route, serverVersion } = this.props; + if (route.params?.name !== 'Files' || compareServerVersion(serverVersion, 'lowerThan', '8.9.0')) { + return item._id; + } + + try { + const result = await getMessageByFileId(item._id); + if (result.success && result.message) { + return result.message._id; + } + } catch { + } + return null; + }; + jumpToMessage = async ({ item }: { item: IMessage }) => { const { isMasterDetail } = this.props; + const jumpToMessageId = await this.resolveMessageId(item); + if (!jumpToMessageId) { + showErrorAlert(I18n.t('Message_not_found'), I18n.t('Oops')); + return; + } let params: IParams = { rid: this.rid, - jumpToMessageId: item._id, + jumpToMessageId, t: this.t, room: this.room }; @@ -149,7 +180,7 @@ class MessagesView extends Component { params = { ...params, tmid: item.tmid, - name: await getThreadName(this.rid, item.tmid, item._id), + name: await getThreadName(this.rid, item.tmid, jumpToMessageId), t: SubscriptionType.THREAD }; Navigation.push('RoomView', params); @@ -367,7 +398,8 @@ class MessagesView extends Component { const mapStateToProps = (state: IApplicationState) => ({ baseUrl: state.server.server, - user: getUserSelector(state) + user: getUserSelector(state), + serverVersion: state.server.version }); export default connect(mapStateToProps)(withTheme(withActionSheet(withMasterDetail(withSafeAreaInsets(MessagesView))))); From 7af2c30785bea36635c4becc49449930fd91e8bb Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Tue, 25 Aug 2026 19:43:05 +0000 Subject: [PATCH 2/4] chore: format code and fix lint issues --- app/views/MessagesView/index.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/views/MessagesView/index.tsx b/app/views/MessagesView/index.tsx index 9e140b50c99..598626df4e7 100644 --- a/app/views/MessagesView/index.tsx +++ b/app/views/MessagesView/index.tsx @@ -151,14 +151,13 @@ class MessagesView extends Component { if (route.params?.name !== 'Files' || compareServerVersion(serverVersion, 'lowerThan', '8.9.0')) { return item._id; } - + try { const result = await getMessageByFileId(item._id); if (result.success && result.message) { return result.message._id; } - } catch { - } + } catch {} return null; }; From 61a4a590d7775fdb19c3bb3f566cfccbed7b2ca7 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Tue, 25 Aug 2026 19:24:45 -0300 Subject: [PATCH 3/4] cleanup --- app/lib/methods/getMessageIdByFileId.ts | 84 ------------------------- 1 file changed, 84 deletions(-) delete mode 100644 app/lib/methods/getMessageIdByFileId.ts diff --git a/app/lib/methods/getMessageIdByFileId.ts b/app/lib/methods/getMessageIdByFileId.ts deleted file mode 100644 index 73b38ff3a17..00000000000 --- a/app/lib/methods/getMessageIdByFileId.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Q } from '@nozbe/watermelondb'; - -import { type IMessage } from '../../definitions'; -import database from '../database'; -import { sanitizeLikeString } from '../database/utils'; -import log from './helpers/log'; -import { type RoomTypes, roomTypeToApiType } from './roomTypeToApiType'; -import sdk from '../services/sdk'; - -// `rooms.files` returns upload records: `_id` is the FILE id, with no link back to its message. -const HISTORY_SKEW_MS = 5000; -const HISTORY_COUNT = 5; - -const carriesFile = (message: IMessage, fileId: string): boolean => - message.file?._id === fileId || !!message.files?.some(file => file._id === fileId); - -// `attachments` is raw JSON in a text column embedding `/file-upload//`. -const findLocally = async (rid: string, fileId: string): Promise => { - const likeString = sanitizeLikeString(fileId); - if (!likeString) { - return null; - } - const messages = await database.active - .get('messages') - .query(Q.where('rid', rid), Q.where('attachments', Q.like(`%${likeString}%`)), Q.take(1)) - .fetch(); - return messages.length ? messages[0].id : null; -}; - -const findOnServer = async (rid: string, t: RoomTypes, fileId: string, uploadedAt: string | Date): Promise => { - const uploadedAtMs = new Date(uploadedAt).getTime(); - if (!Number.isFinite(uploadedAtMs)) { - return null; - } - const params = { - roomId: rid, - latest: new Date(uploadedAtMs + HISTORY_SKEW_MS).toISOString(), - count: HISTORY_COUNT, - showThreadMessages: true - }; - - // sdk.get is typed per endpoint, so the api type branches rather than interpolates. - let data; - switch (roomTypeToApiType(t)) { - case 'channels': - data = await sdk.get('channels.history', params); - break; - case 'groups': - data = await sdk.get('groups.history', params); - break; - case 'im': - data = await sdk.get('im.history', params); - break; - default: - return null; - } - - if (!data?.success) { - return null; - } - return (data.messages as IMessage[])?.find(message => carriesFile(message, fileId))?._id ?? null; -}; - -// Message id that carries the given upload, or null when it can't be resolved. -export const getMessageIdByFileId = async ({ - rid, - t, - fileId, - uploadedAt -}: { - rid: string; - t: RoomTypes; - fileId: string; - uploadedAt: string | Date; -}): Promise => { - try { - return (await findLocally(rid, fileId)) ?? (await findOnServer(rid, t, fileId, uploadedAt)); - } catch (e) { - log(e); - return null; - } -}; - -export default getMessageIdByFileId; From 253d215b7f52e958809b4e81b677d54d735a7c1c Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Tue, 25 Aug 2026 19:25:45 -0300 Subject: [PATCH 4/4] cleanup --- app/views/MessagesView/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/views/MessagesView/index.tsx b/app/views/MessagesView/index.tsx index 598626df4e7..92dfda086d6 100644 --- a/app/views/MessagesView/index.tsx +++ b/app/views/MessagesView/index.tsx @@ -145,7 +145,6 @@ class MessagesView extends Component { navigation.navigate('RoomInfoView', navParam); }; - // A Files row is an upload record, not a message: its `_id` is the file id. resolveMessageId = async (item: IMessage): Promise => { const { route, serverVersion } = this.props; if (route.params?.name !== 'Files' || compareServerVersion(serverVersion, 'lowerThan', '8.9.0')) {