diff --git a/keyserver/src/creators/message-creator.js b/keyserver/src/creators/message-creator.js index 76276d116..7c255a59f 100644 --- a/keyserver/src/creators/message-creator.js +++ b/keyserver/src/creators/message-creator.js @@ -1,693 +1,696 @@ // @flow import invariant from 'invariant'; import _pickBy from 'lodash/fp/pickBy.js'; import { permissionLookup } from 'lib/permissions/thread-permissions.js'; import { rawMessageInfoFromMessageData, shimUnsupportedRawMessageInfos, stripLocalIDs, } from 'lib/shared/message-utils.js'; import { pushTypes } from 'lib/shared/messages/message-spec.js'; import type { PushType } from 'lib/shared/messages/message-spec.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { messageDataLocalID, type MessageData, type RawMessageInfo, } from 'lib/types/message-types.js'; import { redisMessageTypes } from 'lib/types/redis-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { promiseAll } from 'lib/utils/promises.js'; import createIDs from './id-creator.js'; import type { UpdatesForCurrentSession } from './update-creator.js'; import { createUpdates } from './update-creator.js'; import { dbQuery, SQL, appendSQLArray, mergeOrConditions, } from '../database/database.js'; import { processMessagesForSearch } from '../database/search-utils.js'; import { fetchMessageInfoForLocalID, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { fetchOtherSessionsForViewer } from '../fetchers/session-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { sendPushNotifs, sendRescindNotifs } from '../push/send.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import type { Viewer } from '../session/viewer.js'; import { earliestFocusedTimeConsideredExpired } from '../shared/focused-times.js'; import { publisher } from '../socket/redis.js'; import { creationString } from '../utils/idempotent.js'; type UserThreadInfo = { +devices: Map< string, { +platform: string, +deviceToken: string, +cookieID: string, +codeVersion: ?string, }, >, +threadIDs: Set, +notFocusedThreadIDs: Set, +userNotMemberOfSubthreads: Set, +subthreadsCanSetToUnread: Set, }; type LatestMessagesPerUser = Map< string, $ReadOnlyMap< string, { +latestMessage: string, +latestReadMessage?: string, }, >, >; type LatestMessages = $ReadOnlyArray<{ +userID: string, +threadID: string, +latestMessage: string, +latestReadMessage: ?string, }>; // Does not do permission checks! (checkThreadPermission) async function createMessages( viewer: Viewer, messageDatas: $ReadOnlyArray, updatesForCurrentSession?: UpdatesForCurrentSession = 'return', ): Promise { if (messageDatas.length === 0) { return []; } const existingMessages = await Promise.all( messageDatas.map(messageData => fetchMessageInfoForLocalID(viewer, messageDataLocalID(messageData)), ), ); const existingMessageInfos: RawMessageInfo[] = []; const newMessageDatas: MessageData[] = []; for (let i = 0; i < messageDatas.length; i++) { const existingMessage = existingMessages[i]; if (existingMessage) { existingMessageInfos.push(existingMessage); } else { newMessageDatas.push(messageDatas[i]); } } if (newMessageDatas.length === 0) { return shimUnsupportedRawMessageInfos( existingMessageInfos, viewer.platformDetails, ); } const ids = await createIDs('messages', newMessageDatas.length); const returnMessageInfos: RawMessageInfo[] = []; const subthreadPermissionsToCheck: Set = new Set(); const messageInsertRows = []; // Indices in threadsToMessageIndices point to newMessageInfos const newMessageInfos: RawMessageInfo[] = []; const threadsToMessageIndices: Map = new Map(); let nextNewMessageIndex = 0; for (let i = 0; i < messageDatas.length; i++) { const existingMessage = existingMessages[i]; if (existingMessage) { returnMessageInfos.push(existingMessage); continue; } const messageData = messageDatas[i]; const threadID = messageData.threadID; const creatorID = messageData.creatorID; let messageIndices = threadsToMessageIndices.get(threadID); if (!messageIndices) { messageIndices = []; threadsToMessageIndices.set(threadID, messageIndices); } const newMessageIndex = nextNewMessageIndex++; messageIndices.push(newMessageIndex); const serverID = ids[newMessageIndex]; if (messageData.type === messageTypes.CREATE_SUB_THREAD) { subthreadPermissionsToCheck.add(messageData.childThreadID); } const content = messageSpecs[messageData.type].messageContentForServerDB?.(messageData); const creation = messageData.localID && viewer.hasSessionInfo ? creationString(viewer, messageData.localID) : null; let targetMessageID = null; if (messageData.targetMessageID) { targetMessageID = messageData.targetMessageID; } else if (messageData.sourceMessage) { targetMessageID = messageData.sourceMessage.id; } messageInsertRows.push([ serverID, threadID, creatorID, messageData.type, content, messageData.time, creation, targetMessageID, ]); const rawMessageInfo = rawMessageInfoFromMessageData(messageData, serverID); newMessageInfos.push(rawMessageInfo); // at newMessageIndex returnMessageInfos.push(rawMessageInfo); // at i } const messageInsertQuery = SQL` INSERT INTO messages(id, thread, user, type, content, time, creation, target_message) VALUES ${messageInsertRows} `; const messageInsertPromise = dbQuery(messageInsertQuery); const postMessageSendPromise = postMessageSend( viewer, threadsToMessageIndices, subthreadPermissionsToCheck, stripLocalIDs(newMessageInfos), newMessageDatas, updatesForCurrentSession, ); if (!viewer.isScriptViewer) { // If we're not being called from a script, then we avoid awaiting // postMessageSendPromise below so that we don't delay the response to the // user on external services. In that case, we use handleAsyncPromise to // make sure any exceptions are caught and logged. handleAsyncPromise(postMessageSendPromise); } await Promise.all([ messageInsertPromise, updateRepliesCount(threadsToMessageIndices, newMessageDatas), viewer.isScriptViewer ? postMessageSendPromise : undefined, ]); if (updatesForCurrentSession !== 'return') { return []; } return shimUnsupportedRawMessageInfos( returnMessageInfos, viewer.platformDetails, ); } async function updateRepliesCount( threadsToMessageIndices: Map, newMessageDatas: MessageData[], ) { const updatedThreads = []; const updateThreads = SQL` UPDATE threads SET replies_count = replies_count + (CASE `; const membershipConditions = []; for (const [threadID, messages] of threadsToMessageIndices.entries()) { const newRepliesIncrease = messages .map(i => newMessageDatas[i].type) .filter(type => messageSpecs[type].includedInRepliesCount).length; if (newRepliesIncrease === 0) { continue; } updateThreads.append(SQL` WHEN id = ${threadID} THEN ${newRepliesIncrease} `); updatedThreads.push(threadID); const senders = messages.map(i => newMessageDatas[i].creatorID); membershipConditions.push( SQL`thread = ${threadID} AND user IN (${senders})`, ); } updateThreads.append(SQL` ELSE 0 END) WHERE id IN (${updatedThreads}) AND source_message IS NOT NULL `); const updateMemberships = SQL` UPDATE memberships SET sender = 1 WHERE sender = 0 AND ( `; updateMemberships.append(mergeOrConditions(membershipConditions)); updateMemberships.append(SQL` ) `); if (updatedThreads.length > 0) { const [{ threadInfos: serverThreadInfos }] = await Promise.all([ fetchServerThreadInfos(SQL`t.id IN (${updatedThreads})`), dbQuery(updateThreads), dbQuery(updateMemberships), ]); const time = Date.now(); const updates = []; for (const threadID in serverThreadInfos) { for (const member of serverThreadInfos[threadID].members) { updates.push({ userID: member.id, time, threadID, type: updateTypes.UPDATE_THREAD, }); } } await createUpdates(updates); } } // Handles: // (1) Sending push notifs // (2) Setting threads to unread and generating corresponding UpdateInfos // (3) Publishing to Redis so that active sockets pass on new messages // (4) Processing messages for search async function postMessageSend( viewer: Viewer, threadsToMessageIndices: Map, subthreadPermissionsToCheck: Set, messageInfos: RawMessageInfo[], messageDatas: MessageData[], updatesForCurrentSession: UpdatesForCurrentSession, ) { const processForSearch = processMessagesForSearch(messageInfos); let joinIndex = 0; let subthreadSelects = ''; const subthreadJoins = []; for (const subthread of subthreadPermissionsToCheck) { const index = joinIndex++; subthreadSelects += ` , stm${index}.permissions AS subthread${subthread}_permissions, stm${index}.role AS subthread${subthread}_role `; const join = SQL`LEFT JOIN memberships `; join.append(`stm${index} ON stm${index}.`); join.append(SQL`thread = ${subthread} AND `); join.append(`stm${index}.user = m.user`); subthreadJoins.push(join); } const time = earliestFocusedTimeConsideredExpired(); const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; const query = SQL` SELECT m.user, m.thread, c.platform, c.device_token, c.versions, c.id, f.user AS focused_user `; query.append(subthreadSelects); query.append(SQL` FROM memberships m LEFT JOIN cookies c ON c.user = m.user AND c.device_token IS NOT NULL LEFT JOIN focused f ON f.user = m.user AND f.thread = m.thread AND f.time > ${time} `); appendSQLArray(query, subthreadJoins, SQL` `); query.append(SQL` WHERE (m.role > 0 OR f.user IS NOT NULL) AND JSON_EXTRACT(m.permissions, ${visibleExtractString}) IS TRUE AND m.thread IN (${[...threadsToMessageIndices.keys()]}) `); const perUserInfo = new Map(); const [result] = await dbQuery(query); for (const row of result) { const userID = row.user.toString(); const threadID = row.thread.toString(); const deviceToken = row.device_token; const focusedUser = !!row.focused_user; const { platform } = row; const versions = JSON.parse(row.versions); const cookieID = row.id; let thisUserInfo = perUserInfo.get(userID); if (!thisUserInfo) { thisUserInfo = { devices: new Map(), threadIDs: new Set(), notFocusedThreadIDs: new Set(), userNotMemberOfSubthreads: new Set(), subthreadsCanSetToUnread: new Set(), }; perUserInfo.set(userID, thisUserInfo); // Subthread info will be the same for each subthread, so we only parse // it once for (const subthread of subthreadPermissionsToCheck) { const isSubthreadMember = row[`subthread${subthread}_role`] > 0; const rawSubthreadPermissions = row[`subthread${subthread}_permissions`]; const subthreadPermissions = JSON.parse(rawSubthreadPermissions); const canSeeSubthread = permissionLookup( subthreadPermissions, threadPermissions.KNOW_OF, ); if (!canSeeSubthread) { continue; } thisUserInfo.subthreadsCanSetToUnread.add(subthread); // Only include the notification from the superthread if there is no // notification from the subthread if ( !isSubthreadMember || !permissionLookup(subthreadPermissions, threadPermissions.VISIBLE) ) { thisUserInfo.userNotMemberOfSubthreads.add(subthread); } } } if (deviceToken && cookieID) { thisUserInfo.devices.set(deviceToken, { platform, deviceToken, cookieID: cookieID.toString(), codeVersion: versions ? versions.codeVersion : null, }); } thisUserInfo.threadIDs.add(threadID); if (!focusedUser) { thisUserInfo.notFocusedThreadIDs.add(threadID); } } const messageInfosPerUser = {}; const latestMessagesPerUser: LatestMessagesPerUser = new Map(); const userPushInfoPromises = {}; const userRescindInfoPromises = {}; for (const pair of perUserInfo) { const [userID, preUserPushInfo] = pair; const userMessageInfos = []; for (const threadID of preUserPushInfo.threadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant(messageIndices, `indices should exist for thread ${threadID}`); for (const messageIndex of messageIndices) { const messageInfo = messageInfos[messageIndex]; userMessageInfos.push(messageInfo); } } if (userMessageInfos.length > 0) { messageInfosPerUser[userID] = userMessageInfos; } latestMessagesPerUser.set( userID, determineLatestMessagesPerThread( preUserPushInfo, userID, threadsToMessageIndices, messageInfos, ), ); const { userNotMemberOfSubthreads } = preUserPushInfo; const userDevices = [...preUserPushInfo.devices.values()]; if (userDevices.length === 0) { continue; } const generateNotifUserInfoPromise = async (pushType: PushType) => { const promises = []; for (const threadID of preUserPushInfo.notFocusedThreadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant( messageIndices, `indices should exist for thread ${threadID}`, ); promises.push( ...messageIndices.map(async messageIndex => { const messageInfo = messageInfos[messageIndex]; const { type } = messageInfo; if (messageInfo.creatorID === userID) { // We never send a user notifs about their own activity return undefined; } const { generatesNotifs } = messageSpecs[type]; const messageData = messageDatas[messageIndex]; + if (!generatesNotifs) { + return undefined; + } const doesGenerateNotif = await generatesNotifs( messageInfo, messageData, { notifTargetUserID: userID, userNotMemberOfSubthreads, fetchMessageInfoByID: (messageID: string) => fetchMessageInfoByID(viewer, messageID), }, ); return doesGenerateNotif === pushType ? { messageInfo, messageData } : undefined; }), ); } const messagesToNotify = await Promise.all(promises); const filteredMessagesToNotify = messagesToNotify.filter(Boolean); if (filteredMessagesToNotify.length === 0) { return undefined; } return { devices: userDevices, messageInfos: filteredMessagesToNotify.map( ({ messageInfo }) => messageInfo, ), messageDatas: filteredMessagesToNotify.map( ({ messageData }) => messageData, ), }; }; const userPushInfoPromise = generateNotifUserInfoPromise(pushTypes.NOTIF); const userRescindInfoPromise = generateNotifUserInfoPromise( pushTypes.RESCIND, ); userPushInfoPromises[userID] = userPushInfoPromise; userRescindInfoPromises[userID] = userRescindInfoPromise; } const latestMessages = flattenLatestMessagesPerUser(latestMessagesPerUser); const [pushInfo, rescindInfo] = await Promise.all([ promiseAll(userPushInfoPromises), promiseAll(userRescindInfoPromises), createReadStatusUpdates(latestMessages), redisPublish(viewer, messageInfosPerUser, updatesForCurrentSession), updateLatestMessages(latestMessages), processForSearch, ]); await Promise.all([ sendPushNotifs(_pickBy(Boolean)(pushInfo)), sendRescindNotifs(_pickBy(Boolean)(rescindInfo)), ]); } async function redisPublish( viewer: Viewer, messageInfosPerUser: { [userID: string]: $ReadOnlyArray }, updatesForCurrentSession: UpdatesForCurrentSession, ) { const avoidBroadcastingToCurrentSession = viewer.hasSessionInfo && updatesForCurrentSession !== 'broadcast'; for (const userID in messageInfosPerUser) { if (userID === viewer.userID && avoidBroadcastingToCurrentSession) { continue; } const messageInfos = messageInfosPerUser[userID]; publisher.sendMessage( { userID }, { type: redisMessageTypes.NEW_MESSAGES, messages: messageInfos, }, ); } const viewerMessageInfos = messageInfosPerUser[viewer.userID]; if (!viewerMessageInfos || !avoidBroadcastingToCurrentSession) { return; } const sessionIDs = await fetchOtherSessionsForViewer(viewer); for (const sessionID of sessionIDs) { publisher.sendMessage( { userID: viewer.userID, sessionID }, { type: redisMessageTypes.NEW_MESSAGES, messages: viewerMessageInfos, }, ); } } function determineLatestMessagesPerThread( preUserPushInfo: UserThreadInfo, userID: string, threadsToMessageIndices: $ReadOnlyMap>, messageInfos: $ReadOnlyArray, ) { const { threadIDs, notFocusedThreadIDs, subthreadsCanSetToUnread } = preUserPushInfo; const latestMessagesPerThread = new Map(); for (const threadID of threadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant(messageIndices, `indices should exist for thread ${threadID}`); for (const messageIndex of messageIndices) { const messageInfo = messageInfos[messageIndex]; if ( messageInfo.type === messageTypes.CREATE_SUB_THREAD && !subthreadsCanSetToUnread.has(messageInfo.childThreadID) ) { continue; } const messageID = messageInfo.id; invariant( messageID, 'message ID should exist in determineLatestMessagesPerThread', ); if ( notFocusedThreadIDs.has(threadID) && messageInfo.creatorID !== userID ) { latestMessagesPerThread.set(threadID, { latestMessage: messageID, }); } else { latestMessagesPerThread.set(threadID, { latestMessage: messageID, latestReadMessage: messageID, }); } } } return latestMessagesPerThread; } function flattenLatestMessagesPerUser( latestMessagesPerUser: LatestMessagesPerUser, ): LatestMessages { const result = []; for (const [userID, latestMessagesPerThread] of latestMessagesPerUser) { for (const [threadID, latestMessages] of latestMessagesPerThread) { result.push({ userID, threadID, latestMessage: latestMessages.latestMessage, latestReadMessage: latestMessages.latestReadMessage, }); } } return result; } async function createReadStatusUpdates(latestMessages: LatestMessages) { const now = Date.now(); const readStatusUpdates = latestMessages .filter(message => !message.latestReadMessage) .map(({ userID, threadID }) => ({ type: updateTypes.UPDATE_THREAD_READ_STATUS, userID, time: now, threadID, unread: true, })); if (readStatusUpdates.length === 0) { return; } await createUpdates(readStatusUpdates); } function updateLatestMessages(latestMessages: LatestMessages) { if (latestMessages.length === 0) { return; } const query = SQL` UPDATE memberships SET `; const lastMessageExpression = SQL` last_message = GREATEST(last_message, CASE `; const lastReadMessageExpression = SQL` , last_read_message = GREATEST(last_read_message, CASE `; let shouldUpdateLastReadMessage = false; for (const { userID, threadID, latestMessage, latestReadMessage, } of latestMessages) { lastMessageExpression.append(SQL` WHEN user = ${userID} AND thread = ${threadID} THEN ${latestMessage} `); if (latestReadMessage) { shouldUpdateLastReadMessage = true; lastReadMessageExpression.append(SQL` WHEN user = ${userID} AND thread = ${threadID} THEN ${latestReadMessage} `); } } lastMessageExpression.append(SQL` ELSE last_message END) `); lastReadMessageExpression.append(SQL` ELSE last_read_message END) `); const conditions = latestMessages.map( ({ userID, threadID }) => SQL`(user = ${userID} AND thread = ${threadID})`, ); query.append(lastMessageExpression); if (shouldUpdateLastReadMessage) { query.append(lastReadMessageExpression); } query.append(SQL`WHERE `); query.append(mergeOrConditions(conditions)); dbQuery(query); } export default createMessages; diff --git a/lib/shared/messages/add-members-message-spec.js b/lib/shared/messages/add-members-message-spec.js index 28b40ef41..11db03e7a 100644 --- a/lib/shared/messages/add-members-message-spec.js +++ b/lib/shared/messages/add-members-message-spec.js @@ -1,162 +1,160 @@ // @flow import invariant from 'invariant'; import type { CreateMessageInfoParams, MessageSpec } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type AddMembersMessageData, type AddMembersMessageInfo, type RawAddMembersMessageInfo, rawAddMembersMessageInfoValidator, } from '../../types/messages/add-members.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { values } from '../../utils/objects.js'; import { notifRobotextForMessageInfo } from '../notif-utils.js'; export const addMembersMessageSpec: MessageSpec< AddMembersMessageData, RawAddMembersMessageInfo, AddMembersMessageInfo, > = Object.freeze({ messageContentForServerDB( data: AddMembersMessageData | RawAddMembersMessageInfo, ): string { return JSON.stringify(data.addedUserIDs); }, messageContentForClientDB(data: RawAddMembersMessageInfo): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawAddMembersMessageInfo { return { type: messageTypes.ADD_MEMBERS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), addedUserIDs: JSON.parse(row.content), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawAddMembersMessageInfo { const content = clientDBMessageInfo.content; invariant( content !== undefined && content !== null, 'content must be defined for AddMembers', ); const rawAddMembersMessageInfo: RawAddMembersMessageInfo = { type: messageTypes.ADD_MEMBERS, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, addedUserIDs: JSON.parse(content), }; return rawAddMembersMessageInfo; }, createMessageInfo( rawMessageInfo: RawAddMembersMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): AddMembersMessageInfo { const addedMembers = params.createRelativeUserInfos( rawMessageInfo.addedUserIDs, ); return { type: messageTypes.ADD_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, addedMembers, }; }, rawMessageInfoFromMessageData( messageData: AddMembersMessageData, id: ?string, ): RawAddMembersMessageInfo { invariant(id, 'RawAddMembersMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: AddMembersMessageInfo): EntityText { const users = messageInfo.addedMembers; invariant(users.length !== 0, 'added who??'); const creator = ET.user({ userInfo: messageInfo.creator }); const addedUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); return ET`${creator} added ${addedUsers}`; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { const addedMembersObject = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); for (const member of messageInfo.addedMembers) { addedMembersObject[member.id] = member; } } const addedMembers = values(addedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, addedMembers }; const robotext = notifRobotextForMessageInfo(mergedMessageInfo, threadInfo); const merged = ET`${robotext} to ${ET.thread({ display: 'shortName', threadInfo, })}`; return { merged, title: threadInfo.uiName, body: robotext, }; }, notificationCollapseKey(rawMessageInfo: RawAddMembersMessageInfo): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, - generatesNotifs: async () => undefined, - userIDs(rawMessageInfo: RawAddMembersMessageInfo): $ReadOnlyArray { return rawMessageInfo.addedUserIDs; }, validator: rawAddMembersMessageInfoValidator, }); diff --git a/lib/shared/messages/edit-message-spec.js b/lib/shared/messages/edit-message-spec.js index 8de9d7191..1eb030535 100644 --- a/lib/shared/messages/edit-message-spec.js +++ b/lib/shared/messages/edit-message-spec.js @@ -1,146 +1,144 @@ // @flow import invariant from 'invariant'; import { type MessageSpec, type MessageTitleParam } from './message-spec.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { assertMessageType, messageTypes, } from '../../types/message-types-enum.js'; import { type ClientDBMessageInfo } from '../../types/message-types.js'; import { type EditMessageData, type RawEditMessageInfo, type EditMessageInfo, rawEditMessageInfoValidator, } from '../../types/messages/edit.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET } from '../../utils/entity-text.js'; import { hasMinCodeVersion } from '../version-utils.js'; export const editMessageSpec: MessageSpec< EditMessageData, RawEditMessageInfo, EditMessageInfo, > = Object.freeze({ messageContentForServerDB( data: EditMessageData | RawEditMessageInfo, ): string { return JSON.stringify({ text: data.text, }); }, messageContentForClientDB(data: RawEditMessageInfo): string { return JSON.stringify({ targetMessageID: data.targetMessageID, text: data.text, }); }, messageTitle({ messageInfo }: MessageTitleParam) { const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} edited a message`; }, rawMessageInfoFromServerDBRow(row: Object): RawEditMessageInfo { invariant( row.targetMessageID !== null && row.targetMessageID !== undefined, 'targetMessageID should be set', ); const content = JSON.parse(row.content); return { type: messageTypes.EDIT_MESSAGE, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), targetMessageID: row.targetMessageID.toString(), text: content.text, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawEditMessageInfo { const messageType = assertMessageType(parseInt(clientDBMessageInfo.type)); invariant( messageType === messageTypes.EDIT_MESSAGE, 'message must be of type EDIT_MESSAGE', ); invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for EditMessage', ); const content = JSON.parse(clientDBMessageInfo.content); const rawEditMessageInfo: RawEditMessageInfo = { type: messageTypes.EDIT_MESSAGE, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, targetMessageID: content.targetMessageID, text: content.text, }; return rawEditMessageInfo; }, createMessageInfo( rawMessageInfo: RawEditMessageInfo, creator: RelativeUserInfo, ): EditMessageInfo { return { type: messageTypes.EDIT_MESSAGE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, targetMessageID: rawMessageInfo.targetMessageID, text: rawMessageInfo.text, }; }, rawMessageInfoFromMessageData( messageData: EditMessageData, id: ?string, ): RawEditMessageInfo { invariant(id, 'RawEditMessageInfo needs id'); return { ...messageData, id }; }, shimUnsupportedMessageInfo( rawMessageInfo: RawEditMessageInfo, platformDetails: ?PlatformDetails, ): RawEditMessageInfo | RawUnsupportedMessageInfo { if (hasMinCodeVersion(platformDetails, { native: 215 })) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'edited a message', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo(unwrapped: RawEditMessageInfo): RawEditMessageInfo { return unwrapped; }, - generatesNotifs: async () => undefined, - validator: rawEditMessageInfoValidator, }); diff --git a/lib/shared/messages/join-thread-message-spec.js b/lib/shared/messages/join-thread-message-spec.js index f6e0f2602..0380299c0 100644 --- a/lib/shared/messages/join-thread-message-spec.js +++ b/lib/shared/messages/join-thread-message-spec.js @@ -1,125 +1,123 @@ // @flow import invariant from 'invariant'; import type { MessageSpec, RobotextParams } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type JoinThreadMessageData, type JoinThreadMessageInfo, type RawJoinThreadMessageInfo, rawJoinThreadMessageInfoValidator, } from '../../types/messages/join-thread.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { values } from '../../utils/objects.js'; export const joinThreadMessageSpec: MessageSpec< JoinThreadMessageData, RawJoinThreadMessageInfo, JoinThreadMessageInfo, > = Object.freeze({ rawMessageInfoFromServerDBRow(row: Object): RawJoinThreadMessageInfo { return { type: messageTypes.JOIN_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawJoinThreadMessageInfo { const rawJoinThreadMessageInfo: RawJoinThreadMessageInfo = { type: messageTypes.JOIN_THREAD, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, }; return rawJoinThreadMessageInfo; }, createMessageInfo( rawMessageInfo: RawJoinThreadMessageInfo, creator: RelativeUserInfo, ): JoinThreadMessageInfo { return { type: messageTypes.JOIN_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, }; }, rawMessageInfoFromMessageData( messageData: JoinThreadMessageData, id: ?string, ): RawJoinThreadMessageInfo { invariant(id, 'RawJoinThreadMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: JoinThreadMessageInfo, params: RobotextParams, ): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} joined ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, threadType: params.threadInfo?.type, parentThreadID: params.threadInfo?.parentThreadID, })}`; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { const joinerArray = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.JOIN_THREAD, 'messageInfo should be messageTypes.JOIN_THREAD!', ); joinerArray[messageInfo.creator.id] = messageInfo.creator; } const joiners = values(joinerArray); const joiningUsers = pluralizeEntityText( joiners.map(user => ET`${ET.user({ userInfo: user })}`), ); const body = ET`${joiningUsers} joined`; const thread = ET.thread({ display: 'shortName', threadInfo }); const merged = ET`${body} ${thread}`; return { merged, title: threadInfo.uiName, body, }; }, notificationCollapseKey(rawMessageInfo: RawJoinThreadMessageInfo): string { return joinResult(rawMessageInfo.type, rawMessageInfo.threadID); }, - generatesNotifs: async () => undefined, - validator: rawJoinThreadMessageInfoValidator, }); diff --git a/lib/shared/messages/leave-thread-message-spec.js b/lib/shared/messages/leave-thread-message-spec.js index 874edfa7e..73751ff1c 100644 --- a/lib/shared/messages/leave-thread-message-spec.js +++ b/lib/shared/messages/leave-thread-message-spec.js @@ -1,125 +1,123 @@ // @flow import invariant from 'invariant'; import type { MessageSpec, RobotextParams } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type LeaveThreadMessageData, type LeaveThreadMessageInfo, type RawLeaveThreadMessageInfo, rawLeaveThreadMessageInfoValidator, } from '../../types/messages/leave-thread.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { values } from '../../utils/objects.js'; export const leaveThreadMessageSpec: MessageSpec< LeaveThreadMessageData, RawLeaveThreadMessageInfo, LeaveThreadMessageInfo, > = Object.freeze({ rawMessageInfoFromServerDBRow(row: Object): RawLeaveThreadMessageInfo { return { type: messageTypes.LEAVE_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawLeaveThreadMessageInfo { const rawLeaveThreadMessageInfo: RawLeaveThreadMessageInfo = { type: messageTypes.LEAVE_THREAD, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, }; return rawLeaveThreadMessageInfo; }, createMessageInfo( rawMessageInfo: RawLeaveThreadMessageInfo, creator: RelativeUserInfo, ): LeaveThreadMessageInfo { return { type: messageTypes.LEAVE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, }; }, rawMessageInfoFromMessageData( messageData: LeaveThreadMessageData, id: ?string, ): RawLeaveThreadMessageInfo { invariant(id, 'RawLeaveThreadMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: LeaveThreadMessageInfo, params: RobotextParams, ): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} left ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, threadType: params.threadInfo?.type, parentThreadID: params.threadInfo?.parentThreadID, })}`; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { const leaverBeavers = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.LEAVE_THREAD, 'messageInfo should be messageTypes.LEAVE_THREAD!', ); leaverBeavers[messageInfo.creator.id] = messageInfo.creator; } const leavers = values(leaverBeavers); const leavingUsers = pluralizeEntityText( leavers.map(user => ET`${ET.user({ userInfo: user })}`), ); const body = ET`${leavingUsers} left`; const thread = ET.thread({ display: 'shortName', threadInfo }); const merged = ET`${body} ${thread}`; return { merged, title: threadInfo.uiName, body, }; }, notificationCollapseKey(rawMessageInfo: RawLeaveThreadMessageInfo): string { return joinResult(rawMessageInfo.type, rawMessageInfo.threadID); }, - generatesNotifs: async () => undefined, - validator: rawLeaveThreadMessageInfoValidator, }); diff --git a/lib/shared/messages/message-spec.js b/lib/shared/messages/message-spec.js index 7b0a461cb..d1b64f5c9 100644 --- a/lib/shared/messages/message-spec.js +++ b/lib/shared/messages/message-spec.js @@ -1,120 +1,120 @@ // @flow import type { TType } from 'tcomb'; import type { PlatformDetails } from '../../types/device-types.js'; import type { Media } from '../../types/media-types.js'; import type { MessageInfo, ClientDBMessageInfo, RawComposableMessageInfo, RawMessageInfo, RawRobotextMessageInfo, } from '../../types/message-types.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo, UserInfo } from '../../types/user-types.js'; import type { EntityText } from '../../utils/entity-text.js'; import { type ParserRules } from '../markdown.js'; export type MessageTitleParam = { +messageInfo: Info, +threadInfo: ThreadInfo, +markdownRules: ParserRules, }; export type RawMessageInfoFromServerDBRowParams = { +localID: ?string, +media?: $ReadOnlyArray, +derivedMessages: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, }; export type CreateMessageInfoParams = { +threadInfos: { +[id: string]: ThreadInfo }, +createMessageInfoFromRaw: (rawInfo: RawMessageInfo) => ?MessageInfo, +createRelativeUserInfos: ( userIDs: $ReadOnlyArray, ) => RelativeUserInfo[], }; export type RobotextParams = { +threadInfo: ?ThreadInfo, }; export type NotificationTextsParams = { +notificationTexts: ( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ) => Promise, +notifTargetUserInfo: UserInfo, }; export type GeneratesNotifsParams = { +notifTargetUserID: string, +userNotMemberOfSubthreads: Set, +fetchMessageInfoByID: (messageID: string) => Promise, }; export const pushTypes = Object.freeze({ NOTIF: 'notif', RESCIND: 'rescind', }); export type PushType = $Values; export type CreationSideEffectsFunc = ( messageInfo: RawInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => Promise; export type MessageSpec = { +messageContentForServerDB?: (data: Data | RawInfo) => string, +messageContentForClientDB?: (data: RawInfo) => string, +messageTitle?: (param: MessageTitleParam) => EntityText, +rawMessageInfoFromServerDBRow?: ( row: Object, params: RawMessageInfoFromServerDBRowParams, ) => ?RawInfo, +rawMessageInfoFromClientDB: ( clientDBMessageInfo: ClientDBMessageInfo, ) => RawInfo, +createMessageInfo: ( rawMessageInfo: RawInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ) => ?Info, +rawMessageInfoFromMessageData?: (messageData: Data, id: ?string) => RawInfo, +robotext?: (messageInfo: Info, params: RobotextParams) => EntityText, +shimUnsupportedMessageInfo?: ( rawMessageInfo: RawInfo, platformDetails: ?PlatformDetails, ) => RawInfo | RawUnsupportedMessageInfo, +unshimMessageInfo?: ( unwrapped: RawInfo, messageInfo: RawMessageInfo, ) => ?RawMessageInfo, +notificationTexts?: ( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ) => Promise, +notificationCollapseKey?: ( rawMessageInfo: RawInfo, messageData: Data, ) => ?string, - +generatesNotifs: ( + +generatesNotifs?: ( rawMessageInfo: RawInfo, messageData: Data, params: GeneratesNotifsParams, ) => Promise, +userIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +startsThread?: boolean, +threadIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +includedInRepliesCount?: boolean, +useCreationSideEffectsFunc?: () => CreationSideEffectsFunc, +validator: TType, }; diff --git a/lib/shared/messages/remove-members-message-spec.js b/lib/shared/messages/remove-members-message-spec.js index 20f8e6071..6f408dc19 100644 --- a/lib/shared/messages/remove-members-message-spec.js +++ b/lib/shared/messages/remove-members-message-spec.js @@ -1,162 +1,160 @@ // @flow import invariant from 'invariant'; import type { CreateMessageInfoParams, MessageSpec } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type RawRemoveMembersMessageInfo, type RemoveMembersMessageData, type RemoveMembersMessageInfo, rawRemoveMembersMessageInfoValidator, } from '../../types/messages/remove-members.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { values } from '../../utils/objects.js'; import { notifRobotextForMessageInfo } from '../notif-utils.js'; export const removeMembersMessageSpec: MessageSpec< RemoveMembersMessageData, RawRemoveMembersMessageInfo, RemoveMembersMessageInfo, > = Object.freeze({ messageContentForServerDB( data: RemoveMembersMessageData | RawRemoveMembersMessageInfo, ): string { return JSON.stringify(data.removedUserIDs); }, messageContentForClientDB(data: RawRemoveMembersMessageInfo): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawRemoveMembersMessageInfo { return { type: messageTypes.REMOVE_MEMBERS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), removedUserIDs: JSON.parse(row.content), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawRemoveMembersMessageInfo { const content = clientDBMessageInfo.content; invariant( content !== undefined && content !== null, 'content must be defined for RemoveMembers', ); const rawRemoveMembersMessageInfo: RawRemoveMembersMessageInfo = { type: messageTypes.REMOVE_MEMBERS, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, removedUserIDs: JSON.parse(content), }; return rawRemoveMembersMessageInfo; }, createMessageInfo( rawMessageInfo: RawRemoveMembersMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): RemoveMembersMessageInfo { const removedMembers = params.createRelativeUserInfos( rawMessageInfo.removedUserIDs, ); return { type: messageTypes.REMOVE_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, removedMembers, }; }, rawMessageInfoFromMessageData( messageData: RemoveMembersMessageData, id: ?string, ): RawRemoveMembersMessageInfo { invariant(id, 'RawRemoveMembersMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: RemoveMembersMessageInfo): EntityText { const users = messageInfo.removedMembers; invariant(users.length !== 0, 'added who??'); const creator = ET.user({ userInfo: messageInfo.creator }); const removedUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); return ET`${creator} removed ${removedUsers}`; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { const removedMembersObject = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); for (const member of messageInfo.removedMembers) { removedMembersObject[member.id] = member; } } const removedMembers = values(removedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, removedMembers }; const robotext = notifRobotextForMessageInfo(mergedMessageInfo, threadInfo); const merged = ET`${robotext} from ${ET.thread({ display: 'shortName', threadInfo, })}`; return { merged, title: threadInfo.uiName, body: robotext, }; }, notificationCollapseKey(rawMessageInfo: RawRemoveMembersMessageInfo): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, - generatesNotifs: async () => undefined, - userIDs(rawMessageInfo: RawRemoveMembersMessageInfo): $ReadOnlyArray { return rawMessageInfo.removedUserIDs; }, validator: rawRemoveMembersMessageInfoValidator, }); diff --git a/lib/shared/messages/toggle-pin-message-spec.js b/lib/shared/messages/toggle-pin-message-spec.js index 93ea7ff0f..bad5c75eb 100644 --- a/lib/shared/messages/toggle-pin-message-spec.js +++ b/lib/shared/messages/toggle-pin-message-spec.js @@ -1,161 +1,159 @@ // @flow import invariant from 'invariant'; import type { MessageSpec, RobotextParams, RawMessageInfoFromServerDBRowParams, } from './message-spec.js'; import type { PlatformDetails } from '../../types/device-types'; import { messageTypes } from '../../types/message-types-enum.js'; import type { ClientDBMessageInfo } from '../../types/message-types.js'; import { type TogglePinMessageData, type TogglePinMessageInfo, type RawTogglePinMessageInfo, rawTogglePinMessageInfoValidator, } from '../../types/messages/toggle-pin.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText } from '../../utils/entity-text.js'; import { getPinnedContentFromClientDBMessageInfo } from '../../utils/message-ops-utils.js'; import { getPinnedContentFromMessage } from '../message-utils.js'; import { hasMinCodeVersion } from '../version-utils.js'; export const togglePinMessageSpec: MessageSpec< TogglePinMessageData, RawTogglePinMessageInfo, TogglePinMessageInfo, > = Object.freeze({ messageContentForServerDB( data: TogglePinMessageData | RawTogglePinMessageInfo, ): string { return JSON.stringify({ action: data.action, threadID: data.threadID, targetMessageID: data.targetMessageID, }); }, messageContentForClientDB(data: RawTogglePinMessageInfo): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawTogglePinMessageInfo { const content = JSON.parse(row.content); const { derivedMessages } = params; const targetMessage = derivedMessages.get(content.targetMessageID); invariant(targetMessage, 'targetMessage should be defined'); return { type: messageTypes.TOGGLE_PIN, id: row.id.toString(), threadID: row.threadID.toString(), targetMessageID: content.targetMessageID.toString(), action: content.action, pinnedContent: getPinnedContentFromMessage(targetMessage), time: row.time, creatorID: row.creatorID.toString(), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawTogglePinMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for TogglePin', ); const content = JSON.parse(clientDBMessageInfo.content); const pinnedContent = getPinnedContentFromClientDBMessageInfo(clientDBMessageInfo); const rawTogglePinMessageInfo: RawTogglePinMessageInfo = { type: messageTypes.TOGGLE_PIN, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, targetMessageID: content.targetMessageID, action: content.action, pinnedContent, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, }; return rawTogglePinMessageInfo; }, createMessageInfo( rawMessageInfo: RawTogglePinMessageInfo, creator: RelativeUserInfo, ): TogglePinMessageInfo { return { type: messageTypes.TOGGLE_PIN, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, targetMessageID: rawMessageInfo.targetMessageID, action: rawMessageInfo.action, pinnedContent: rawMessageInfo.pinnedContent, creator, time: rawMessageInfo.time, }; }, rawMessageInfoFromMessageData( messageData: TogglePinMessageData, id: ?string, ): RawTogglePinMessageInfo { invariant(id, 'RawTogglePinMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: TogglePinMessageInfo, params: RobotextParams, ): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); const action = messageInfo.action === 'pin' ? 'pinned' : 'unpinned'; const pinnedContent = messageInfo.pinnedContent; const preposition = messageInfo.action === 'pin' ? 'in' : 'from'; return ET`${creator} ${action} ${pinnedContent} ${preposition} ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, threadType: params.threadInfo?.type, parentThreadID: params.threadInfo?.parentThreadID, })}`; }, shimUnsupportedMessageInfo( rawMessageInfo: RawTogglePinMessageInfo, platformDetails: ?PlatformDetails, ): RawTogglePinMessageInfo | RawUnsupportedMessageInfo { if (hasMinCodeVersion(platformDetails, { native: 209 })) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'toggled a message pin', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawTogglePinMessageInfo, ): RawTogglePinMessageInfo { return unwrapped; }, - generatesNotifs: async () => undefined, - validator: rawTogglePinMessageInfoValidator, }); diff --git a/native/root.react.js b/native/root.react.js index 04ee6b243..5028d3c90 100644 --- a/native/root.react.js +++ b/native/root.react.js @@ -1,321 +1,322 @@ // @flow import { ActionSheetProvider } from '@expo/react-native-action-sheet'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useReduxDevToolsExtension } from '@react-navigation/devtools'; import { NavigationContainer } from '@react-navigation/native'; import type { PossiblyStaleNavigationState } from '@react-navigation/native'; import * as SplashScreen from 'expo-splash-screen'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, UIManager, StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import { SafeAreaProvider, initialWindowMetrics, } from 'react-native-safe-area-context'; import { Provider } from 'react-redux'; import { PersistGate as ReduxPersistGate } from 'redux-persist/es/integration/react.js'; import { ENSCacheProvider } from 'lib/components/ens-cache-provider.react.js'; import { MediaCacheProvider } from 'lib/components/media-cache-provider.react.js'; import { actionLogger } from 'lib/utils/action-logger.js'; import { RegistrationContextProvider } from './account/registration/registration-context-provider.react.js'; import { EditThreadAvatarProvider } from './avatars/edit-thread-avatar-provider.react.js'; import { EditUserAvatarProvider } from './avatars/edit-user-avatar-provider.react.js'; import ChatContextProvider from './chat/chat-context-provider.react.js'; import { FeatureFlagsProvider } from './components/feature-flags-provider.react.js'; import PersistedStateGate from './components/persisted-state-gate.js'; import ConnectedStatusBar from './connected-status-bar.react.js'; import { SQLiteDataHandler } from './data/sqlite-data-handler.js'; import ErrorBoundary from './error-boundary.react.js'; import InputStateContainer from './input/input-state-container.react.js'; import LifecycleHandler from './lifecycle/lifecycle-handler.react.js'; import MarkdownContextProvider from './markdown/markdown-context-provider.react.js'; import { filesystemMediaCache } from './media/media-cache.js'; import { defaultNavigationState } from './navigation/default-state.js'; import DisconnectedBarVisibilityHandler from './navigation/disconnected-bar-visibility-handler.react.js'; import { setGlobalNavContext } from './navigation/icky-global.js'; import { NavContext } from './navigation/navigation-context.js'; import NavigationHandler from './navigation/navigation-handler.react.js'; import { validNavState } from './navigation/navigation-utils.js'; import OrientationHandler from './navigation/orientation-handler.react.js'; import { navStateAsyncStorageKey } from './navigation/persistance.js'; import RootNavigator from './navigation/root-navigator.react.js'; import ConnectivityUpdater from './redux/connectivity-updater.react.js'; import { DimensionsUpdater } from './redux/dimensions-updater.react.js'; import { getPersistor } from './redux/persist.js'; import { store } from './redux/redux-setup.js'; import { useSelector } from './redux/redux-utils.js'; import { RootContext } from './root-context.js'; import { MessageSearchProvider } from './search/search-provider.react.js'; import Socket from './socket.react.js'; import { StaffContextProvider } from './staff/staff-context.provider.react.js'; import { useLoadCommFonts } from './themes/fonts.js'; import { DarkTheme, LightTheme } from './themes/navigation.js'; import ThemeHandler from './themes/theme-handler.react.js'; import { provider } from './utils/ethers-utils.js'; // Add custom items to expo-dev-menu import './dev-menu.js'; +import './types/message-types-validator.js'; if (Platform.OS === 'android') { UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true); } const navInitAction = Object.freeze({ type: 'NAV/@@INIT' }); const navUnknownAction = Object.freeze({ type: 'NAV/@@UNKNOWN' }); SplashScreen.preventAutoHideAsync().catch(console.log); function Root() { const navStateRef = React.useRef(); const navDispatchRef = React.useRef(); const navStateInitializedRef = React.useRef(false); // We call this here to start the loading process // We gate the UI on the fonts loading in AppNavigator useLoadCommFonts(); const [navContext, setNavContext] = React.useState(null); const updateNavContext = React.useCallback(() => { if ( !navStateRef.current || !navDispatchRef.current || !navStateInitializedRef.current ) { return; } const updatedNavContext = { state: navStateRef.current, dispatch: navDispatchRef.current, }; setNavContext(updatedNavContext); setGlobalNavContext(updatedNavContext); }, []); const [initialState, setInitialState] = React.useState( __DEV__ ? undefined : defaultNavigationState, ); React.useEffect(() => { Orientation.lockToPortrait(); (async () => { let loadedState = initialState; if (__DEV__) { try { const navStateString = await AsyncStorage.getItem( navStateAsyncStorageKey, ); if (navStateString) { const savedState = JSON.parse(navStateString); if (validNavState(savedState)) { loadedState = savedState; } } } catch {} } if (!loadedState) { loadedState = defaultNavigationState; } if (loadedState !== initialState) { setInitialState(loadedState); } navStateRef.current = loadedState; updateNavContext(); actionLogger.addOtherAction('navState', navInitAction, null, loadedState); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateNavContext]); const setNavStateInitialized = React.useCallback(() => { navStateInitializedRef.current = true; updateNavContext(); }, [updateNavContext]); const [rootContext, setRootContext] = React.useState(() => ({ setNavStateInitialized, })); const detectUnsupervisedBackgroundRef = React.useCallback( (detectUnsupervisedBackground: ?(alreadyClosed: boolean) => boolean) => { setRootContext(prevRootContext => ({ ...prevRootContext, detectUnsupervisedBackground, })); }, [], ); const frozen = useSelector(state => state.frozen); const queuedActionsRef = React.useRef([]); const onNavigationStateChange = React.useCallback( (state: ?PossiblyStaleNavigationState) => { invariant(state, 'nav state should be non-null'); const prevState = navStateRef.current; navStateRef.current = state; updateNavContext(); const queuedActions = queuedActionsRef.current; queuedActionsRef.current = []; if (queuedActions.length === 0) { queuedActions.push(navUnknownAction); } for (const action of queuedActions) { actionLogger.addOtherAction('navState', action, prevState, state); } if (!__DEV__ || frozen) { return; } (async () => { try { await AsyncStorage.setItem( navStateAsyncStorageKey, JSON.stringify(state), ); } catch (e) { console.log('AsyncStorage threw while trying to persist navState', e); } })(); }, [updateNavContext, frozen], ); const navContainerRef = React.useRef(); const containerRef = React.useCallback( (navContainer: ?React.ElementRef) => { navContainerRef.current = navContainer; if (navContainer && !navDispatchRef.current) { navDispatchRef.current = navContainer.dispatch; updateNavContext(); } }, [updateNavContext], ); useReduxDevToolsExtension(navContainerRef); const navContainer = navContainerRef.current; React.useEffect(() => { if (!navContainer) { return undefined; } return navContainer.addListener('__unsafe_action__', event => { const { action, noop } = event.data; const navState = navStateRef.current; if (noop) { actionLogger.addOtherAction('navState', action, navState, navState); return; } queuedActionsRef.current.push({ ...action, type: `NAV/${action.type}`, }); }); }, [navContainer]); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const theme = (() => { if (activeTheme === 'light') { return LightTheme; } else if (activeTheme === 'dark') { return DarkTheme; } return undefined; })(); const gated: React.Node = ( <> ); let navigation; if (initialState) { navigation = ( ); } return ( {gated} {navigation} ); } const styles = StyleSheet.create({ app: { flex: 1, }, }); function AppRoot(): React.Node { return ( ); } export default AppRoot; diff --git a/native/types/message-types-validator.js b/native/types/message-types-validator.js new file mode 100644 index 000000000..be4c57873 --- /dev/null +++ b/native/types/message-types-validator.js @@ -0,0 +1,29 @@ +// @flow +import { Alert } from 'react-native'; + +import { messageSpecs } from 'lib/shared/messages/message-specs.js'; +import { messageTypes } from 'lib/types/message-types-enum.js'; + +import { commConstants } from '../native-modules.js'; + +if (__DEV__) { + const messageTypesCpp = new Set(commConstants.NATIVE_MESSAGE_TYPES); + const missingMessageTypesCpp = []; + for (const messageName in messageTypes) { + const messageType = messageTypes[messageName]; + if ( + messageSpecs[messageType]?.generatesNotifs && + !messageTypesCpp.has(messageType) + ) { + missingMessageTypesCpp.push(messageName); + } + } + + if (missingMessageTypesCpp.length !== 0) { + Alert.alert( + 'C++ MessageSpecs missing', + 'C++ MessageSpec implementation missing for message types: ' + + missingMessageTypesCpp.join(', '), + ); + } +}