diff --git a/keyserver/src/creators/message-creator.js b/keyserver/src/creators/message-creator.js index 7b161f81e..8f499b0ad 100644 --- a/keyserver/src/creators/message-creator.js +++ b/keyserver/src/creators/message-creator.js @@ -1,687 +1,687 @@ // @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, 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-types.js'; import { updateTypes } from 'lib/types/update-types.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, +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; const targetMessageID = messageData.targetMessageID ? messageData.targetMessageID : null; 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, 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); 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) { thisUserInfo.devices.set(deviceToken, { platform, deviceToken, 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]; 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; } - return await createUpdates(readStatusUpdates); + 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)); - return dbQuery(query); + dbQuery(query); } export default createMessages; diff --git a/keyserver/src/fetchers/message-fetchers.js b/keyserver/src/fetchers/message-fetchers.js index 1f497025a..c2ad83f88 100644 --- a/keyserver/src/fetchers/message-fetchers.js +++ b/keyserver/src/fetchers/message-fetchers.js @@ -1,837 +1,839 @@ // @flow import invariant from 'invariant'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, } from 'lib/shared/message-utils.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { getNotifCollapseKey } from 'lib/shared/notif-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { type RawMessageInfo, type RawComposableMessageInfo, type RawRobotextMessageInfo, messageTypes, type MessageType, type EditMessageContent, assertMessageType, type MessageSelectionCriteria, type MessageTruncationStatus, messageTruncationStatus, type FetchMessageInfosResult, defaultMaxMessageAge, type FetchPinnedMessagesRequest, type FetchPinnedMessagesResult, } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { constructMediaFromMediaMessageContentsAndUploadRows, imagesFromRow, } from './upload-fetchers.js'; import { dbQuery, SQL, mergeOrConditions, mergeAndConditions, } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import type { PushInfo } from '../push/send.js'; import type { Viewer } from '../session/viewer.js'; import { creationString, localIDFromCreationString, } from '../utils/idempotent.js'; export type CollapsableNotifInfo = { collapseKey: ?string, existingMessageInfos: RawMessageInfo[], newMessageInfos: RawMessageInfo[], }; export type FetchCollapsableNotifsResult = { [userID: string]: CollapsableNotifInfo[], }; const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; // This function doesn't filter RawMessageInfos based on what messageTypes the // client supports, since each user can have multiple clients. The caller must // handle this filtering. async function fetchCollapsableNotifs( pushInfo: PushInfo, ): Promise { // First, we need to fetch any notifications that should be collapsed const usersToCollapseKeysToInfo = {}; const usersToCollapsableNotifInfo = {}; for (const userID in pushInfo) { usersToCollapseKeysToInfo[userID] = {}; usersToCollapsableNotifInfo[userID] = []; for (let i = 0; i < pushInfo[userID].messageInfos.length; i++) { const rawMessageInfo = pushInfo[userID].messageInfos[i]; const messageData = pushInfo[userID].messageDatas[i]; const collapseKey = getNotifCollapseKey(rawMessageInfo, messageData); if (!collapseKey) { const collapsableNotifInfo = { collapseKey, existingMessageInfos: [], newMessageInfos: [rawMessageInfo], }; usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo); continue; } if (!usersToCollapseKeysToInfo[userID][collapseKey]) { usersToCollapseKeysToInfo[userID][collapseKey] = { collapseKey, existingMessageInfos: [], newMessageInfos: [], }; } usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push( rawMessageInfo, ); } } const sqlTuples = []; for (const userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (const collapseKey in collapseKeysToInfo) { sqlTuples.push( SQL`(n.user = ${userID} AND n.collapse_key = ${collapseKey})`, ); } } if (sqlTuples.length === 0) { return usersToCollapsableNotifInfo; } const collapseQuery = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, n.user, n.collapse_key, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM notifications n LEFT JOIN messages m ON m.id = n.message LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = n.user LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = n.user WHERE n.rescinded = 0 AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; collapseQuery.append(mergeOrConditions(sqlTuples)); collapseQuery.append(SQL`ORDER BY m.time DESC, m.id DESC`); const [collapseResult] = await dbQuery(collapseQuery); const rowsByUser = new Map(); for (const row of collapseResult) { const user = row.user.toString(); const currentRowsForUser = rowsByUser.get(user); if (currentRowsForUser) { currentRowsForUser.push(row); } else { rowsByUser.set(user, [row]); } } const derivedMessages = await fetchDerivedMessages(collapseResult); for (const userRows of rowsByUser.values()) { const messages = parseMessageSQLResult(userRows, derivedMessages); for (const message of messages) { const { rawMessageInfo, rows } = message; const [row] = rows; const info = usersToCollapseKeysToInfo[row.user][row.collapse_key]; info.existingMessageInfos.push(rawMessageInfo); } } for (const userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (const collapseKey in collapseKeysToInfo) { const info = collapseKeysToInfo[collapseKey]; usersToCollapsableNotifInfo[userID].push({ collapseKey: info.collapseKey, existingMessageInfos: sortMessageInfoList(info.existingMessageInfos), newMessageInfos: sortMessageInfoList(info.newMessageInfos), }); } } return usersToCollapsableNotifInfo; } type MessageSQLResult = $ReadOnlyArray<{ rawMessageInfo: RawMessageInfo, rows: $ReadOnlyArray, }>; function parseMessageSQLResult( rows: $ReadOnlyArray, derivedMessages: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, viewer?: Viewer, ): MessageSQLResult { const rowsByID = new Map(); for (const row of rows) { const id = row.id.toString(); const currentRowsForID = rowsByID.get(id); if (currentRowsForID) { currentRowsForID.push(row); } else { rowsByID.set(id, [row]); } } const messages = []; for (const messageRows of rowsByID.values()) { const rawMessageInfo = rawMessageInfoFromRows( messageRows, viewer, derivedMessages, ); if (rawMessageInfo) { messages.push({ rawMessageInfo, rows: messageRows }); } } return messages; } function assertSingleRow(rows: $ReadOnlyArray): Object { if (rows.length === 0) { throw new Error('expected single row, but none present!'); } else if (rows.length !== 1) { const messageIDs = rows.map(row => row.id.toString()); console.warn( `expected single row, but there are multiple! ${messageIDs.join(', ')}`, ); } return rows[0]; } function mostRecentRowType(rows: $ReadOnlyArray): MessageType { if (rows.length === 0) { throw new Error('expected row, but none present!'); } return assertMessageType(rows[0].type); } function rawMessageInfoFromRows( rawRows: $ReadOnlyArray, viewer?: Viewer, derivedMessages: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, ): ?RawMessageInfo { const rows = rawRows.map(row => ({ ...row, subthread_permissions: JSON.parse(row.subthread_permissions), })); const type = mostRecentRowType(rows); const messageSpec = messageSpecs[type]; if (type === messageTypes.IMAGES || type === messageTypes.MULTIMEDIA) { let media; if (type === messageTypes.MULTIMEDIA) { const mediaMessageContents = JSON.parse(rows[0].content); media = constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents, rows, ); } else { media = rows.filter(row => row.uploadID).map(imagesFromRow); } const [row] = rows; const localID = localIDFromCreationString(viewer, row.creation); invariant( messageSpec.rawMessageInfoFromServerDBRow, `multimedia message spec should have rawMessageInfoFromServerDBRow`, ); return messageSpec.rawMessageInfoFromServerDBRow(row, { media, derivedMessages, localID, }); } const row = assertSingleRow(rows); const localID = localIDFromCreationString(viewer, row.creation); invariant( messageSpec.rawMessageInfoFromServerDBRow, `message spec ${type} should have rawMessageInfoFromServerDBRow`, ); return messageSpec.rawMessageInfoFromServerDBRow(row, { derivedMessages, localID, }); } async function fetchMessageInfos( viewer: Viewer, criteria: MessageSelectionCriteria, numberPerThread: number, ): Promise { const { sqlClause: selectionClause, timeFilterData } = parseMessageSelectionCriteria(viewer, criteria); const truncationStatuses = {}; const viewerID = viewer.id; const query = SQL` WITH thread_window AS ( SELECT m.id, m.thread AS threadID, m.user AS creatorID, m.target_message as targetMessageID, m.content, m.time, m.type, m.creation, stm.permissions AS subthread_permissions, ROW_NUMBER() OVER ( PARTITION BY threadID ORDER BY m.time DESC, m.id DESC ) n FROM messages m LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(selectionClause); query.append(SQL` ) SELECT tw.*, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM thread_window tw LEFT JOIN uploads up ON up.container = tw.id WHERE tw.n <= ${numberPerThread} ORDER BY tw.threadID, tw.time DESC, tw.id DESC `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = await parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; const threadToMessageCount = new Map(); for (const message of messages) { const { rawMessageInfo } = message; rawMessageInfos.push(rawMessageInfo); const { threadID } = rawMessageInfo; const currentCountValue = threadToMessageCount.get(threadID); const currentCount = currentCountValue ? currentCountValue : 0; threadToMessageCount.set(threadID, currentCount + 1); } for (const [threadID, messageCount] of threadToMessageCount) { // If we matched the exact amount we limited to, we're probably truncating // our result set. By setting TRUNCATED here, we tell the client that the // result set might not be continguous with what's already in their // MessageStore. More details about TRUNCATED can be found in // lib/types/message-types.js if (messageCount >= numberPerThread) { // We won't set TRUNCATED if a cursor was specified for a given thread, // since then the result is guaranteed to be contiguous with what the // client has if (criteria.threadCursors && criteria.threadCursors[threadID]) { truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } else { truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED; } continue; } const hasTimeFilter = hasTimeFilterForThread(timeFilterData, threadID); if (!hasTimeFilter) { // If there is no time filter for a given thread, and there are fewer // messages returned than the max we queried for a given thread, we can // conclude that our result set includes all messages for that thread truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } } for (const rawMessageInfo of rawMessageInfos) { if (messageSpecs[rawMessageInfo.type].startsThread) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } } for (const threadID in criteria.threadCursors) { const truncationStatus = truncationStatuses[threadID]; if (truncationStatus !== null && truncationStatus !== undefined) { continue; } const hasTimeFilter = hasTimeFilterForThread(timeFilterData, threadID); if (!hasTimeFilter) { // If there is no time filter for a given thread, and zero messages were // returned, we can conclude that this thread has zero messages. This is // a case of database corruption that should not be possible, but likely // we have some threads like this on prod (either due to some transient // issues or due to old buggy code) truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } else { // If this thread was explicitly queried for, and we got no results, but // we can't conclude that it's EXHAUSTIVE, then we'll set to UNCHANGED. truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } +// ESLint doesn't recognize that invariant always throws +// eslint-disable-next-line consistent-return function hasTimeFilterForThread( timeFilterData: TimeFilterData, threadID: string, ) { if (timeFilterData.timeFilter === 'ALL') { return true; } else if (timeFilterData.timeFilter === 'NONE') { return false; } else if (timeFilterData.timeFilter === 'ALL_EXCEPT_EXCLUDED') { return !timeFilterData.excludedFromTimeFilter.has(threadID); } else { invariant( false, `unrecognized timeFilter type ${timeFilterData.timeFilter}`, ); } } type TimeFilterData = | { +timeFilter: 'ALL' | 'NONE' } | { +timeFilter: 'ALL_EXCEPT_EXCLUDED', +excludedFromTimeFilter: $ReadOnlySet, }; type ParsedMessageSelectionCriteria = { +sqlClause: SQLStatementType, +timeFilterData: TimeFilterData, }; function parseMessageSelectionCriteria( viewer: Viewer, criteria: MessageSelectionCriteria, ): ParsedMessageSelectionCriteria { const minMessageTime = Date.now() - defaultMaxMessageAge; const shouldApplyTimeFilter = hasMinCodeVersion(viewer.platformDetails, 130); let globalTimeFilter; if (criteria.newerThan) { globalTimeFilter = SQL`m.time > ${criteria.newerThan}`; } else if (!criteria.threadCursors && shouldApplyTimeFilter) { globalTimeFilter = SQL`m.time > ${minMessageTime}`; } const threadConditions = []; if ( criteria.joinedThreads === true && shouldApplyTimeFilter && !globalTimeFilter ) { threadConditions.push(SQL`(mm.role > 0 AND m.time > ${minMessageTime})`); } else if (criteria.joinedThreads === true) { threadConditions.push(SQL`mm.role > 0`); } if (criteria.threadCursors) { for (const threadID in criteria.threadCursors) { const cursor = criteria.threadCursors[threadID]; if (cursor) { threadConditions.push( SQL`(m.thread = ${threadID} AND m.id < ${cursor})`, ); } else { threadConditions.push(SQL`m.thread = ${threadID}`); } } } if (threadConditions.length === 0) { throw new ServerError('internal_error'); } const threadClause = mergeOrConditions(threadConditions); let timeFilterData; if (globalTimeFilter) { timeFilterData = { timeFilter: 'ALL' }; } else if (!shouldApplyTimeFilter) { timeFilterData = { timeFilter: 'NONE' }; } else { invariant( criteria.threadCursors, 'ALL_EXCEPT_EXCLUDED should correspond to threadCursors being set', ); const excludedFromTimeFilter = new Set(Object.keys(criteria.threadCursors)); timeFilterData = { timeFilter: 'ALL_EXCEPT_EXCLUDED', excludedFromTimeFilter, }; } const conditions = [globalTimeFilter, threadClause].filter(Boolean); const sqlClause = mergeAndConditions(conditions); return { sqlClause, timeFilterData }; } function messageSelectionCriteriaToInitialTruncationStatuses( criteria: MessageSelectionCriteria, defaultTruncationStatus: MessageTruncationStatus, ) { const truncationStatuses = {}; if (criteria.threadCursors) { for (const threadID in criteria.threadCursors) { truncationStatuses[threadID] = defaultTruncationStatus; } } return truncationStatuses; } async function fetchMessageInfosSince( viewer: Viewer, criteria: MessageSelectionCriteria, maxNumberPerThread: number, ): Promise { const { sqlClause: selectionClause } = parseMessageSelectionCriteria( viewer, criteria, ); const truncationStatuses = messageSelectionCriteriaToInitialTruncationStatuses( criteria, messageTruncationStatus.UNCHANGED, ); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(selectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC, m.id DESC `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = await parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; let currentThreadID = null; let numMessagesForCurrentThreadID = 0; for (const message of messages) { const { rawMessageInfo } = message; const { threadID } = rawMessageInfo; if (threadID !== currentThreadID) { currentThreadID = threadID; numMessagesForCurrentThreadID = 1; truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } else { numMessagesForCurrentThreadID++; } if (numMessagesForCurrentThreadID <= maxNumberPerThread) { if (messageSpecs[rawMessageInfo.type].startsThread) { truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } rawMessageInfos.push(rawMessageInfo); } else if (numMessagesForCurrentThreadID === maxNumberPerThread + 1) { truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } function getMessageFetchResultFromRedisMessages( viewer: Viewer, rawMessageInfos: $ReadOnlyArray, ): FetchMessageInfosResult { const truncationStatuses = {}; for (const rawMessageInfo of rawMessageInfos) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } async function fetchMessageInfoForLocalID( viewer: Viewer, localID: ?string, ): Promise { if (!localID || !viewer.hasSessionInfo) { return null; } const creation = creationString(viewer, localID); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.creation = ${creation} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } const entryIDExtractString = '$.entryID'; async function fetchMessageInfoForEntryAction( viewer: Viewer, messageType: MessageType, entryID: string, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.thread = ${threadID} AND m.type = ${messageType} AND JSON_EXTRACT(m.content, ${entryIDExtractString}) = ${entryID} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } async function fetchMessageRowsByIDs(messageIDs: $ReadOnlyArray) { const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = m.user WHERE m.id IN (${messageIDs}) `; const [result] = await dbQuery(query); return result; } async function fetchPinnedMessageInfos( viewer: Viewer, request: FetchPinnedMessagesRequest, ): Promise { // The only message types that can be pinned are 0, 14, and 15 // (text, images, and multimedia), so we don't need to worry about // an admin pinning a message about creating a secret subchannel. This is // why we don't check subthread permissions (as opposed to other queries). const messageRowsQuery = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, NULL AS subthread_permissions, u.id AS uploadID, u.type AS uploadType, u.secret AS uploadSecret, u.extra AS uploadExtra FROM messages m LEFT JOIN uploads u ON u.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewer.id} WHERE m.thread = ${request.threadID} AND m.pinned = 1 AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE ORDER BY m.pin_time DESC `; const [messageRows] = await dbQuery(messageRowsQuery); if (messageRows.length === 0) { return { pinnedMessages: [] }; } const derivedMessages = await fetchDerivedMessages(messageRows, viewer); const parsedPinnedMessages = await parseMessageSQLResult( messageRows, derivedMessages, viewer, ); const pinnedRawMessageInfos = parsedPinnedMessages.map( message => message.rawMessageInfo, ); const shimmedPinnedRawMessageInfos = shimUnsupportedRawMessageInfos( pinnedRawMessageInfos, viewer.platformDetails, ); return { pinnedMessages: shimmedPinnedRawMessageInfos, }; } async function fetchDerivedMessages( rows: $ReadOnlyArray, viewer?: Viewer, ): Promise< $ReadOnlyMap, > { const requiredIDs = new Set(); for (const row of rows) { if (row.type === messageTypes.SIDEBAR_SOURCE) { const content = JSON.parse(row.content); requiredIDs.add(content.sourceMessageID); } else if (row.type === messageTypes.TOGGLE_PIN) { const content = JSON.parse(row.content); requiredIDs.add(content.targetMessageID); } } const messagesByID = new Map< string, RawComposableMessageInfo | RawRobotextMessageInfo, >(); if (requiredIDs.size === 0) { return messagesByID; } const [result, edits] = await Promise.all([ fetchMessageRowsByIDs([...requiredIDs]), fetchLatestEditMessageContentByIDs([...requiredIDs]), ]); const messages = await parseMessageSQLResult(result, new Map(), viewer); for (const message of messages) { let { rawMessageInfo } = message; if (rawMessageInfo.id) { invariant( rawMessageInfo.type !== messageTypes.SIDEBAR_SOURCE && rawMessageInfo.type !== messageTypes.REACTION && rawMessageInfo.type !== messageTypes.EDIT_MESSAGE, 'SIDEBAR_SOURCE should not point to a SIDEBAR_SOURCE, REACTION or EDIT_MESSAGE', ); const editedContent = edits.get(rawMessageInfo.id); if (editedContent && rawMessageInfo.type === messageTypes.TEXT) { rawMessageInfo = { ...rawMessageInfo, text: editedContent.text, }; } invariant(rawMessageInfo.id, 'rawMessageInfo.id should not be null'); messagesByID.set(rawMessageInfo.id, rawMessageInfo); } } return messagesByID; } async function fetchMessageInfoByID( viewer?: Viewer, messageID: string, ): Promise { const result = await fetchMessageRowsByIDs([messageID]); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } async function fetchThreadMessagesCount(threadID: string): Promise { const query = SQL` SELECT COUNT(*) AS count FROM messages WHERE thread = ${threadID} `; const [result] = await dbQuery(query); return result[0].count; } async function fetchLatestEditMessageContentByIDs( messageIDs: $ReadOnlyArray, ): Promise<$ReadOnlyMap> { const latestEditedMessageQuery = SQL` SELECT m.id, ( SELECT m2.content FROM messages m2 WHERE m.id = m2.target_message AND m.thread = m2.thread AND m2.type = ${messageTypes.EDIT_MESSAGE} ORDER BY time DESC, id DESC LIMIT 1 ) content FROM messages m WHERE m.id IN(${messageIDs}) `; const [result] = await dbQuery(latestEditedMessageQuery); const latestContentByID = new Map(); for (const row of result) { if (!row.content) { continue; } const content = JSON.parse(row.content); latestContentByID.set(row.id.toString(), content); } return latestContentByID; } async function fetchLatestEditMessageContentByID( messageID: string, ): Promise { const result = await fetchLatestEditMessageContentByIDs([messageID]); const content = result.get(messageID); return content; } export { fetchCollapsableNotifs, fetchMessageInfos, fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, fetchMessageInfoForLocalID, fetchMessageInfoForEntryAction, fetchMessageInfoByID, fetchThreadMessagesCount, fetchLatestEditMessageContentByID, fetchPinnedMessageInfos, }; diff --git a/keyserver/src/fetchers/update-fetchers.js b/keyserver/src/fetchers/update-fetchers.js index f6d160e38..bc4e57a9a 100644 --- a/keyserver/src/fetchers/update-fetchers.js +++ b/keyserver/src/fetchers/update-fetchers.js @@ -1,173 +1,175 @@ // @flow import invariant from 'invariant'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import { type RawUpdateInfo, updateTypes, assertUpdateType, } from 'lib/types/update-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { type ViewerInfo, type FetchUpdatesResult, fetchUpdateInfosWithRawUpdateInfos, } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import type { Viewer } from '../session/viewer.js'; const defaultUpdateFetchResult = { updateInfos: [], userInfos: {} }; async function fetchUpdateInfosWithQuery( viewerInfo: ViewerInfo, query: SQLStatementType, ): Promise { if (!viewerInfo.viewer.loggedIn) { throw new ServerError('not_logged_in'); } if (viewerInfo.viewer.isScriptViewer) { return defaultUpdateFetchResult; } const [result] = await dbQuery(query); const rawUpdateInfos = []; for (const row of result) { rawUpdateInfos.push(rawUpdateInfoFromRow(row)); } return await fetchUpdateInfosWithRawUpdateInfos(rawUpdateInfos, viewerInfo); } function fetchUpdateInfos( viewer: Viewer, currentAsOf: number, calendarQuery: CalendarQuery, ): Promise { const query = SQL` SELECT id, type, content, time FROM updates WHERE user = ${viewer.id} AND time > ${currentAsOf} AND (updater IS NULL OR updater != ${viewer.session}) AND (target IS NULL OR target = ${viewer.session}) ORDER BY time ASC `; return fetchUpdateInfosWithQuery({ viewer, calendarQuery }, query); } +// ESLint doesn't recognize that invariant always throws +// eslint-disable-next-line consistent-return function rawUpdateInfoFromRow(row: Object): RawUpdateInfo { const type = assertUpdateType(row.type); if (type === updateTypes.DELETE_ACCOUNT) { const content = JSON.parse(row.content); return { type: updateTypes.DELETE_ACCOUNT, id: row.id.toString(), time: row.time, deletedUserID: content.deletedUserID, }; } else if (type === updateTypes.UPDATE_THREAD) { const { threadID } = JSON.parse(row.content); return { type: updateTypes.UPDATE_THREAD, id: row.id.toString(), time: row.time, threadID, }; } else if (type === updateTypes.UPDATE_THREAD_READ_STATUS) { const { threadID, unread } = JSON.parse(row.content); return { type: updateTypes.UPDATE_THREAD_READ_STATUS, id: row.id.toString(), time: row.time, threadID, unread, }; } else if (type === updateTypes.DELETE_THREAD) { const { threadID } = JSON.parse(row.content); return { type: updateTypes.DELETE_THREAD, id: row.id.toString(), time: row.time, threadID, }; } else if (type === updateTypes.JOIN_THREAD) { const { threadID } = JSON.parse(row.content); return { type: updateTypes.JOIN_THREAD, id: row.id.toString(), time: row.time, threadID, }; } else if (type === updateTypes.BAD_DEVICE_TOKEN) { const { deviceToken } = JSON.parse(row.content); return { type: updateTypes.BAD_DEVICE_TOKEN, id: row.id.toString(), time: row.time, deviceToken, }; } else if (type === updateTypes.UPDATE_ENTRY) { const { entryID } = JSON.parse(row.content); return { type: updateTypes.UPDATE_ENTRY, id: row.id.toString(), time: row.time, entryID, }; } else if (type === updateTypes.UPDATE_CURRENT_USER) { return { type: updateTypes.UPDATE_CURRENT_USER, id: row.id.toString(), time: row.time, }; } else if (type === updateTypes.UPDATE_USER) { const content = JSON.parse(row.content); return { type: updateTypes.UPDATE_USER, id: row.id.toString(), time: row.time, updatedUserID: content.updatedUserID, }; } invariant(false, `unrecognized updateType ${type}`); } const entryIDExtractString = '$.entryID'; function fetchUpdateInfoForEntryUpdate( viewer: Viewer, entryID: string, ): Promise { const query = SQL` SELECT id, type, content, time FROM updates WHERE user = ${viewer.id} AND type = ${updateTypes.UPDATE_ENTRY} AND JSON_EXTRACT(content, ${entryIDExtractString}) = ${entryID} ORDER BY time DESC LIMIT 1 `; return fetchUpdateInfosWithQuery({ viewer }, query); } const threadIDExtractString = '$.threadID'; function fetchUpdateInfoForThreadDeletion( viewer: Viewer, threadID: string, ): Promise { const query = SQL` SELECT id, type, content, time FROM updates WHERE user = ${viewer.id} AND type = ${updateTypes.DELETE_THREAD} AND JSON_EXTRACT(content, ${threadIDExtractString}) = ${threadID} ORDER BY time DESC LIMIT 1 `; return fetchUpdateInfosWithQuery({ viewer }, query); } export { fetchUpdateInfos, fetchUpdateInfoForEntryUpdate, fetchUpdateInfoForThreadDeletion, }; diff --git a/keyserver/src/scripts/set-last-read-messages.js b/keyserver/src/scripts/set-last-read-messages.js index 402eb2ecc..f5418568f 100644 --- a/keyserver/src/scripts/set-last-read-messages.js +++ b/keyserver/src/scripts/set-last-read-messages.js @@ -1,130 +1,130 @@ // @flow import { messageTypes } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-types.js'; import { endScript } from './utils.js'; import { dbQuery, SQL } from '../database/database.js'; async function main() { try { await createLastMessageColumn(); await setLastReadMessage(); } catch (e) { console.warn(e); } finally { endScript(); } } async function createLastMessageColumn() { try { - return await dbQuery(SQL` + await dbQuery(SQL` ALTER TABLE memberships ADD last_read_message bigint(20) NOT NULL DEFAULT 0, ADD last_message bigint(20) NOT NULL DEFAULT 0 `); } catch (e) { console.info('Column probably exists', e); } } async function setLastReadMessage() { const knowOfExtractString = `$.${threadPermissions.KNOW_OF}.value`; const [result] = await dbQuery(SQL` SELECT MAX(msg.id) AS message, msg.thread, stm.user FROM messages msg LEFT JOIN memberships stm ON msg.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = msg.content WHERE msg.type != ${messageTypes.CREATE_SUB_THREAD} OR JSON_EXTRACT(stm.permissions, ${knowOfExtractString}) IS TRUE GROUP BY msg.thread, stm.user `); const lastMessages = []; const userSpecificLastMessages = []; for (const row of result) { if (row.user) { userSpecificLastMessages.push({ message: row.message, thread: row.thread, user: row.user, }); } else { lastMessages.push({ message: row.message, thread: row.thread, }); } } if (lastMessages.length > 0) { const lastMessageExpression = SQL`last_message = CASE `; const lastReadMessageExpression = SQL`last_read_message = CASE `; for (const entry of lastMessages) { lastMessageExpression.append(SQL` WHEN thread = ${entry.thread} THEN ${entry.message} `); lastReadMessageExpression.append(SQL` WHEN thread = ${entry.thread} AND unread = 0 THEN ${entry.message} `); } lastMessageExpression.append(SQL` ELSE last_message END, `); lastReadMessageExpression.append(SQL` ELSE last_read_message END `); const query = SQL` UPDATE memberships SET `; query.append(lastMessageExpression); query.append(lastReadMessageExpression); await dbQuery(query); } if (userSpecificLastMessages.length > 0) { const lastMessageExpression = SQL` last_message = GREATEST(last_message, CASE `; const lastReadMessageExpression = SQL` last_read_message = GREATEST(last_read_message, CASE `; for (const entry of userSpecificLastMessages) { lastMessageExpression.append(SQL` WHEN thread = ${entry.thread} AND user = ${entry.user} THEN ${entry.message} `); lastReadMessageExpression.append(SQL` WHEN thread = ${entry.thread} AND unread = 0 AND user = ${entry.user} THEN ${entry.message} `); } lastMessageExpression.append(SQL` ELSE last_message END), `); lastReadMessageExpression.append(SQL` ELSE last_read_message END) `); const query = SQL` UPDATE memberships SET `; query.append(lastMessageExpression); query.append(lastReadMessageExpression); await dbQuery(query); } } main(); diff --git a/keyserver/src/scripts/soft-launch-migration.js b/keyserver/src/scripts/soft-launch-migration.js index fc9d141dd..affce406a 100644 --- a/keyserver/src/scripts/soft-launch-migration.js +++ b/keyserver/src/scripts/soft-launch-migration.js @@ -1,429 +1,430 @@ // @flow import invariant from 'invariant'; import ashoat from 'lib/facts/ashoat.js'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import testers from 'lib/facts/testers.js'; import { messageTypes } from 'lib/types/message-types.js'; import { threadTypes, type ThreadType } from 'lib/types/thread-types.js'; import { main } from './utils.js'; import createMessages from '../creators/message-creator.js'; import { createThread } from '../creators/thread-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchAllUserIDs } from '../fetchers/user-fetchers.js'; import { createScriptViewer } from '../session/scripts.js'; import type { Viewer } from '../session/viewer.js'; import { recalculateThreadPermissions, commitMembershipChangeset, saveMemberships, } from '../updaters/thread-permission-updaters.js'; import { updateThread } from '../updaters/thread-updaters.js'; const batchSize = 10; const createThreadOptions = { forceAddMembers: true }; const updateThreadOptions = { forceUpdateRoot: true, silenceMessages: true, ignorePermissions: true, }; const convertUnadminnedToCommunities = ['311733', '421638']; const convertToAnnouncementCommunities = ['375310']; const convertToAnnouncementSubthreads = ['82649']; const threadsWithMissingParent = ['534395']; const personalThreadsWithMissingMembers = [ '82161', '103111', '210609', '227049', ]; const excludeFromTestersThread = new Set([ '1402', '39227', '156159', '526973', '740732', ]); async function createGenesisCommunity() { const genesisThreadInfos = await fetchServerThreadInfos( SQL`t.id = ${genesis.id}`, ); const genesisThreadInfo = genesisThreadInfos.threadInfos[genesis.id]; if (genesisThreadInfo && genesisThreadInfo.type === threadTypes.GENESIS) { return; } else if (genesisThreadInfo) { - return await updateGenesisCommunityType(); + await updateGenesisCommunityType(); + return; } console.log('creating GENESIS community'); const idInsertQuery = SQL` INSERT INTO ids(id, table_name) VALUES ${[[genesis.id, 'threads']]} `; await dbQuery(idInsertQuery); const ashoatViewer = createScriptViewer(ashoat.id); const allUserIDs = await fetchAllUserIDs(); const nonAshoatUserIDs = allUserIDs.filter(id => id !== ashoat.id); await createThread( ashoatViewer, { id: genesis.id, type: threadTypes.GENESIS, name: genesis.name, description: genesis.description, initialMemberIDs: nonAshoatUserIDs, }, createThreadOptions, ); await createMessages( ashoatViewer, genesis.introMessages.map(message => ({ type: messageTypes.TEXT, threadID: genesis.id, creatorID: ashoat.id, time: Date.now(), text: message, })), ); console.log('creating testers thread'); const testerUserIDs = nonAshoatUserIDs.filter( userID => !excludeFromTestersThread.has(userID), ); const { newThreadID } = await createThread( ashoatViewer, { type: threadTypes.COMMUNITY_SECRET_SUBTHREAD, name: testers.name, description: testers.description, initialMemberIDs: testerUserIDs, }, createThreadOptions, ); invariant( newThreadID, 'newThreadID for tester thread creation should be set', ); await createMessages( ashoatViewer, testers.introMessages.map(message => ({ type: messageTypes.TEXT, threadID: newThreadID, creatorID: ashoat.id, time: Date.now(), text: message, })), ); } async function updateGenesisCommunityType() { console.log('updating GENESIS community to GENESIS type'); const ashoatViewer = createScriptViewer(ashoat.id); await updateThread( ashoatViewer, { threadID: genesis.id, changes: { type: threadTypes.GENESIS, }, }, updateThreadOptions, ); } async function convertExistingCommunities() { const communityQuery = SQL` SELECT t.id, t.name FROM threads t LEFT JOIN roles r ON r.thread = t.id LEFT JOIN memberships m ON m.thread = t.id WHERE t.type = ${threadTypes.COMMUNITY_SECRET_SUBTHREAD} AND t.parent_thread_id IS NULL GROUP BY t.id HAVING COUNT(DISTINCT r.id) > 1 AND COUNT(DISTINCT m.user) > 2 `; const [convertToCommunity] = await dbQuery(communityQuery); const botViewer = createScriptViewer(bots.commbot.userID); await convertThreads( botViewer, convertToCommunity, threadTypes.COMMUNITY_ROOT, ); } async function convertThreads( viewer: Viewer, threads: Array<{ +id: number, +name: string }>, type: ThreadType, ) { while (threads.length > 0) { const batch = threads.splice(0, batchSize); await Promise.all( batch.map(async thread => { console.log(`converting ${JSON.stringify(thread)} to ${type}`); return await updateThread( viewer, { threadID: thread.id.toString(), changes: { type }, }, updateThreadOptions, ); }), ); } } async function convertUnadminnedCommunities() { const communityQuery = SQL` SELECT id, name FROM threads WHERE id IN (${convertUnadminnedToCommunities}) AND type = ${threadTypes.COMMUNITY_SECRET_SUBTHREAD} `; const [convertToCommunity] = await dbQuery(communityQuery); // We use ashoat here to make sure he becomes the admin of these communities const ashoatViewer = createScriptViewer(ashoat.id); await convertThreads( ashoatViewer, convertToCommunity, threadTypes.COMMUNITY_ROOT, ); } async function convertAnnouncementCommunities() { const announcementCommunityQuery = SQL` SELECT id, name FROM threads WHERE id IN (${convertToAnnouncementCommunities}) AND type != ${threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT} `; const [convertToAnnouncementCommunity] = await dbQuery( announcementCommunityQuery, ); const botViewer = createScriptViewer(bots.commbot.userID); await convertThreads( botViewer, convertToAnnouncementCommunity, threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT, ); } async function convertAnnouncementSubthreads() { const announcementSubthreadQuery = SQL` SELECT id, name FROM threads WHERE id IN (${convertToAnnouncementSubthreads}) AND type != ${threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD} `; const [convertToAnnouncementSubthread] = await dbQuery( announcementSubthreadQuery, ); const botViewer = createScriptViewer(bots.commbot.userID); await convertThreads( botViewer, convertToAnnouncementSubthread, threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD, ); } async function fixThreadsWithMissingParent() { const threadsWithMissingParentQuery = SQL` SELECT id, name FROM threads WHERE id IN (${threadsWithMissingParent}) AND type != ${threadTypes.COMMUNITY_SECRET_SUBTHREAD} `; const [threadsWithMissingParentResult] = await dbQuery( threadsWithMissingParentQuery, ); const botViewer = createScriptViewer(bots.commbot.userID); while (threadsWithMissingParentResult.length > 0) { const batch = threadsWithMissingParentResult.splice(0, batchSize); await Promise.all( batch.map(async thread => { console.log(`fixing ${JSON.stringify(thread)} with missing parent`); return await updateThread( botViewer, { threadID: thread.id.toString(), changes: { parentThreadID: null, type: threadTypes.COMMUNITY_SECRET_SUBTHREAD, }, }, updateThreadOptions, ); }), ); } } async function fixPersonalThreadsWithMissingMembers() { const missingMembersQuery = SQL` SELECT thread, user FROM memberships WHERE thread IN (${personalThreadsWithMissingMembers}) AND role <= 0 `; const [missingMembers] = await dbQuery(missingMembersQuery); const botViewer = createScriptViewer(bots.commbot.userID); for (const row of missingMembers) { console.log(`fixing ${JSON.stringify(row)} with missing member`); await updateThread( botViewer, { threadID: row.thread.toString(), changes: { newMemberIDs: [row.user.toString()], }, }, updateThreadOptions, ); } } async function moveThreadsToGenesis() { const noParentQuery = SQL` SELECT id, name FROM threads WHERE type != ${threadTypes.COMMUNITY_ROOT} AND type != ${threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT} AND type != ${threadTypes.GENESIS} AND parent_thread_id IS NULL `; const [noParentThreads] = await dbQuery(noParentQuery); const botViewer = createScriptViewer(bots.commbot.userID); while (noParentThreads.length > 0) { const batch = noParentThreads.splice(0, batchSize); await Promise.all( batch.map(async thread => { console.log(`processing ${JSON.stringify(thread)}`); return await updateThread( botViewer, { threadID: thread.id.toString(), changes: { parentThreadID: genesis.id, }, }, updateThreadOptions, ); }), ); } const childQuery = SQL` SELECT id, name FROM threads WHERE type != ${threadTypes.COMMUNITY_ROOT} AND type != ${threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT} AND type != ${threadTypes.GENESIS} AND parent_thread_id IS NOT NULL AND parent_thread_id != ${genesis.id} `; const [childThreads] = await dbQuery(childQuery); for (const childThread of childThreads) { // We go one by one because the changes in a parent thread can affect a // child thread. If the child thread update starts at the same time as an // update for its parent thread, a race can cause incorrect results for the // child thread (in particular for the permissions on the memberships table) console.log(`processing ${JSON.stringify(childThread)}`); await updateThread( botViewer, { threadID: childThread.id.toString(), changes: {}, }, updateThreadOptions, ); } } async function clearMembershipPermissions() { const membershipPermissionQuery = SQL` SELECT DISTINCT thread FROM memberships WHERE JSON_EXTRACT(permissions, '$.membership') IS NOT NULL `; const [membershipPermissionResult] = await dbQuery(membershipPermissionQuery); if (membershipPermissionResult.length === 0) { return; } const botViewer = createScriptViewer(bots.commbot.userID); for (const row of membershipPermissionResult) { const threadID = row.thread.toString(); console.log(`clearing membership permissions for ${threadID}`); const changeset = await recalculateThreadPermissions(threadID); await commitMembershipChangeset(botViewer, changeset); } console.log('clearing -1 rows...'); const emptyMembershipDeletionQuery = SQL` DELETE FROM memberships WHERE role = -1 AND permissions IS NULL `; await dbQuery(emptyMembershipDeletionQuery); await createMembershipsForFormerMembers(); } async function createMembershipsForFormerMembers() { const [result] = await dbQuery(SQL` SELECT DISTINCT thread, user FROM messages m WHERE NOT EXISTS ( SELECT thread, user FROM memberships mm WHERE m.thread = mm.thread AND m.user = mm.user ) `); const rowsToSave = []; for (const row of result) { rowsToSave.push({ operation: 'save', userID: row.user.toString(), threadID: row.thread.toString(), userNeedsFullThreadDetails: false, intent: 'none', permissions: null, permissionsForChildren: null, role: '-1', oldRole: '-1', }); } await saveMemberships(rowsToSave); } main([ createGenesisCommunity, convertExistingCommunities, convertUnadminnedCommunities, convertAnnouncementCommunities, convertAnnouncementSubthreads, fixThreadsWithMissingParent, fixPersonalThreadsWithMissingMembers, moveThreadsToGenesis, clearMembershipPermissions, ]); diff --git a/keyserver/src/socket/session-utils.js b/keyserver/src/socket/session-utils.js index 52061d2f5..78dff919f 100644 --- a/keyserver/src/socket/session-utils.js +++ b/keyserver/src/socket/session-utils.js @@ -1,601 +1,601 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import type { TUnion, TInterface } from 'tcomb'; import { usersInRawEntryInfos, serverEntryInfo, serverEntryInfosObject, } from 'lib/shared/entry-utils.js'; import { usersInThreadInfo } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { UpdateActivityResult } from 'lib/types/activity-types.js'; import type { IdentityKeysBlob } from 'lib/types/crypto-types.js'; import { isDeviceType } from 'lib/types/device-types.js'; import type { CalendarQuery, DeltaEntryInfosResponse, } from 'lib/types/entry-types.js'; import { reportTypes, type ThreadInconsistencyReportCreationRequest, type EntryInconsistencyReportCreationRequest, } from 'lib/types/report-types.js'; import { serverRequestTypes, type ThreadInconsistencyClientResponse, type EntryInconsistencyClientResponse, type ClientResponse, type ServerServerRequest, type ServerCheckStateServerRequest, } from 'lib/types/request-types.js'; import { sessionCheckFrequency } from 'lib/types/session-types.js'; import { signedIdentityKeysBlobValidator } from 'lib/utils/crypto-utils.js'; import { hash } from 'lib/utils/objects.js'; import { promiseAll } from 'lib/utils/promises.js'; import { tShape, tPlatform, tPlatformDetails, } from 'lib/utils/validation-utils.js'; import { saveOneTimeKeys } from '../creators/one-time-keys-creator.js'; import createReport from '../creators/report-creator.js'; import { SQL } from '../database/database.js'; import { fetchEntryInfos, fetchEntryInfosByID, fetchEntriesForSession, } from '../fetchers/entry-fetchers.js'; import { checkIfSessionHasEnoughOneTimeKeys } from '../fetchers/key-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchCurrentUserInfo, fetchUserInfos, fetchKnownUserInfos, } from '../fetchers/user-fetchers.js'; import { activityUpdatesInputValidator } from '../responders/activity-responders.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import { threadInconsistencyReportValidatorShape, entryInconsistencyReportValidatorShape, } from '../responders/report-responders.js'; import { setNewSession, setCookiePlatform, setCookiePlatformDetails, setCookieSignedIdentityKeysBlob, } from '../session/cookies.js'; import type { Viewer } from '../session/viewer.js'; import { activityUpdater } from '../updaters/activity-updaters.js'; import { compareNewCalendarQuery } from '../updaters/entry-updaters.js'; import type { SessionUpdate } from '../updaters/session-updaters.js'; import { getOlmUtility } from '../utils/olm-utils.js'; const clientResponseInputValidator: TUnion = t.union([ tShape({ type: t.irreducible( 'serverRequestTypes.PLATFORM', x => x === serverRequestTypes.PLATFORM, ), platform: tPlatform, }), tShape({ ...threadInconsistencyReportValidatorShape, type: t.irreducible( 'serverRequestTypes.THREAD_INCONSISTENCY', x => x === serverRequestTypes.THREAD_INCONSISTENCY, ), }), tShape({ ...entryInconsistencyReportValidatorShape, type: t.irreducible( 'serverRequestTypes.ENTRY_INCONSISTENCY', x => x === serverRequestTypes.ENTRY_INCONSISTENCY, ), }), tShape({ type: t.irreducible( 'serverRequestTypes.PLATFORM_DETAILS', x => x === serverRequestTypes.PLATFORM_DETAILS, ), platformDetails: tPlatformDetails, }), tShape({ type: t.irreducible( 'serverRequestTypes.CHECK_STATE', x => x === serverRequestTypes.CHECK_STATE, ), hashResults: t.dict(t.String, t.Boolean), }), tShape({ type: t.irreducible( 'serverRequestTypes.INITIAL_ACTIVITY_UPDATES', x => x === serverRequestTypes.INITIAL_ACTIVITY_UPDATES, ), activityUpdates: activityUpdatesInputValidator, }), tShape({ type: t.irreducible( 'serverRequestTypes.MORE_ONE_TIME_KEYS', x => x === serverRequestTypes.MORE_ONE_TIME_KEYS, ), keys: t.list(t.String), }), tShape({ type: t.irreducible( 'serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB', x => x === serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB, ), signedIdentityKeysBlob: signedIdentityKeysBlobValidator, }), ]); type StateCheckStatus = | { status: 'state_validated' } | { status: 'state_invalid', invalidKeys: $ReadOnlyArray } | { status: 'state_check' }; type ProcessClientResponsesResult = { serverRequests: ServerServerRequest[], stateCheckStatus: ?StateCheckStatus, activityUpdateResult: ?UpdateActivityResult, }; async function processClientResponses( viewer: Viewer, clientResponses: $ReadOnlyArray, ): Promise { let viewerMissingPlatform = !viewer.platform; const { platformDetails } = viewer; let viewerMissingPlatformDetails = !platformDetails || (isDeviceType(viewer.platform) && (platformDetails.codeVersion === null || platformDetails.codeVersion === undefined || platformDetails.stateVersion === null || platformDetails.stateVersion === undefined)); const promises = []; let activityUpdates = []; let stateCheckStatus = null; const clientSentPlatformDetails = clientResponses.some( response => response.type === serverRequestTypes.PLATFORM_DETAILS, ); for (const clientResponse of clientResponses) { if ( clientResponse.type === serverRequestTypes.PLATFORM && !clientSentPlatformDetails ) { promises.push(setCookiePlatform(viewer, clientResponse.platform)); viewerMissingPlatform = false; if (!isDeviceType(clientResponse.platform)) { viewerMissingPlatformDetails = false; } } else if ( clientResponse.type === serverRequestTypes.THREAD_INCONSISTENCY ) { promises.push(recordThreadInconsistency(viewer, clientResponse)); } else if (clientResponse.type === serverRequestTypes.ENTRY_INCONSISTENCY) { promises.push(recordEntryInconsistency(viewer, clientResponse)); } else if (clientResponse.type === serverRequestTypes.PLATFORM_DETAILS) { promises.push( setCookiePlatformDetails(viewer, clientResponse.platformDetails), ); viewerMissingPlatform = false; viewerMissingPlatformDetails = false; } else if ( clientResponse.type === serverRequestTypes.INITIAL_ACTIVITY_UPDATES ) { activityUpdates = [...activityUpdates, ...clientResponse.activityUpdates]; } else if (clientResponse.type === serverRequestTypes.CHECK_STATE) { const invalidKeys = []; for (const key in clientResponse.hashResults) { const result = clientResponse.hashResults[key]; if (!result) { invalidKeys.push(key); } } stateCheckStatus = invalidKeys.length > 0 ? { status: 'state_invalid', invalidKeys } : { status: 'state_validated' }; } else if (clientResponse.type === serverRequestTypes.MORE_ONE_TIME_KEYS) { invariant(clientResponse.keys, 'keys expected in client response'); handleAsyncPromise(saveOneTimeKeys(viewer, clientResponse.keys)); } else if ( clientResponse.type === serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB ) { invariant( clientResponse.signedIdentityKeysBlob, 'signedIdentityKeysBlob expected in client response', ); const { signedIdentityKeysBlob } = clientResponse; const identityKeys: IdentityKeysBlob = JSON.parse( signedIdentityKeysBlob.payload, ); const olmUtil = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); handleAsyncPromise( setCookieSignedIdentityKeysBlob( viewer.cookieID, signedIdentityKeysBlob, ), ); } catch (e) { continue; } } } const activityUpdatePromise = (async () => { if (activityUpdates.length === 0) { - return; + return undefined; } return await activityUpdater(viewer, { updates: activityUpdates }); })(); const serverRequests = []; const checkOneTimeKeysPromise = (async () => { if (!viewer.loggedIn) { return; } const enoughOneTimeKeys = await checkIfSessionHasEnoughOneTimeKeys( viewer.session, ); if (!enoughOneTimeKeys) { serverRequests.push({ type: serverRequestTypes.MORE_ONE_TIME_KEYS }); } })(); const { activityUpdateResult } = await promiseAll({ all: Promise.all(promises), activityUpdateResult: activityUpdatePromise, checkOneTimeKeysPromise, }); if ( !stateCheckStatus && viewer.loggedIn && viewer.sessionLastValidated + sessionCheckFrequency < Date.now() ) { stateCheckStatus = { status: 'state_check' }; } if (viewerMissingPlatform) { serverRequests.push({ type: serverRequestTypes.PLATFORM }); } if (viewerMissingPlatformDetails) { serverRequests.push({ type: serverRequestTypes.PLATFORM_DETAILS }); } return { serverRequests, stateCheckStatus, activityUpdateResult }; } async function recordThreadInconsistency( viewer: Viewer, response: ThreadInconsistencyClientResponse, ): Promise { const { type, ...rest } = response; const reportCreationRequest = ({ ...rest, type: reportTypes.THREAD_INCONSISTENCY, }: ThreadInconsistencyReportCreationRequest); await createReport(viewer, reportCreationRequest); } async function recordEntryInconsistency( viewer: Viewer, response: EntryInconsistencyClientResponse, ): Promise { const { type, ...rest } = response; const reportCreationRequest = ({ ...rest, type: reportTypes.ENTRY_INCONSISTENCY, }: EntryInconsistencyReportCreationRequest); await createReport(viewer, reportCreationRequest); } type SessionInitializationResult = | { sessionContinued: false } | { sessionContinued: true, deltaEntryInfoResult: DeltaEntryInfosResponse, sessionUpdate: SessionUpdate, }; async function initializeSession( viewer: Viewer, calendarQuery: CalendarQuery, oldLastUpdate: number, ): Promise { if (!viewer.loggedIn) { return { sessionContinued: false }; } if (!viewer.hasSessionInfo) { // If the viewer has no session info but is logged in, that is indicative // of an expired / invalidated session and we should generate a new one await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } if (oldLastUpdate < viewer.sessionLastUpdated) { // If the client has an older last_update than the server is tracking for // that client, then the client either had some issue persisting its store, // or the user restored the client app from a backup. Either way, we should // invalidate the existing session, since the server has assumed that the // checkpoint is further along than it is on the client, and might not still // have all of the updates necessary to do an incremental update await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } let comparisonResult = null; try { comparisonResult = compareNewCalendarQuery(viewer, calendarQuery); } catch (e) { if (e.message !== 'unknown_error') { throw e; } } if (comparisonResult) { const { difference, oldCalendarQuery } = comparisonResult; const sessionUpdate = { ...comparisonResult.sessionUpdate, lastUpdate: oldLastUpdate, }; const deltaEntryInfoResult = await fetchEntriesForSession( viewer, difference, oldCalendarQuery, ); return { sessionContinued: true, deltaEntryInfoResult, sessionUpdate }; } else { await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } } type StateCheckResult = { sessionUpdate?: SessionUpdate, checkStateRequest?: ServerCheckStateServerRequest, }; async function checkState( viewer: Viewer, status: StateCheckStatus, calendarQuery: CalendarQuery, ): Promise { const shouldCheckUserInfos = hasMinCodeVersion(viewer.platformDetails, 59); if (status.status === 'state_validated') { return { sessionUpdate: { lastValidated: Date.now() } }; } else if (status.status === 'state_check') { const promises = { threadsResult: fetchThreadInfos(viewer), entriesResult: fetchEntryInfos(viewer, [calendarQuery]), currentUserInfo: fetchCurrentUserInfo(viewer), userInfosResult: undefined, }; if (shouldCheckUserInfos) { promises.userInfosResult = fetchKnownUserInfos(viewer); } const fetchedData = await promiseAll(promises); let hashesToCheck = { threadInfos: hash(fetchedData.threadsResult.threadInfos), entryInfos: hash( serverEntryInfosObject(fetchedData.entriesResult.rawEntryInfos), ), currentUserInfo: hash(fetchedData.currentUserInfo), }; if (shouldCheckUserInfos) { hashesToCheck = { ...hashesToCheck, userInfos: hash(fetchedData.userInfosResult), }; } const checkStateRequest = { type: serverRequestTypes.CHECK_STATE, hashesToCheck, }; return { checkStateRequest }; } const { invalidKeys } = status; let fetchAllThreads = false, fetchAllEntries = false, fetchAllUserInfos = false, fetchUserInfo = false; const threadIDsToFetch = [], entryIDsToFetch = [], userIDsToFetch = []; for (const key of invalidKeys) { if (key === 'threadInfos') { fetchAllThreads = true; } else if (key === 'entryInfos') { fetchAllEntries = true; } else if (key === 'userInfos') { fetchAllUserInfos = true; } else if (key === 'currentUserInfo') { fetchUserInfo = true; } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); threadIDsToFetch.push(threadID); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); entryIDsToFetch.push(entryID); } else if (key.startsWith('userInfo|')) { const [, userID] = key.split('|'); userIDsToFetch.push(userID); } } const fetchPromises = {}; if (fetchAllThreads) { fetchPromises.threadsResult = fetchThreadInfos(viewer); } else if (threadIDsToFetch.length > 0) { fetchPromises.threadsResult = fetchThreadInfos( viewer, SQL`t.id IN (${threadIDsToFetch})`, ); } if (fetchAllEntries) { fetchPromises.entriesResult = fetchEntryInfos(viewer, [calendarQuery]); } else if (entryIDsToFetch.length > 0) { fetchPromises.entryInfos = fetchEntryInfosByID(viewer, entryIDsToFetch); } if (fetchAllUserInfos) { fetchPromises.userInfos = fetchKnownUserInfos(viewer); } else if (userIDsToFetch.length > 0) { fetchPromises.userInfos = fetchKnownUserInfos(viewer, userIDsToFetch); } if (fetchUserInfo) { fetchPromises.currentUserInfo = fetchCurrentUserInfo(viewer); } const fetchedData = await promiseAll(fetchPromises); const hashesToCheck = {}, failUnmentioned = {}, stateChanges = {}; for (const key of invalidKeys) { if (key === 'threadInfos') { // Instead of returning all threadInfos, we want to narrow down and figure // out which threadInfos don't match first const { threadInfos } = fetchedData.threadsResult; for (const threadID in threadInfos) { hashesToCheck[`threadInfo|${threadID}`] = hash(threadInfos[threadID]); } failUnmentioned.threadInfos = true; } else if (key === 'entryInfos') { // Instead of returning all entryInfos, we want to narrow down and figure // out which entryInfos don't match first const { rawEntryInfos } = fetchedData.entriesResult; for (const rawEntryInfo of rawEntryInfos) { const entryInfo = serverEntryInfo(rawEntryInfo); invariant(entryInfo, 'should be set'); const { id: entryID } = entryInfo; invariant(entryID, 'should be set'); hashesToCheck[`entryInfo|${entryID}`] = hash(entryInfo); } failUnmentioned.entryInfos = true; } else if (key === 'userInfos') { // Instead of returning all userInfos, we want to narrow down and figure // out which userInfos don't match first const { userInfos } = fetchedData; for (const userID in userInfos) { hashesToCheck[`userInfo|${userID}`] = hash(userInfos[userID]); } failUnmentioned.userInfos = true; } else if (key === 'currentUserInfo') { stateChanges.currentUserInfo = fetchedData.currentUserInfo; } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); const { threadInfos } = fetchedData.threadsResult; const threadInfo = threadInfos[threadID]; if (!threadInfo) { if (!stateChanges.deleteThreadIDs) { stateChanges.deleteThreadIDs = []; } stateChanges.deleteThreadIDs.push(threadID); continue; } if (!stateChanges.rawThreadInfos) { stateChanges.rawThreadInfos = []; } stateChanges.rawThreadInfos.push(threadInfo); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); const rawEntryInfos = fetchedData.entriesResult ? fetchedData.entriesResult.rawEntryInfos : fetchedData.entryInfos; const entryInfo = rawEntryInfos.find( candidate => candidate.id === entryID, ); if (!entryInfo) { if (!stateChanges.deleteEntryIDs) { stateChanges.deleteEntryIDs = []; } stateChanges.deleteEntryIDs.push(entryID); continue; } if (!stateChanges.rawEntryInfos) { stateChanges.rawEntryInfos = []; } stateChanges.rawEntryInfos.push(entryInfo); } else if (key.startsWith('userInfo|')) { const { userInfos: fetchedUserInfos } = fetchedData; const [, userID] = key.split('|'); const userInfo = fetchedUserInfos[userID]; if (!userInfo || !userInfo.username) { if (!stateChanges.deleteUserInfoIDs) { stateChanges.deleteUserInfoIDs = []; } stateChanges.deleteUserInfoIDs.push(userID); } else { if (!stateChanges.userInfos) { stateChanges.userInfos = []; } stateChanges.userInfos.push({ ...userInfo, // Flow gets confused if we don't do this username: userInfo.username, }); } } } if (!shouldCheckUserInfos) { const userIDs = new Set(); if (stateChanges.rawThreadInfos) { for (const threadInfo of stateChanges.rawThreadInfos) { for (const userID of usersInThreadInfo(threadInfo)) { userIDs.add(userID); } } } if (stateChanges.rawEntryInfos) { for (const userID of usersInRawEntryInfos(stateChanges.rawEntryInfos)) { userIDs.add(userID); } } const userInfos = []; if (userIDs.size > 0) { const fetchedUserInfos = await fetchUserInfos([...userIDs]); for (const userID in fetchedUserInfos) { const userInfo = fetchedUserInfos[userID]; if (userInfo && userInfo.username) { const { id, username } = userInfo; userInfos.push({ id, username }); } } } if (userInfos.length > 0) { stateChanges.userInfos = userInfos; } } const checkStateRequest = { type: serverRequestTypes.CHECK_STATE, hashesToCheck, failUnmentioned, stateChanges, }; if (Object.keys(hashesToCheck).length === 0) { return { checkStateRequest, sessionUpdate: { lastValidated: Date.now() } }; } else { return { checkStateRequest }; } } export { clientResponseInputValidator, processClientResponses, initializeSession, checkState, }; diff --git a/keyserver/src/updaters/activity-updaters.js b/keyserver/src/updaters/activity-updaters.js index b614b6a0e..d360a69f0 100644 --- a/keyserver/src/updaters/activity-updaters.js +++ b/keyserver/src/updaters/activity-updaters.js @@ -1,513 +1,513 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import _max from 'lodash/fp/max.js'; import { localIDPrefix } from 'lib/shared/message-utils.js'; import type { UpdateActivityResult, UpdateActivityRequest, SetThreadUnreadStatusRequest, SetThreadUnreadStatusResult, } from 'lib/types/activity-types.js'; import { messageTypes } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL, mergeOrConditions } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import { deleteActivityForViewerSession } from '../deleters/activity-deleters.js'; import { checkThread, getValidThreads, } from '../fetchers/thread-permission-fetchers.js'; import { rescindPushNotifs } from '../push/rescind.js'; import { updateBadgeCount } from '../push/send.js'; import type { Viewer } from '../session/viewer.js'; import { earliestFocusedTimeConsideredExpired } from '../shared/focused-times.js'; type PartialThreadStatus = { +focusActive: boolean, +threadID: string, +newLastReadMessage: ?number, }; type ThreadStatus = | { +focusActive: true, +threadID: string, +newLastReadMessage: number, +curLastReadMessage: number, +rescindCondition: SQLStatementType, } | { +focusActive: false, +threadID: string, +newLastReadMessage: ?number, +curLastReadMessage: number, +rescindCondition: ?SQLStatementType, +newerMessageFromOtherAuthor: boolean, }; async function activityUpdater( viewer: Viewer, request: UpdateActivityRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const focusUpdatesByThreadID = new Map(); for (const activityUpdate of request.updates) { const threadID = activityUpdate.threadID; const updatesForThreadID = focusUpdatesByThreadID.get(threadID) ?? []; if (!focusUpdatesByThreadID.has(threadID)) { focusUpdatesByThreadID.set(threadID, updatesForThreadID); } updatesForThreadID.push(activityUpdate); } const unverifiedThreadIDs: $ReadOnlySet = new Set( request.updates.map(update => update.threadID), ); const verifiedThreadsData = await getValidThreads( viewer, [...unverifiedThreadIDs], [ { check: 'permission', permission: threadPermissions.VISIBLE, }, ], ); if (verifiedThreadsData.length === 0) { return { unfocusedToUnread: [] }; } const memberThreadIDs = new Set(); const verifiedThreadIDs = []; for (const threadData of verifiedThreadsData) { if (threadData.role > 0) { memberThreadIDs.add(threadData.threadID); } verifiedThreadIDs.push(threadData.threadID); } const partialThreadStatuses: PartialThreadStatus[] = []; for (const threadID of verifiedThreadIDs) { const focusUpdates = focusUpdatesByThreadID.get(threadID); invariant(focusUpdates, `no focusUpdate for thread ID ${threadID}`); const focusActive = !focusUpdates.some(update => !update.focus); const newLastReadMessage = _max( focusUpdates .filter( update => update.latestMessage && !update.latestMessage.startsWith(localIDPrefix), ) .map(update => parseInt(update.latestMessage)), ); partialThreadStatuses.push({ threadID, focusActive, newLastReadMessage, }); } // We update the focused rows before we check for new messages so we can // guarantee that any messages that may have set the thread to unread before // we set it to focused are caught and overriden await updateFocusedRows(viewer, partialThreadStatuses); if (memberThreadIDs.size === 0) { return { unfocusedToUnread: [] }; } const memberPartialThreadStatuses = partialThreadStatuses.filter( partialStatus => memberThreadIDs.has(partialStatus.threadID), ); const unfocusedLatestMessages = new Map(); for (const partialThreadStatus of memberPartialThreadStatuses) { const { threadID, focusActive, newLastReadMessage } = partialThreadStatus; if (!focusActive) { unfocusedLatestMessages.set(threadID, newLastReadMessage ?? 0); } } const [unfocusedThreadsWithNewerMessages, lastMessageInfos] = await Promise.all([ checkForNewerMessages(viewer, unfocusedLatestMessages), fetchLastMessageInfo(viewer, [...memberThreadIDs]), ]); const threadStatuses: ThreadStatus[] = []; for (const partialThreadStatus of memberPartialThreadStatuses) { const { threadID, focusActive, newLastReadMessage } = partialThreadStatus; const lastMessageInfo = lastMessageInfos.get(threadID); invariant( lastMessageInfo !== undefined, `no lastMessageInfo for thread ID ${threadID}`, ); const { lastMessage, lastReadMessage: curLastReadMessage } = lastMessageInfo; if (focusActive) { threadStatuses.push({ focusActive: true, threadID, newLastReadMessage: newLastReadMessage ? Math.max(lastMessage, newLastReadMessage) : lastMessage, curLastReadMessage, rescindCondition: SQL`n.thread = ${threadID}`, }); } else { threadStatuses.push({ focusActive: false, threadID, newLastReadMessage, curLastReadMessage, rescindCondition: newLastReadMessage ? SQL`(n.thread = ${threadID} AND n.message <= ${newLastReadMessage})` : null, newerMessageFromOtherAuthor: unfocusedThreadsWithNewerMessages.has(threadID), }); } } // The following block determines whether to enqueue updates for a given // (user, thread) pair and whether to propagate badge count notifs to all of // that user's devices const setUnread: Array<{ +threadID: string, +unread: boolean }> = []; for (const threadStatus of threadStatuses) { const { threadID, curLastReadMessage } = threadStatus; if (!threadStatus.focusActive) { const { newLastReadMessage, newerMessageFromOtherAuthor } = threadStatus; if (newerMessageFromOtherAuthor) { setUnread.push({ threadID, unread: true }); } else if (!newLastReadMessage) { // This is a rare edge case. It should only be possible for threads that // have zero messages on both the client and server, which shouldn't // happen. In this case we'll set the thread to read, just in case... console.warn(`thread ID ${threadID} appears to have no messages`); setUnread.push({ threadID, unread: false }); } else if (newLastReadMessage > curLastReadMessage) { setUnread.push({ threadID, unread: false }); } } else { const { newLastReadMessage } = threadStatus; if (newLastReadMessage > curLastReadMessage) { setUnread.push({ threadID, unread: false }); } } } const time = Date.now(); const updateDatas = setUnread.map(({ threadID, unread }) => ({ type: updateTypes.UPDATE_THREAD_READ_STATUS, userID: viewer.userID, time, threadID, unread, })); const latestMessages = new Map(); for (const threadStatus of threadStatuses) { const { threadID, newLastReadMessage, curLastReadMessage } = threadStatus; if (newLastReadMessage && newLastReadMessage > curLastReadMessage) { latestMessages.set(threadID, newLastReadMessage); } } await Promise.all([ updateLastReadMessage(viewer, latestMessages), createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'ignore' }), ]); // We do this afterwards so the badge count is correct const rescindConditions = threadStatuses .map(({ rescindCondition }) => rescindCondition) .filter(Boolean); let rescindCondition; if (rescindConditions.length > 0) { rescindCondition = SQL`n.user = ${viewer.userID} AND `; rescindCondition.append(mergeOrConditions(rescindConditions)); } await rescindAndUpdateBadgeCounts( viewer, rescindCondition, updateDatas.length > 0 ? 'activity_update' : null, ); return { unfocusedToUnread: [...unfocusedThreadsWithNewerMessages] }; } async function updateFocusedRows( viewer: Viewer, partialThreadStatuses: $ReadOnlyArray, ): Promise { const threadIDs = partialThreadStatuses .filter(threadStatus => threadStatus.focusActive) .map(({ threadID }) => threadID); const time = Date.now(); if (threadIDs.length > 0) { const focusedInsertRows = threadIDs.map(threadID => [ viewer.userID, viewer.session, threadID, time, ]); const query = SQL` INSERT INTO focused (user, session, thread, time) VALUES ${focusedInsertRows} ON DUPLICATE KEY UPDATE time = VALUE(time) `; await dbQuery(query); } if (viewer.hasSessionInfo) { await deleteActivityForViewerSession(viewer, time); } } // To protect against a possible race condition, we reset the thread to unread // if the latest message ID on the client at the time that focus was dropped // is no longer the latest message ID. // Returns the set of unfocused threads that should be set to unread on // the client because a new message arrived since they were unfocused. async function checkForNewerMessages( viewer: Viewer, latestMessages: Map, ): Promise> { if (latestMessages.size === 0 || !viewer.loggedIn) { return new Set(); } const unfocusedThreadIDs = [...latestMessages.keys()]; const focusedElsewhereThreadIDs = await checkThreadsFocused( viewer, unfocusedThreadIDs, ); const unreadCandidates = _difference(unfocusedThreadIDs)( focusedElsewhereThreadIDs, ); if (unreadCandidates.length === 0) { return new Set(); } const knowOfExtractString = `$.${threadPermissions.KNOW_OF}.value`; const query = SQL` SELECT m.thread, MAX(m.id) AS latest_message FROM messages m LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewer.userID} WHERE m.thread IN (${unreadCandidates}) AND m.user != ${viewer.userID} AND ( m.type != ${messageTypes.CREATE_SUB_THREAD} OR JSON_EXTRACT(stm.permissions, ${knowOfExtractString}) IS TRUE ) GROUP BY m.thread `; const [result] = await dbQuery(query); const threadsWithNewerMessages = new Set(); for (const row of result) { const threadID = row.thread.toString(); const serverLatestMessage = row.latest_message; const clientLatestMessage = latestMessages.get(threadID); if (clientLatestMessage < serverLatestMessage) { threadsWithNewerMessages.add(threadID); } } return threadsWithNewerMessages; } async function checkThreadsFocused( viewer: Viewer, threadIDs: $ReadOnlyArray, ): Promise { const time = earliestFocusedTimeConsideredExpired(); const query = SQL` SELECT thread FROM focused WHERE time > ${time} AND user = ${viewer.userID} AND thread IN (${threadIDs}) GROUP BY thread `; const [result] = await dbQuery(query); const focusedThreadIDs = []; for (const row of result) { focusedThreadIDs.push(row.thread.toString()); } return focusedThreadIDs; } async function updateLastReadMessage( viewer: Viewer, lastReadMessages: $ReadOnlyMap, ) { if (lastReadMessages.size === 0) { return; } const query = SQL` UPDATE memberships SET last_read_message = GREATEST(last_read_message, CASE `; lastReadMessages.forEach((lastMessage, threadID) => { query.append(SQL` WHEN thread = ${threadID} THEN ${lastMessage} `); }); query.append(SQL` ELSE last_read_message END) WHERE thread IN (${[...lastReadMessages.keys()]}) AND user = ${viewer.userID} `); - return await dbQuery(query); + await dbQuery(query); } async function setThreadUnreadStatus( viewer: Viewer, request: SetThreadUnreadStatusRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const isMemberAndCanViewThread = await checkThread(viewer, request.threadID, [ { check: 'is_member', }, { check: 'permission', permission: threadPermissions.VISIBLE, }, ]); if (!isMemberAndCanViewThread) { throw new ServerError('invalid_parameters'); } const resetThreadToUnread = await shouldResetThreadToUnread(viewer, request); if (!resetThreadToUnread) { const lastReadMessage = request.unread ? SQL`0` : SQL`GREATEST(m.last_read_message, ${request.latestMessage ?? 0})`; const update = SQL` UPDATE memberships m SET m.last_read_message = `; update.append(lastReadMessage); update.append(SQL` WHERE m.thread = ${request.threadID} AND m.user = ${viewer.userID} `); const queryPromise = dbQuery(update); const time = Date.now(); const updatesPromise = createUpdates( [ { type: updateTypes.UPDATE_THREAD_READ_STATUS, userID: viewer.userID, time: time, threadID: request.threadID, unread: request.unread, }, ], { viewer, updatesForCurrentSession: 'ignore' }, ); await Promise.all([updatesPromise, queryPromise]); } let rescindCondition; if (!request.unread) { rescindCondition = SQL` n.user = ${viewer.userID} AND n.thread = ${request.threadID} AND n.message <= ${request.latestMessage} `; } await rescindAndUpdateBadgeCounts( viewer, rescindCondition, request.unread ? 'mark_as_unread' : 'mark_as_read', ); return { resetToUnread: resetThreadToUnread, }; } async function rescindAndUpdateBadgeCounts( viewer: Viewer, rescindCondition: ?SQLStatementType, badgeCountUpdateSource: ?( | 'activity_update' | 'mark_as_unread' | 'mark_as_read' ), ) { const notificationPromises = []; if (rescindCondition) { notificationPromises.push(rescindPushNotifs(rescindCondition)); } if (badgeCountUpdateSource) { notificationPromises.push(updateBadgeCount(viewer, badgeCountUpdateSource)); } await Promise.all(notificationPromises); } async function shouldResetThreadToUnread( viewer: Viewer, request: SetThreadUnreadStatusRequest, ): Promise { if (request.unread) { return false; } const threadsWithNewerMessages = await checkForNewerMessages( viewer, new Map([[request.threadID, parseInt(request.latestMessage) || 0]]), ); return threadsWithNewerMessages.has(request.threadID); } type LastMessageInfo = { +lastMessage: number, +lastReadMessage: number, }; async function fetchLastMessageInfo( viewer: Viewer, threadIDs: $ReadOnlyArray, ) { const query = SQL` SELECT thread, last_message, last_read_message FROM memberships WHERE user = ${viewer.userID} AND thread IN (${threadIDs}) `; const [result] = await dbQuery(query); const lastMessages = new Map(); for (const row of result) { const threadID = row.thread.toString(); const lastMessage = row.last_message; const lastReadMessage = row.last_read_message; lastMessages.set(threadID, { lastMessage, lastReadMessage }); } return lastMessages; } export { activityUpdater, setThreadUnreadStatus }; diff --git a/keyserver/src/utils/urls.js b/keyserver/src/utils/urls.js index be6744195..d87e8d4e7 100644 --- a/keyserver/src/utils/urls.js +++ b/keyserver/src/utils/urls.js @@ -1,93 +1,95 @@ // @flow import invariant from 'invariant'; import { getCommConfig } from 'lib/utils/comm-config.js'; import { values } from 'lib/utils/objects.js'; export type AppURLFacts = { +baseDomain: string, +basePath: string, +https: boolean, +baseRoutePath: string, +proxy?: 'apache' | 'none', // defaults to apache }; const validProxies = new Set(['apache', 'none']); const sitesObj = Object.freeze({ a: 'landing', b: 'commapp', c: 'squadcal', }); export type Site = $Values; const sites: $ReadOnlyArray = values(sitesObj); const cachedURLFacts = new Map(); async function fetchURLFacts(site: Site): Promise { const existing = cachedURLFacts.get(site); if (existing !== undefined) { return existing; } let urlFacts: ?AppURLFacts = await getCommConfig({ folder: 'facts', name: `${site}_url`, }); if (urlFacts) { const { proxy } = urlFacts; urlFacts = { ...urlFacts, proxy: validProxies.has(proxy) ? proxy : 'apache', }; } cachedURLFacts.set(site, urlFacts); return urlFacts; } async function prefetchAllURLFacts() { await Promise.all(sites.map(fetchURLFacts)); } function getSquadCalURLFacts(): ?AppURLFacts { return cachedURLFacts.get('squadcal'); } function getCommAppURLFacts(): ?AppURLFacts { return cachedURLFacts.get('commapp'); } function getAndAssertCommAppURLFacts(): AppURLFacts { const urlFacts = getCommAppURLFacts(); invariant(urlFacts, 'keyserver/facts/commapp_url.json missing'); return urlFacts; } +// ESLint doesn't recognize that invariant always throws +// eslint-disable-next-line consistent-return function getAppURLFactsFromRequestURL(url: string): AppURLFacts { const commURLFacts = getCommAppURLFacts(); if (commURLFacts && url.startsWith(commURLFacts.baseRoutePath)) { return commURLFacts; } const squadCalURLFacts = getSquadCalURLFacts(); if (squadCalURLFacts) { return squadCalURLFacts; } invariant(false, 'request received but no URL facts are present'); } function getLandingURLFacts(): ?AppURLFacts { return cachedURLFacts.get('landing'); } function getAndAssertLandingURLFacts(): AppURLFacts { const urlFacts = getLandingURLFacts(); invariant(urlFacts, 'keyserver/facts/landing_url.json missing'); return urlFacts; } export { prefetchAllURLFacts, getSquadCalURLFacts, getCommAppURLFacts, getAndAssertCommAppURLFacts, getLandingURLFacts, getAndAssertLandingURLFacts, getAppURLFactsFromRequestURL, }; diff --git a/landing/investor-profile-modal.react.js b/landing/investor-profile-modal.react.js index 99181f4d6..bbdc83a65 100644 --- a/landing/investor-profile-modal.react.js +++ b/landing/investor-profile-modal.react.js @@ -1,48 +1,48 @@ // @flow import * as React from 'react'; import ModalOverlay from 'lib/components/modal-overlay.react.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { keyedInvestorData } from './investor-data.js'; import css from './investor-profile-modal.css'; import InvestorProfile from './investor-profile.react.js'; type Props = { +investorID: string, }; function InvestorProfileModal(props: Props): React.Node { const { investorID } = props; const { popModal } = useModalContext(); const selectedInvestor = React.useMemo(() => { const foundInvestor = keyedInvestorData[investorID]; if (!foundInvestor) { - return; + return undefined; } return ( ); }, [investorID, popModal]); return (
{selectedInvestor}
); } export default InvestorProfileModal; diff --git a/landing/keyservers.react.js b/landing/keyservers.react.js index 5373f374f..157aa8c43 100644 --- a/landing/keyservers.react.js +++ b/landing/keyservers.react.js @@ -1,147 +1,147 @@ // @flow import { create } from '@lottiefiles/lottie-interactivity'; import * as React from 'react'; import { useIsomorphicLayoutEffect } from 'lib/hooks/isomorphic-layout-effect.react.js'; import { assetsCacheURLPrefix } from './asset-meta-data.js'; import css from './keyservers.css'; import ReadDocsButton from './read-docs-btn.react.js'; import StarBackground from './star-background.react.js'; function Keyservers(): React.Node { React.useEffect(() => { import('@lottiefiles/lottie-player'); }, []); const onEyeIllustrationLoad = React.useCallback(() => { create({ mode: 'scroll', player: '#eye-illustration', actions: [ { visibility: [0, 1], type: 'seek', frames: [0, 720], }, ], }); }, []); const onCloudIllustrationLoad = React.useCallback(() => { create({ mode: 'scroll', player: '#cloud-illustration', actions: [ { visibility: [0, 0.2], type: 'stop', frames: [0], }, { visibility: [0.2, 1], type: 'seek', frames: [0, 300], }, ], }); }, []); const [eyeNode, setEyeNode] = React.useState(null); useIsomorphicLayoutEffect(() => { if (!eyeNode) { - return; + return undefined; } eyeNode.addEventListener('load', onEyeIllustrationLoad); return () => eyeNode.removeEventListener('load', onEyeIllustrationLoad); }, [eyeNode, onEyeIllustrationLoad]); const [cloudNode, setCloudNode] = React.useState(null); useIsomorphicLayoutEffect(() => { if (!cloudNode) { - return; + return undefined; } cloudNode.addEventListener('load', onCloudIllustrationLoad); return () => cloudNode.removeEventListener('load', onCloudIllustrationLoad); }, [cloudNode, onCloudIllustrationLoad]); return (

Reclaim your digital identity.

The Internet is broken today. Private user data is owned by mega-corporations and farmed for their benefit.

E2E encryption has the potential to change this equation. But it's constrained by a crucial limitation.

Apps need servers.

Sophisticated applications rely on servers to do things that your devices simply can't.

That's why E2E encryption only works for simple chat apps today. There's no way to build a robust server layer that has access to your data without leaking that data to corporations.

Comm {' '} is the keyserver{' '} company.

In the future, people have their own servers.

Your keyserver is the home of your digital identity. It owns your private keys and your personal data. It's your password manager, your crypto bank, your digital surrogate, and your second brain.

); } export default Keyservers; diff --git a/lib/hooks/child-threads.js b/lib/hooks/child-threads.js index 33dc15d85..d87370090 100644 --- a/lib/hooks/child-threads.js +++ b/lib/hooks/child-threads.js @@ -1,120 +1,120 @@ // @flow import * as React from 'react'; import { fetchSingleMostRecentMessagesFromThreads, fetchSingleMostRecentMessagesFromThreadsActionTypes, } from '../actions/message-actions.js'; import { useFilteredChatListData, type ChatThreadItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { childThreadInfos } from '../selectors/thread-selectors.js'; import { threadInChatList } from '../shared/thread-utils.js'; import threadWatcher from '../shared/thread-watcher.js'; import type { ThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise, useServerCall, } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type ThreadFilter = { +predicate?: (thread: ThreadInfo) => boolean, +searchText?: string, }; function useFilteredChildThreads( threadID: string, filter?: ThreadFilter, ): $ReadOnlyArray { const defaultPredicate = React.useCallback(() => true, []); const { predicate = defaultPredicate, searchText = '' } = filter ?? {}; const childThreads = useSelector(state => childThreadInfos(state)[threadID]); const subchannelIDs = React.useMemo(() => { if (!childThreads) { return new Set(); } return new Set( childThreads.filter(predicate).map(threadInfo => threadInfo.id), ); }, [childThreads, predicate]); const filterSubchannels = React.useCallback( thread => subchannelIDs.has(thread?.id), [subchannelIDs], ); const allSubchannelsList = useFilteredChatListData(filterSubchannels); const searchIndex = useGlobalThreadSearchIndex(); const searchResultIDs = React.useMemo( () => searchIndex.getSearchResults(searchText), [searchIndex, searchText], ); const searchTextExists = !!searchText.length; const subchannelIDsNotInChatList = React.useMemo( () => new Set( allSubchannelsList .filter(item => !threadInChatList(item.threadInfo)) .map(item => item.threadInfo.id), ), [allSubchannelsList], ); React.useEffect(() => { if (!subchannelIDsNotInChatList.size) { - return; + return undefined; } subchannelIDsNotInChatList.forEach(tID => threadWatcher.watchID(tID)); return () => subchannelIDsNotInChatList.forEach(tID => threadWatcher.removeID(tID)); }, [subchannelIDsNotInChatList]); const filteredSubchannelsChatList = React.useMemo(() => { if (!searchTextExists) { return allSubchannelsList; } return allSubchannelsList.filter(item => searchResultIDs.includes(item.threadInfo.id), ); }, [allSubchannelsList, searchResultIDs, searchTextExists]); const threadIDsWithNoMessages = React.useMemo( () => new Set( filteredSubchannelsChatList .filter(item => !item.mostRecentMessageInfo) .map(item => item.threadInfo.id), ), [filteredSubchannelsChatList], ); const dispatchActionPromise = useDispatchActionPromise(); const fetchSingleMostRecentMessages = useServerCall( fetchSingleMostRecentMessagesFromThreads, ); React.useEffect(() => { if (!threadIDsWithNoMessages.size) { return; } dispatchActionPromise( fetchSingleMostRecentMessagesFromThreadsActionTypes, fetchSingleMostRecentMessages(Array.from(threadIDsWithNoMessages)), ); }, [ threadIDsWithNoMessages, fetchSingleMostRecentMessages, dispatchActionPromise, ]); return filteredSubchannelsChatList; } export { useFilteredChildThreads }; diff --git a/lib/shared/messages/multimedia-message-spec.js b/lib/shared/messages/multimedia-message-spec.js index 5a9975f72..ea7245511 100644 --- a/lib/shared/messages/multimedia-message-spec.js +++ b/lib/shared/messages/multimedia-message-spec.js @@ -1,334 +1,335 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec, type MessageTitleParam, type RawMessageInfoFromServerDBRowParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import { contentStringForMediaArray, multimediaMessagePreview, } from '../../media/media-utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { messageTypes, assertMessageType, isMediaMessageType, } from '../../types/message-types.js'; import type { MessageInfo, RawMessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import type { ImagesMessageData, RawImagesMessageInfo, ImagesMessageInfo, } from '../../types/messages/images.js'; import type { MediaMessageData, MediaMessageInfo, RawMediaMessageInfo, } from '../../types/messages/media.js'; import { getMediaMessageServerDBContentsFromMedia } from '../../types/messages/media.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 } from '../../types/user-types.js'; import { ET } from '../../utils/entity-text.js'; import { translateClientDBMediaInfosToMedia, translateClientDBMediaInfoToImage, } from '../../utils/message-ops-utils.js'; import { createMediaMessageInfo } from '../message-utils.js'; import { threadIsGroupChat } from '../thread-utils.js'; import { hasMinCodeVersion } from '../version-utils.js'; export const multimediaMessageSpec: MessageSpec< MediaMessageData | ImagesMessageData, RawMediaMessageInfo | RawImagesMessageInfo, MediaMessageInfo | ImagesMessageInfo, > = Object.freeze({ messageContentForServerDB( data: | MediaMessageData | ImagesMessageData | RawMediaMessageInfo | RawImagesMessageInfo, ): string { if (data.type === messageTypes.MULTIMEDIA) { return JSON.stringify( getMediaMessageServerDBContentsFromMedia(data.media), ); } const mediaIDs = data.media.map(media => parseInt(media.id, 10)); return JSON.stringify(mediaIDs); }, messageContentForClientDB( data: RawMediaMessageInfo | RawImagesMessageInfo, ): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawImagesMessageInfo | RawMediaMessageInfo { const messageType = assertMessageType(parseInt(clientDBMessageInfo.type)); invariant( isMediaMessageType(messageType), 'message must be of type IMAGES or MULTIMEDIA', ); invariant( clientDBMessageInfo.media_infos !== null && clientDBMessageInfo.media_infos !== undefined, `media_infos must be defined`, ); let rawMessageInfo: RawImagesMessageInfo | RawMediaMessageInfo = messageType === messageTypes.IMAGES ? { type: messageTypes.IMAGES, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: clientDBMessageInfo.media_infos?.map( translateClientDBMediaInfoToImage, ) ?? [], } : { type: messageTypes.MULTIMEDIA, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: translateClientDBMediaInfosToMedia(clientDBMessageInfo), }; if (clientDBMessageInfo.local_id) { rawMessageInfo = { ...rawMessageInfo, localID: clientDBMessageInfo.local_id, }; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawMessageInfo = { ...rawMessageInfo, id: clientDBMessageInfo.id, }; } return rawMessageInfo; }, messageTitle({ messageInfo, }: MessageTitleParam) { const creator = ET.user({ userInfo: messageInfo.creator }); const preview = multimediaMessagePreview(messageInfo); return ET`${creator} ${preview}`; }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawMediaMessageInfo | RawImagesMessageInfo { const { localID, media } = params; invariant(media, 'Media should be provided'); return createMediaMessageInfo({ threadID: row.threadID.toString(), creatorID: row.creatorID.toString(), media, id: row.id.toString(), localID, time: row.time, }); }, createMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, creator: RelativeUserInfo, ): ?(MediaMessageInfo | ImagesMessageInfo) { if (rawMessageInfo.type === messageTypes.IMAGES) { let messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { let messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; } + return undefined; }, rawMessageInfoFromMessageData( messageData: MediaMessageData | ImagesMessageData, id: ?string, ): RawMediaMessageInfo | RawImagesMessageInfo { const { sidebarCreation, ...rest } = messageData; if (rest.type === messageTypes.IMAGES && id) { return ({ ...rest, id }: RawImagesMessageInfo); } else if (rest.type === messageTypes.IMAGES) { return ({ ...rest }: RawImagesMessageInfo); } else if (id) { return ({ ...rest, id }: RawMediaMessageInfo); } else { return ({ ...rest }: RawMediaMessageInfo); } }, shimUnsupportedMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, platformDetails: ?PlatformDetails, ): RawMediaMessageInfo | RawImagesMessageInfo | RawUnsupportedMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { return rawMessageInfo; } const containsEncryptedMedia = rawMessageInfo.media.some( media => media.type === 'encrypted_photo' || media.type === 'encrypted_video', ); if (!containsEncryptedMedia && hasMinCodeVersion(platformDetails, 158)) { return rawMessageInfo; } if (hasMinCodeVersion(platformDetails, 205)) { 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: multimediaMessagePreview(rawMessageInfo), unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawMediaMessageInfo | RawImagesMessageInfo, messageInfo: RawMessageInfo, ): ?RawMessageInfo { if (unwrapped.type === messageTypes.IMAGES) { return { ...unwrapped, media: unwrapped.media.map(media => { if (media.dimensions) { return media; } const dimensions = preDimensionUploads[media.id]; invariant( dimensions, 'only four photos were uploaded before dimensions were calculated, ' + `and ${media.id} was not one of them`, ); return { ...media, dimensions }; }), }; } else if (unwrapped.type === messageTypes.MULTIMEDIA) { for (const { type } of unwrapped.media) { if ( type !== 'photo' && type !== 'video' && type !== 'encrypted_photo' && type !== 'encrypted_video' ) { return messageInfo; } } } return undefined; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { const media = []; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, 'messageInfo should be multimedia type!', ); for (const singleMedia of messageInfo.media) { media.push(singleMedia); } } const contentString = contentStringForMediaArray(media); const creator = ET.user({ userInfo: messageInfos[0].creator }); let body, merged; if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { body = `sent you ${contentString}`; merged = body; } else { body = `sent ${contentString}`; const thread = ET.thread({ display: 'shortName', threadInfo }); merged = ET`${body} to ${thread}`; } merged = ET`${creator} ${merged}`; return { merged, body, title: threadInfo.uiName, prefix: ET`${creator}`, }; }, notificationCollapseKey( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, ): string { // We use the legacy constant here to collapse both types into one return joinResult( messageTypes.IMAGES, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, generatesNotifs: async ( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, messageData: MediaMessageData | ImagesMessageData, ) => (messageData.sidebarCreation ? undefined : pushTypes.NOTIF), includedInRepliesCount: true, }); // Four photos were uploaded before dimensions were calculated server-side, // and delivered to clients without dimensions in the MultimediaMessageInfo. const preDimensionUploads = { '156642': { width: 1440, height: 1080 }, '156649': { width: 720, height: 803 }, '156794': { width: 720, height: 803 }, '156877': { width: 574, height: 454 }, }; diff --git a/lib/shared/messages/update-relationship-message-spec.js b/lib/shared/messages/update-relationship-message-spec.js index 4fa93e7bc..ecd5fdd00 100644 --- a/lib/shared/messages/update-relationship-message-spec.js +++ b/lib/shared/messages/update-relationship-message-spec.js @@ -1,176 +1,178 @@ // @flow import invariant from 'invariant'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, } from './message-spec.js'; import { assertSingleMessageInfo } from './utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { messageTypes } from '../../types/message-types.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { RawUpdateRelationshipMessageInfo, UpdateRelationshipMessageData, UpdateRelationshipMessageInfo, } from '../../types/messages/update-relationship.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 } from '../../utils/entity-text.js'; import { hasMinCodeVersion } from '../version-utils.js'; export const updateRelationshipMessageSpec: MessageSpec< UpdateRelationshipMessageData, RawUpdateRelationshipMessageInfo, UpdateRelationshipMessageInfo, > = Object.freeze({ messageContentForServerDB( data: UpdateRelationshipMessageData | RawUpdateRelationshipMessageInfo, ): string { return JSON.stringify({ operation: data.operation, targetID: data.targetID, }); }, messageContentForClientDB(data: RawUpdateRelationshipMessageInfo): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawUpdateRelationshipMessageInfo { const content = JSON.parse(row.content); return { type: messageTypes.UPDATE_RELATIONSHIP, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), targetID: content.targetID, operation: content.operation, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawUpdateRelationshipMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for UpdateRelationship', ); const content = JSON.parse(clientDBMessageInfo.content); const rawUpdateRelationshipMessageInfo: RawUpdateRelationshipMessageInfo = { type: messageTypes.UPDATE_RELATIONSHIP, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, targetID: content.targetID, operation: content.operation, }; return rawUpdateRelationshipMessageInfo; }, createMessageInfo( rawMessageInfo: RawUpdateRelationshipMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?UpdateRelationshipMessageInfo { const target = params.createRelativeUserInfos([rawMessageInfo.targetID])[0]; if (!target) { return null; } return { type: messageTypes.UPDATE_RELATIONSHIP, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, target, time: rawMessageInfo.time, operation: rawMessageInfo.operation, }; }, rawMessageInfoFromMessageData( messageData: UpdateRelationshipMessageData, id: ?string, ): RawUpdateRelationshipMessageInfo { invariant(id, 'RawUpdateRelationshipMessageInfo needs id'); return { ...messageData, id }; }, + // ESLint doesn't recognize that invariant always throws + // eslint-disable-next-line consistent-return robotext(messageInfo: UpdateRelationshipMessageInfo): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); if (messageInfo.operation === 'request_sent') { const target = ET.user({ userInfo: messageInfo.target }); return ET`${creator} sent ${target} a friend request`; } else if (messageInfo.operation === 'request_accepted') { const targetPossessive = ET.user({ userInfo: messageInfo.target, possessive: true, }); return ET`${creator} accepted ${targetPossessive} friend request`; } invariant( false, `Invalid operation ${messageInfo.operation} ` + `of message with type ${messageInfo.type}`, ); }, shimUnsupportedMessageInfo( rawMessageInfo: RawUpdateRelationshipMessageInfo, platformDetails: ?PlatformDetails, ): RawUpdateRelationshipMessageInfo | RawUnsupportedMessageInfo { if (hasMinCodeVersion(platformDetails, 71)) { 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: 'performed a relationship action', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawUpdateRelationshipMessageInfo, ): RawUpdateRelationshipMessageInfo { return unwrapped; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { const messageInfo = assertSingleMessageInfo(messageInfos); const creator = ET.user({ userInfo: messageInfo.creator }); const prefix = ET`${creator}`; const title = threadInfo.uiName; const body = messageInfo.operation === 'request_sent' ? 'sent you a friend request' : 'accepted your friend request'; const merged = ET`${prefix} ${body}`; return { merged, body, title, prefix, }; }, generatesNotifs: async () => pushTypes.NOTIF, }); diff --git a/lib/shared/relationship-utils.js b/lib/shared/relationship-utils.js index 523794236..956c6d133 100644 --- a/lib/shared/relationship-utils.js +++ b/lib/shared/relationship-utils.js @@ -1,110 +1,114 @@ // @flow import invariant from 'invariant'; import { type RelationshipButton, type UserRelationshipStatus, userRelationshipStatus, relationshipButtons, relationshipActions, type RelationshipAction, } from '../types/relationship-types.js'; import type { UserInfo } from '../types/user-types.js'; function sortIDs(firstId: string, secondId: string): string[] { return [Number(firstId), Number(secondId)] .sort((a, b) => a - b) .map(num => num.toString()); } function getAvailableRelationshipButtons( userInfo: UserInfo, ): RelationshipButton[] { const relationship = userInfo.relationshipStatus; if (relationship === userRelationshipStatus.FRIEND) { return [relationshipButtons.UNFRIEND, relationshipButtons.BLOCK]; } else if (relationship === userRelationshipStatus.BLOCKED_VIEWER) { return [relationshipButtons.BLOCK]; } else if ( relationship === userRelationshipStatus.BOTH_BLOCKED || relationship === userRelationshipStatus.BLOCKED_BY_VIEWER ) { return [relationshipButtons.UNBLOCK]; } else if (relationship === userRelationshipStatus.REQUEST_RECEIVED) { return [ relationshipButtons.ACCEPT, relationshipButtons.REJECT, relationshipButtons.BLOCK, ]; } else if (relationship === userRelationshipStatus.REQUEST_SENT) { return [relationshipButtons.WITHDRAW, relationshipButtons.BLOCK]; } else { return [relationshipButtons.FRIEND, relationshipButtons.BLOCK]; } } function relationshipBlockedInEitherDirection( relationshipStatus: UserRelationshipStatus, ): boolean { return ( relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER || relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || relationshipStatus === userRelationshipStatus.BOTH_BLOCKED ); } +// ESLint doesn't recognize that invariant always throws +// eslint-disable-next-line consistent-return function getRelationshipDispatchAction( relationshipButton: RelationshipButton, ): RelationshipAction { if (relationshipButton === relationshipButtons.BLOCK) { return relationshipActions.BLOCK; } else if ( relationshipButton === relationshipButtons.FRIEND || relationshipButton === relationshipButtons.ACCEPT ) { return relationshipActions.FRIEND; } else if ( relationshipButton === relationshipButtons.UNFRIEND || relationshipButton === relationshipButtons.REJECT || relationshipButton === relationshipButtons.WITHDRAW ) { return relationshipActions.UNFRIEND; } else if (relationshipButton === relationshipButtons.UNBLOCK) { return relationshipActions.UNBLOCK; } invariant(false, 'relationshipButton conditions should be exhaustive'); } +// ESLint doesn't recognize that invariant always throws +// eslint-disable-next-line consistent-return function getRelationshipActionText( relationshipButton: RelationshipButton, username: string, ): string { switch (relationshipButton) { case relationshipButtons.BLOCK: return `Block ${username}`; case relationshipButtons.FRIEND: return `Add ${username} to friends`; case relationshipButtons.UNFRIEND: return `Unfriend ${username}`; case relationshipButtons.UNBLOCK: return `Unblock ${username}`; case relationshipButtons.ACCEPT: return `Accept friend request from ${username}`; case relationshipButtons.REJECT: return `Reject friend request from ${username}`; case relationshipButtons.WITHDRAW: return `Withdraw request to friend ${username}`; default: invariant(false, 'invalid relationshipButton value'); } } export { sortIDs, getAvailableRelationshipButtons, relationshipBlockedInEitherDirection, getRelationshipDispatchAction, getRelationshipActionText, }; diff --git a/lib/shared/report-utils.js b/lib/shared/report-utils.js index 82aff643f..91aab00aa 100644 --- a/lib/shared/report-utils.js +++ b/lib/shared/report-utils.js @@ -1,30 +1,32 @@ // @flow import invariant from 'invariant'; import { type ClientReportCreationRequest, reportTypes, } from '../types/report-types.js'; import { type ClientInconsistencyResponse, serverRequestTypes, } from '../types/request-types.js'; function inconsistencyResponsesToReports( responses: $ReadOnlyArray, ): ClientReportCreationRequest[] { + // ESLint doesn't recognize that invariant always throws + // eslint-disable-next-line consistent-return return responses.map(response => { if (response.type === serverRequestTypes.THREAD_INCONSISTENCY) { const { type, ...rest } = response; return { ...rest, type: reportTypes.THREAD_INCONSISTENCY }; } else if (response.type === serverRequestTypes.ENTRY_INCONSISTENCY) { const { type, ...rest } = response; return { ...rest, type: reportTypes.ENTRY_INCONSISTENCY }; } else { invariant(false, `unexpected serverRequestType ${response.type}`); } }); } export { inconsistencyResponsesToReports }; diff --git a/lib/shared/update-utils.js b/lib/shared/update-utils.js index 530549df1..844307e88 100644 --- a/lib/shared/update-utils.js +++ b/lib/shared/update-utils.js @@ -1,145 +1,147 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy.js'; import { type ServerUpdateInfo, type UpdateData, type RawUpdateInfo, updateTypes, } from '../types/update-types.js'; function mostRecentUpdateTimestamp( updateInfos: ServerUpdateInfo[], previousTimestamp: number, ): number { if (updateInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(updateInfos).time; } function keyForUpdateData(updateData: UpdateData): ?string { if ( updateData.type === updateTypes.UPDATE_THREAD || updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS || updateData.type === updateTypes.DELETE_THREAD || updateData.type === updateTypes.JOIN_THREAD ) { return updateData.threadID; } else if (updateData.type === updateTypes.UPDATE_ENTRY) { return updateData.entryID; } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { return updateData.userID; } else if (updateData.type === updateTypes.DELETE_ACCOUNT) { return updateData.deletedUserID; } else if (updateData.type === updateTypes.UPDATE_USER) { return updateData.updatedUserID; } return null; } function keyForUpdateInfo(updateInfo: ServerUpdateInfo): ?string { if ( updateInfo.type === updateTypes.UPDATE_THREAD || updateInfo.type === updateTypes.JOIN_THREAD ) { return updateInfo.threadInfo.id; } else if ( updateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS || updateInfo.type === updateTypes.DELETE_THREAD ) { return updateInfo.threadID; } else if (updateInfo.type === updateTypes.UPDATE_ENTRY) { const { id } = updateInfo.entryInfo; invariant(id, 'should be set'); return id; } else if (updateInfo.type === updateTypes.UPDATE_CURRENT_USER) { return updateInfo.currentUserInfo.id; } else if (updateInfo.type === updateTypes.DELETE_ACCOUNT) { return updateInfo.deletedUserID; } else if (updateInfo.type === updateTypes.UPDATE_USER) { return updateInfo.updatedUserID; } return null; } +// ESLint doesn't recognize that invariant always throws +// eslint-disable-next-line consistent-return function rawUpdateInfoFromUpdateData( updateData: UpdateData, id: string, ): RawUpdateInfo { if (updateData.type === updateTypes.DELETE_ACCOUNT) { return { type: updateTypes.DELETE_ACCOUNT, id, time: updateData.time, deletedUserID: updateData.deletedUserID, }; } else if (updateData.type === updateTypes.UPDATE_THREAD) { return { type: updateTypes.UPDATE_THREAD, id, time: updateData.time, threadID: updateData.threadID, }; } else if (updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS) { return { type: updateTypes.UPDATE_THREAD_READ_STATUS, id, time: updateData.time, threadID: updateData.threadID, unread: updateData.unread, }; } else if (updateData.type === updateTypes.DELETE_THREAD) { return { type: updateTypes.DELETE_THREAD, id, time: updateData.time, threadID: updateData.threadID, }; } else if (updateData.type === updateTypes.JOIN_THREAD) { return { type: updateTypes.JOIN_THREAD, id, time: updateData.time, threadID: updateData.threadID, }; } else if (updateData.type === updateTypes.BAD_DEVICE_TOKEN) { return { type: updateTypes.BAD_DEVICE_TOKEN, id, time: updateData.time, deviceToken: updateData.deviceToken, }; } else if (updateData.type === updateTypes.UPDATE_ENTRY) { return { type: updateTypes.UPDATE_ENTRY, id, time: updateData.time, entryID: updateData.entryID, }; } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { return { type: updateTypes.UPDATE_CURRENT_USER, id, time: updateData.time, }; } else if (updateData.type === updateTypes.UPDATE_USER) { return { type: updateTypes.UPDATE_USER, id, time: updateData.time, updatedUserID: updateData.updatedUserID, }; } else { invariant(false, `unrecognized updateType ${updateData.type}`); } } export { mostRecentUpdateTimestamp, keyForUpdateData, keyForUpdateInfo, rawUpdateInfoFromUpdateData, }; diff --git a/lib/utils/call-server-endpoint.js b/lib/utils/call-server-endpoint.js index c7d8a4d87..84f1a1dc7 100644 --- a/lib/utils/call-server-endpoint.js +++ b/lib/utils/call-server-endpoint.js @@ -1,215 +1,215 @@ // @flow import { getConfig } from './config.js'; import { ServerError, FetchTimeout } from './errors.js'; import sleep from './sleep.js'; import { uploadBlob, type UploadBlob } from './upload-blob.js'; import { callServerEndpointTimeout } from '../shared/timeouts.js'; import { SocketOffline, SocketTimeout } from '../socket/inflight-requests.js'; import type { Shape } from '../types/core.js'; import { type Endpoint, type SocketAPIHandler, endpointIsSocketPreferred, endpointIsSocketOnly, } from '../types/endpoints.js'; import { forcePolicyAcknowledgmentActionType } from '../types/policy-types.js'; import type { Dispatch } from '../types/redux-types.js'; import type { ServerSessionChange, ClientSessionChange, } from '../types/session-types.js'; import type { ConnectionStatus } from '../types/socket-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; export type CallServerEndpointOptions = Shape<{ // null timeout means no timeout, which is the default for uploadBlob +timeout: ?number, // in milliseconds // getResultInfo will be called right before callServerEndpoint successfully // resolves and includes additional information about the request +getResultInfo: (resultInfo: CallServerEndpointResultInfo) => mixed, +blobUpload: boolean | UploadBlob, // the rest (onProgress, abortHandler) only work with blobUpload +onProgress: (percent: number) => void, // abortHandler will receive an abort function once the upload starts +abortHandler: (abort: () => void) => void, }>; export type CallServerEndpointResultInfoInterface = 'socket' | 'REST'; export type CallServerEndpointResultInfo = { +interface: CallServerEndpointResultInfoInterface, }; export type CallServerEndpointResponse = Shape<{ +cookieChange: ServerSessionChange, +currentUserInfo: CurrentUserInfo, +error: string, +payload: Object, }>; // You'll notice that this is not the type of the callServerEndpoint // function below. This is because the first several parameters to that // function get bound in by the helpers in lib/utils/action-utils.js. // This type represents the form of the callServerEndpoint function that // gets passed to the action function in lib/actions. export type CallServerEndpoint = ( endpoint: Endpoint, input: Object, options?: ?CallServerEndpointOptions, ) => Promise; type RequestData = { input: { [key: string]: mixed }, cookie?: ?string, sessionID?: ?string, }; // If cookie is undefined, then we will defer to the underlying environment to // handle cookies, and we won't worry about them. We do this on the web since // our cookies are httponly to protect against XSS attacks. On the other hand, // on native we want to keep track of the cookies since we don't trust the // underlying implementations and prefer for things to be explicit, and XSS // isn't a thing on native. Note that for native, cookie might be null // (indicating we don't have one), and we will then set an empty Cookie header. async function callServerEndpoint( cookie: ?string, setNewSession: (sessionChange: ClientSessionChange, error: ?string) => void, waitIfCookieInvalidated: () => Promise, cookieInvalidationRecovery: ( sessionChange: ClientSessionChange, ) => Promise, urlPrefix: string, sessionID: ?string, connectionStatus: ConnectionStatus, socketAPIHandler: ?SocketAPIHandler, endpoint: Endpoint, input: { [key: string]: mixed }, dispatch: Dispatch, options?: ?CallServerEndpointOptions, ): Promise { const possibleReplacement = await waitIfCookieInvalidated(); if (possibleReplacement) { return await possibleReplacement(endpoint, input, options); } if ( endpointIsSocketPreferred(endpoint) && connectionStatus === 'connected' && socketAPIHandler ) { try { const result = await socketAPIHandler({ endpoint, input }); options?.getResultInfo?.({ interface: 'socket' }); return result; } catch (e) { if (endpointIsSocketOnly(endpoint)) { throw e; } else if (e instanceof SocketOffline) { // nothing } else if (e instanceof SocketTimeout) { // nothing } else { throw e; } } } if (endpointIsSocketOnly(endpoint)) { throw new SocketOffline('socket_offline'); } const url = urlPrefix ? `${urlPrefix}/${endpoint}` : endpoint; let json; if (options && options.blobUpload) { const uploadBlobCallback = typeof options.blobUpload === 'function' ? options.blobUpload : uploadBlob; json = await uploadBlobCallback(url, cookie, sessionID, input, options); } else { const mergedData: RequestData = { input }; if (getConfig().setCookieOnRequest) { // We make sure that if setCookieOnRequest is true, we never set cookie to // undefined. null has a special meaning here: we don't currently have a // cookie, and we want the server to specify the new cookie it will // generate in the response body rather than the response header. See // session-types.js for more details on why we specify cookies in the body mergedData.cookie = cookie ? cookie : null; } if (getConfig().setSessionIDOnRequest) { // We make sure that if setSessionIDOnRequest is true, we never set // sessionID to undefined. null has a special meaning here: we cannot // consider the cookieID to be a unique session identifier, but we do not // have a sessionID to use either. This should only happen when the user // is not logged in on web. mergedData.sessionID = sessionID ? sessionID : null; } const callEndpointPromise = (async (): Promise => { const response = await fetch(url, { method: 'POST', // This is necessary to allow cookie headers to get passed down to us credentials: 'same-origin', body: JSON.stringify(mergedData), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }); const text = await response.text(); try { return JSON.parse(text); } catch (e) { console.log(text); throw e; } })(); const timeout = options && options.timeout ? options.timeout : callServerEndpointTimeout; if (!timeout) { json = await callEndpointPromise; } else { const rejectPromise = (async () => { await sleep(timeout); throw new FetchTimeout( `callServerEndpoint timed out call to ${endpoint}`, endpoint, ); })(); json = await Promise.race([callEndpointPromise, rejectPromise]); } } const { cookieChange, error, payload, currentUserInfo } = json; const sessionChange: ?ServerSessionChange = cookieChange; if (sessionChange) { const { threadInfos, userInfos, ...rest } = sessionChange; const clientSessionChange = rest.cookieInvalidated ? rest : { cookieInvalidated: false, currentUserInfo, ...rest }; if (clientSessionChange.cookieInvalidated) { const maybeReplacement = await cookieInvalidationRecovery( clientSessionChange, ); if (maybeReplacement) { return await maybeReplacement(endpoint, input, options); } } setNewSession(clientSessionChange, error); } if (error === 'policies_not_accepted') { dispatch({ type: forcePolicyAcknowledgmentActionType, payload, }); - return; + return undefined; } else if (error) { throw new ServerError(error, payload); } options?.getResultInfo?.({ interface: 'REST' }); return json; } export default callServerEndpoint; diff --git a/lib/utils/date-utils.js b/lib/utils/date-utils.js index 92d00b323..0e34078a4 100644 --- a/lib/utils/date-utils.js +++ b/lib/utils/date-utils.js @@ -1,175 +1,177 @@ // @flow import dateFormat from 'dateformat'; import invariant from 'invariant'; // Javascript uses 0-indexed months which is weird?? function getDate( yearInput: number, monthInput: number, dayOfMonth: number, ): Date { return new Date(yearInput, monthInput - 1, dayOfMonth); } function padMonthOrDay(n: number): string | number { return n < 10 ? '0' + n : n; } function daysInMonth(year: number, month: number) { switch (month) { case 2: return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28; case 4: case 6: case 9: case 11: return 30; default: return 31; } } +// ESLint doesn't recognize that invariant always throws +// eslint-disable-next-line consistent-return function dateString( first: Date | number, month?: number, day?: number, ): string { if (arguments.length === 1) { return dateFormat(first, 'yyyy-mm-dd'); } else if (arguments.length === 3) { invariant(month && day, 'month/day should be set in call to dateString'); invariant(typeof first === 'number', 'first param should be a number'); return `${first}-${padMonthOrDay(month)}-${padMonthOrDay(day)}`; } invariant(false, 'incorrect number of params passed to dateString'); } function startDateForYearAndMonth(year: number, month: number): string { return dateString(year, month, 1); } function endDateForYearAndMonth(year: number, month: number): string { return dateString(year, month, daysInMonth(year, month)); } function fifteenDaysEarlier(timeZone?: ?string): string { const earlier = currentDateInTimeZone(timeZone); earlier.setDate(earlier.getDate() - 15); return dateString(earlier); } function fifteenDaysLater(timeZone?: ?string): string { const later = currentDateInTimeZone(timeZone); later.setDate(later.getDate() + 15); return dateString(later); } function prettyDate(dayString: string): string { return dateFormat(dateFromString(dayString), 'dddd, mmmm dS, yyyy'); } function prettyDateWithoutDay(dayString: string): string { return dateFormat(dateFromString(dayString), 'mmmm dS, yyyy'); } function prettyDateWithoutYear(dayString: string): string { return dateFormat(dateFromString(dayString), 'dddd, mmmm dS'); } function dateFromString(dayString: string): Date { const matches = dayString.match(/^([0-9]+)-([0-1][0-9])-([0-3][0-9])$/); invariant(matches && matches.length === 4, `invalid dayString ${dayString}`); return getDate( parseInt(matches[1], 10), parseInt(matches[2], 10), parseInt(matches[3], 10), ); } const millisecondsInDay = 24 * 60 * 60 * 1000; const millisecondsInWeek = millisecondsInDay * 7; const millisecondsInYear = millisecondsInDay * 365; // Takes a millisecond timestamp and displays the time in the local timezone function shortAbsoluteDate(timestamp: number): string { const now = Date.now(); const msSince = now - timestamp; const date = new Date(timestamp); if (msSince < millisecondsInDay) { return dateFormat(date, 'h:MM TT'); } else if (msSince < millisecondsInWeek) { return dateFormat(date, 'ddd'); } else if (msSince < millisecondsInYear) { return dateFormat(date, 'mmm d'); } else { return dateFormat(date, 'mmm d yyyy'); } } // Same as above, but longer function longAbsoluteDate(timestamp: number): string { const now = Date.now(); const msSince = now - timestamp; const date = new Date(timestamp); if (msSince < millisecondsInDay) { return dateFormat(date, 'h:MM TT'); } else if (msSince < millisecondsInWeek) { return dateFormat(date, 'ddd h:MM TT'); } else if (msSince < millisecondsInYear) { return dateFormat(date, 'mmmm d, h:MM TT'); } else { return dateFormat(date, 'mmmm d yyyy, h:MM TT'); } } function thisMonthDates(timeZone?: ?string): { startDate: string, endDate: string, } { const now = currentDateInTimeZone(timeZone); const year = now.getFullYear(); const month = now.getMonth() + 1; return { startDate: startDateForYearAndMonth(year, month), endDate: endDateForYearAndMonth(year, month), }; } // The Date object doesn't support time zones, and is hardcoded to the server's // time zone. Thus, the best way to convert Date between time zones is to offset // the Date by the difference between the time zones function changeTimeZone(date: Date, timeZone: ?string): Date { if (!timeZone) { return date; } const localeString = date.toLocaleString('en-US', { timeZone }); const localeDate = new Date(localeString); const diff = localeDate.getTime() - date.getTime(); return new Date(date.getTime() + diff); } function currentDateInTimeZone(timeZone: ?string): Date { return changeTimeZone(new Date(), timeZone); } const threeDays = millisecondsInDay * 3; export { getDate, padMonthOrDay, dateString, startDateForYearAndMonth, endDateForYearAndMonth, fifteenDaysEarlier, fifteenDaysLater, prettyDate, prettyDateWithoutDay, prettyDateWithoutYear, dateFromString, shortAbsoluteDate, longAbsoluteDate, thisMonthDates, currentDateInTimeZone, threeDays, }; diff --git a/lib/utils/entity-text.js b/lib/utils/entity-text.js index acb3bdb9b..a477487c2 100644 --- a/lib/utils/entity-text.js +++ b/lib/utils/entity-text.js @@ -1,544 +1,550 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { GetENSNames } from './ens-helpers.js'; import { useENSNames } from '../hooks/ens-cache.js'; import { threadNoun } from '../shared/thread-utils.js'; import { stringForUser } from '../shared/user-utils.js'; import { threadTypes, type ThreadType, type RawThreadInfo, type ThreadInfo, } from '../types/thread-types.js'; import { basePluralize } from '../utils/text-utils.js'; type UserEntity = { +type: 'user', +id: string, +username?: ?string, +isViewer?: ?boolean, +possessive?: ?boolean, // eg. `user's` instead of `user` }; // Comments explain how thread name will appear from user4's perspective export type ThreadEntity = | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or 'user1, user2, and user3' +display: 'uiName', // If uiName is EntityText, then at render time ThreadEntity will be // replaced with a pluralized list of uiName's UserEntities +uiName: $ReadOnlyArray | string, // If name isn't set and uiName is an array with only the viewer, then // just_you_string displays "just you" but viewer_username displays the // viewer's ENS-resolved username. Defaults to just_you_string +ifJustViewer?: 'just_you_string' | 'viewer_username', } | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or eg. 'this thread' or 'this chat' +display: 'shortName', +threadType?: ?ThreadType, +parentThreadID?: ?string, +alwaysDisplayShortName?: ?boolean, // don't default to name +subchannel?: ?boolean, // short name should be "subchannel" +possessive?: ?boolean, // eg. `this thread's` instead of `this thread` }; type ColorEntity = { +type: 'color', +hex: string, }; type EntityTextComponent = UserEntity | ThreadEntity | ColorEntity | string; export type EntityText = $ReadOnlyArray; const entityTextFunction = ( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => { const result = []; for (let i = 0; i < strings.length; i++) { const str = strings[i]; if (str) { result.push(str); } const entity = entities[i]; if (!entity) { continue; } if (typeof entity === 'string') { const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string') { result[result.length - 1] = lastResult + entity; } else { result.push(entity); } } else if (Array.isArray(entity)) { const [firstEntity, ...restOfEntity] = entity; const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string' && typeof firstEntity === 'string') { result[result.length - 1] = lastResult + firstEntity; } else if (firstEntity) { result.push(firstEntity); } result.push(...restOfEntity); } else { result.push(entity); } } return result; }; // defaults to shortName type EntityTextThreadInput = | { +display: 'uiName', +threadInfo: ThreadInfo, } | { +display?: 'shortName', +threadInfo: ThreadInfo | RawThreadInfo, +subchannel?: ?boolean, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', +threadInfo: ThreadInfo | RawThreadInfo, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', +threadID: string, +parentThreadID?: ?string, +threadType?: ?ThreadType, +possessive?: ?boolean, }; +// ESLint doesn't recognize that invariant always throws +// eslint-disable-next-line consistent-return entityTextFunction.thread = (input: EntityTextThreadInput) => { if (input.display === 'uiName') { const { threadInfo } = input; if (typeof threadInfo.uiName !== 'string') { return threadInfo.uiName; } return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: threadInfo.uiName, }; } if (input.display === 'alwaysDisplayShortName' && input.threadID) { const { threadID, threadType, parentThreadID, possessive } = input; return { type: 'thread', id: threadID, name: undefined, display: 'shortName', threadType, parentThreadID, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'alwaysDisplayShortName' && input.threadInfo) { const { threadInfo, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, parentThreadID: threadInfo.parentThreadID, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'shortName' || !input.display) { const { threadInfo, subchannel, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, parentThreadID: threadInfo.parentThreadID, subchannel, possessive, }; } invariant( false, `ET.thread passed unexpected display type: ${input.display}`, ); }; type EntityTextUserInput = { +userInfo: { +id: string, +username?: ?string, +isViewer?: ?boolean, ... }, +possessive?: ?boolean, }; entityTextFunction.user = (input: EntityTextUserInput) => ({ type: 'user', id: input.userInfo.id, username: input.userInfo.username, isViewer: input.userInfo.isViewer, possessive: input.possessive, }); type EntityTextColorInput = { +hex: string }; entityTextFunction.color = (input: EntityTextColorInput) => ({ type: 'color', hex: input.hex, }); // ET is a JS tag function used in template literals, eg. ET`something` // It allows you to compose raw text and "entities" together type EntityTextFunction = (( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => EntityText) & { +thread: EntityTextThreadInput => ThreadEntity, +user: EntityTextUserInput => UserEntity, +color: EntityTextColorInput => ColorEntity, ... }; const ET: EntityTextFunction = entityTextFunction; type MakePossessiveInput = { +str: string, +isViewer?: ?boolean }; function makePossessive(input: MakePossessiveInput) { if (input.isViewer) { return 'your'; } return `${input.str}’s`; } function getNameForThreadEntity( entity: ThreadEntity, params?: ?EntityTextToRawStringParams, ): string { const { name: userGeneratedName, display } = entity; if (entity.display === 'uiName') { if (userGeneratedName) { return userGeneratedName; } const { uiName } = entity; if (typeof uiName === 'string') { return uiName; } let userEntities = uiName; if (!params?.ignoreViewer) { const viewerFilteredUserEntities = userEntities.filter( innerEntity => !innerEntity.isViewer, ); if (viewerFilteredUserEntities.length > 0) { userEntities = viewerFilteredUserEntities; } else if (entity.ifJustViewer === 'viewer_username') { // We pass ignoreViewer to entityTextToRawString in order // to prevent it from rendering the viewer as "you" params = { ...params, ignoreViewer: true }; } else { return 'just you'; } } const pluralized = pluralizeEntityText( userEntities.map(innerEntity => [innerEntity]), ); return entityTextToRawString(pluralized, params); } invariant( entity.display === 'shortName', `getNameForThreadEntity can't handle thread entity display ${display}`, ); let { name } = entity; if (!name || entity.alwaysDisplayShortName) { const threadType = entity.threadType ?? threadTypes.PERSONAL; const { parentThreadID } = entity; const noun = entity.subchannel ? 'subchannel' : threadNoun(threadType, parentThreadID); if (entity.id === params?.threadID) { const prefixThisThreadNounWith = params?.prefixThisThreadNounWith === 'your' ? 'your' : 'this'; name = `${prefixThisThreadNounWith} ${noun}`; } else { name = `a ${noun}`; } } if (entity.possessive) { name = makePossessive({ str: name }); } return name; } function getNameForUserEntity( entity: UserEntity, ignoreViewer: ?boolean, ): string { const isViewer = entity.isViewer && !ignoreViewer; const entityWithIsViewerIgnored = { ...entity, isViewer }; const str = stringForUser(entityWithIsViewerIgnored); if (!entityWithIsViewerIgnored.possessive) { return str; } return makePossessive({ str, isViewer }); } type EntityTextToRawStringParams = { +threadID?: ?string, +ignoreViewer?: ?boolean, +prefixThisThreadNounWith?: ?('this' | 'your'), }; function entityTextToRawString( entityText: EntityText, params?: ?EntityTextToRawStringParams, ): string { + // ESLint doesn't recognize that invariant always throws + // eslint-disable-next-line consistent-return const textParts = entityText.map(entity => { if (typeof entity === 'string') { return entity; } else if (entity.type === 'thread') { return getNameForThreadEntity(entity, params); } else if (entity.type === 'color') { return entity.hex; } else if (entity.type === 'user') { return getNameForUserEntity(entity, params?.ignoreViewer); } else { invariant( false, `entityTextToRawString can't handle entity type ${entity.type}`, ); } }); return textParts.join(''); } type RenderFunctions = { +renderText: ({ +text: string }) => React.Node, +renderThread: ({ +id: string, +name: string }) => React.Node, +renderColor: ({ +hex: string }) => React.Node, }; function entityTextToReact( entityText: EntityText, threadID: string, renderFuncs: RenderFunctions, ): React.Node { const { renderText, renderThread, renderColor } = renderFuncs; + // ESLint doesn't recognize that invariant always throws + // eslint-disable-next-line consistent-return return entityText.map((entity, i) => { const key = `text${i}`; if (typeof entity === 'string') { return ( {renderText({ text: entity })} ); } else if (entity.type === 'thread') { const { id } = entity; const name = getNameForThreadEntity(entity, { threadID }); if (id === threadID) { return name; } else { return ( {renderThread({ id, name })} ); } } else if (entity.type === 'color') { return ( {renderColor({ hex: entity.hex })} ); } else if (entity.type === 'user') { return getNameForUserEntity(entity); } else { invariant( false, `entityTextToReact can't handle entity type ${entity.type}`, ); } }); } function pluralizeEntityText( nouns: $ReadOnlyArray, maxNumberOfNouns: number = 3, ): EntityText { return basePluralize( nouns, maxNumberOfNouns, (a: EntityText | string, b: ?EntityText | string) => b ? ET`${a}${b}` : ET`${a}`, ); } type TextEntity = { +type: 'text', +text: string }; type ShadowUserEntity = { +type: 'shadowUser', +username: string, +originalUsername: string, }; type EntityTextComponentAsObject = | UserEntity | ThreadEntity | ColorEntity | TextEntity | ShadowUserEntity; function entityTextToObjects( entityText: EntityText, ): EntityTextComponentAsObject[] { const objs = []; for (const entity of entityText) { if (typeof entity === 'string') { objs.push({ type: 'text', text: entity }); continue; } objs.push(entity); if ( entity.type === 'thread' && entity.display === 'uiName' && typeof entity.uiName !== 'string' ) { for (const innerEntity of entity.uiName) { if (typeof innerEntity === 'string' || innerEntity.type !== 'user') { continue; } const { username } = innerEntity; if (username) { objs.push({ type: 'shadowUser', originalUsername: username, username, }); } } } } return objs; } function entityTextFromObjects( objects: $ReadOnlyArray, ): EntityText { const shadowUserMap = new Map(); for (const obj of objects) { if (obj.type === 'shadowUser' && obj.username !== obj.originalUsername) { shadowUserMap.set(obj.originalUsername, obj.username); } } return objects .map(entity => { if (entity.type === 'text') { return entity.text; } else if (entity.type === 'shadowUser') { return null; } else if ( entity.type === 'thread' && entity.display === 'uiName' && typeof entity.uiName !== 'string' ) { const uiName = []; let changeOccurred = false; for (const innerEntity of entity.uiName) { if (typeof innerEntity === 'string' || innerEntity.type !== 'user') { uiName.push(innerEntity); continue; } const { username } = innerEntity; if (!username) { uiName.push(innerEntity); continue; } const ensName = shadowUserMap.get(username); if (!ensName) { uiName.push(innerEntity); continue; } changeOccurred = true; uiName.push({ ...innerEntity, username: ensName, }); } if (!changeOccurred) { return entity; } return { ...entity, uiName, }; } else { return entity; } }) .filter(Boolean); } function useENSNamesForEntityText(entityText: ?EntityText): ?EntityText { const allObjects = React.useMemo( () => (entityText ? entityTextToObjects(entityText) : []), [entityText], ); const objectsWithENSNames = useENSNames(allObjects); return React.useMemo( () => entityText ? entityTextFromObjects(objectsWithENSNames) : entityText, [entityText, objectsWithENSNames], ); } function useEntityTextAsString( entityText: ?EntityText, params?: EntityTextToRawStringParams, ): ?string { const withENSNames = useENSNamesForEntityText(entityText); return React.useMemo(() => { if (!withENSNames) { return withENSNames; } return entityTextToRawString(withENSNames, params); }, [withENSNames, params]); } async function getEntityTextAsString( entityText: ?EntityText, getENSNames: ?GetENSNames, params?: EntityTextToRawStringParams, ): Promise { if (!entityText) { return entityText; } let resolvedEntityText = entityText; if (getENSNames) { const allObjects = entityTextToObjects(entityText); const objectsWithENSNames = await getENSNames(allObjects); resolvedEntityText = entityTextFromObjects(objectsWithENSNames); } return entityTextToRawString(resolvedEntityText, params); } export { ET, entityTextToRawString, entityTextToReact, pluralizeEntityText, useENSNamesForEntityText, useEntityTextAsString, getEntityTextAsString, }; diff --git a/native/avatars/avatar-hooks.js b/native/avatars/avatar-hooks.js index db96ee030..9c1501828 100644 --- a/native/avatars/avatar-hooks.js +++ b/native/avatars/avatar-hooks.js @@ -1,551 +1,556 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import * as ImagePicker from 'expo-image-picker'; import * as React from 'react'; import { Platform } from 'react-native'; import Alert from 'react-native/Libraries/Alert/Alert.js'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { changeThreadSettings, changeThreadSettingsActionTypes, } from 'lib/actions/thread-actions.js'; import { uploadMultimedia } from 'lib/actions/upload-actions.js'; import { updateUserAvatar, updateUserAvatarActionTypes, } from 'lib/actions/user-actions.js'; import { extensionFromFilename, filenameFromPathOrURI, } from 'lib/media/file-utils.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { ImageAvatarDBContent, ENSAvatarDBContent, UpdateUserAvatarRemoveRequest, } from 'lib/types/avatar-types.js'; import type { SetState } from 'lib/types/hook-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { NativeMediaSelection, MediaLibrarySelection, MediaMissionFailure, UploadMultimediaResult, } from 'lib/types/media-types.js'; import type { UpdateThreadRequest } from 'lib/types/thread-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import CommIcon from '../components/comm-icon.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { getCompatibleMediaURI } from '../media/identifier-utils.js'; import type { MediaResult } from '../media/media-utils.js'; import { processMedia } from '../media/media-utils.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; function useUploadProcessedMedia(): MediaResult => Promise { const callUploadMultimedia = useServerCall(uploadMultimedia); const uploadProcessedMultimedia: MediaResult => Promise = React.useCallback( processedMedia => { const { uploadURI, filename, mime, dimensions } = processedMedia; return callUploadMultimedia( { uri: uploadURI, name: filename, type: mime, }, dimensions, ); }, [callUploadMultimedia], ); return uploadProcessedMultimedia; } function useProcessSelectedMedia(): NativeMediaSelection => Promise< MediaMissionFailure | MediaResult, > { const hasWiFi = useSelector(state => state.connectivity.hasWiFi); const staffCanSee = useStaffCanSee(); const processSelectedMedia = React.useCallback( async (selection: NativeMediaSelection) => { const { resultPromise } = processMedia(selection, { hasWiFi, finalFileHeaderCheck: staffCanSee, }); return await resultPromise; }, [hasWiFi, staffCanSee], ); return processSelectedMedia; } async function selectFromGallery(): Promise { try { const { assets, canceled } = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, allowsMultipleSelection: false, quality: 1, }); if (canceled || assets.length === 0) { - return; + return undefined; } const asset = assets.pop(); const { width, height, assetId: mediaNativeID } = asset; const assetFilename = asset.fileName || filenameFromPathOrURI(asset.uri) || ''; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(assetFilename), ); const currentTime = Date.now(); const selection: MediaLibrarySelection = { step: 'photo_library', dimensions: { height, width }, uri, filename: assetFilename, mediaNativeID, selectTime: currentTime, sendTime: currentTime, retries: 0, }; return selection; } catch (e) { console.log(e); return undefined; } } function useUploadSelectedMedia( setProcessingOrUploadInProgress: SetState, ): (selection: ?NativeMediaSelection) => Promise { const processSelectedMedia = useProcessSelectedMedia(); const uploadProcessedMedia = useUploadProcessedMedia(); return React.useCallback( async (selection: ?NativeMediaSelection) => { if (!selection) { Alert.alert( 'Media selection failed', 'Unable to select media from Media Library.', ); - return; + return undefined; } setProcessingOrUploadInProgress(true); let processedMedia; try { processedMedia = await processSelectedMedia(selection); } catch (e) { Alert.alert( 'Media processing failed', 'Unable to process selected media.', ); setProcessingOrUploadInProgress(false); - return; + return undefined; } if (!processedMedia.success) { Alert.alert( 'Media processing failed', 'Unable to process selected media.', ); setProcessingOrUploadInProgress(false); - return; + return undefined; } let uploadedMedia: UploadMultimediaResult; try { uploadedMedia = await uploadProcessedMedia(processedMedia); } catch { Alert.alert( 'Media upload failed', 'Unable to upload selected media. Please try again.', ); setProcessingOrUploadInProgress(false); - return; + return undefined; } return uploadedMedia.id; }, [ processSelectedMedia, setProcessingOrUploadInProgress, uploadProcessedMedia, ], ); } const updateUserAvatarLoadingStatusSelector = createLoadingStatusSelector( updateUserAvatarActionTypes, ); function useSelectFromGalleryAndUpdateUserAvatar(): [ () => Promise, boolean, ] { const dispatchActionPromise = useDispatchActionPromise(); const updateUserAvatarCall = useServerCall(updateUserAvatar); const [processingOrUploadInProgress, setProcessingOrUploadInProgress] = React.useState(false); const updateUserAvatarLoadingStatus: LoadingStatus = useSelector( updateUserAvatarLoadingStatusSelector, ); const inProgress = React.useMemo( () => processingOrUploadInProgress || updateUserAvatarLoadingStatus === 'loading', [processingOrUploadInProgress, updateUserAvatarLoadingStatus], ); const uploadSelectedMedia = useUploadSelectedMedia( setProcessingOrUploadInProgress, ); const selectFromGalleryAndUpdateUserAvatar = React.useCallback(async () => { const selection: ?MediaLibrarySelection = await selectFromGallery(); const uploadedMediaID = await uploadSelectedMedia(selection); if (!uploadedMediaID) { return; } const imageAvatarUpdateRequest: ImageAvatarDBContent = { type: 'image', uploadID: uploadedMediaID, }; dispatchActionPromise( updateUserAvatarActionTypes, (async () => { setProcessingOrUploadInProgress(false); try { return await updateUserAvatarCall(imageAvatarUpdateRequest); - } catch { + } catch (e) { Alert.alert('Avatar update failed', 'Unable to update avatar.'); + throw e; } })(), ); }, [dispatchActionPromise, updateUserAvatarCall, uploadSelectedMedia]); return React.useMemo( () => [selectFromGalleryAndUpdateUserAvatar, inProgress], [selectFromGalleryAndUpdateUserAvatar, inProgress], ); } const threadAvatarLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:avatar`, ); function useSelectFromGalleryAndUpdateThreadAvatar( threadID: string, ): [() => Promise, boolean] { const dispatchActionPromise = useDispatchActionPromise(); const changeThreadSettingsCall = useServerCall(changeThreadSettings); const [processingOrUploadInProgress, setProcessingOrUploadInProgress] = React.useState(false); const updateThreadAvatarLoadingStatus: LoadingStatus = useSelector( threadAvatarLoadingStatusSelector, ); const inProgress = React.useMemo( () => processingOrUploadInProgress || updateThreadAvatarLoadingStatus === 'loading', [processingOrUploadInProgress, updateThreadAvatarLoadingStatus], ); const uploadSelectedMedia = useUploadSelectedMedia( setProcessingOrUploadInProgress, ); const selectFromGalleryAndUpdateThreadAvatar = React.useCallback(async () => { const selection: ?MediaLibrarySelection = await selectFromGallery(); const uploadedMediaID = await uploadSelectedMedia(selection); if (!uploadedMediaID) { return; } const imageAvatarUpdateRequest: ImageAvatarDBContent = { type: 'image', uploadID: uploadedMediaID, }; const updateThreadRequest: UpdateThreadRequest = { threadID, changes: { avatar: imageAvatarUpdateRequest, }, }; dispatchActionPromise( changeThreadSettingsActionTypes, (async () => { setProcessingOrUploadInProgress(false); try { return await changeThreadSettingsCall(updateThreadRequest); - } catch { + } catch (e) { Alert.alert('Avatar update failed', 'Unable to update avatar.'); + throw e; } })(), { customKeyName: `${changeThreadSettingsActionTypes.started}:avatar` }, ); }, [ changeThreadSettingsCall, dispatchActionPromise, threadID, uploadSelectedMedia, ]); return React.useMemo( () => [selectFromGalleryAndUpdateThreadAvatar, inProgress], [inProgress, selectFromGalleryAndUpdateThreadAvatar], ); } function useRemoveUserAvatar(): [() => void, boolean] { const dispatchActionPromise = useDispatchActionPromise(); const updateUserAvatarCall = useServerCall(updateUserAvatar); const updateUserAvatarLoadingStatus: LoadingStatus = useSelector( updateUserAvatarLoadingStatusSelector, ); const removeUserAvatar = React.useCallback(() => { const removeAvatarRequest: UpdateUserAvatarRemoveRequest = { type: 'remove', }; dispatchActionPromise( updateUserAvatarActionTypes, (async () => { try { return await updateUserAvatarCall(removeAvatarRequest); - } catch { + } catch (e) { Alert.alert('Avatar update failed', 'Unable to update avatar.'); + throw e; } })(), ); }, [dispatchActionPromise, updateUserAvatarCall]); return React.useMemo( () => [removeUserAvatar, updateUserAvatarLoadingStatus === 'loading'], [removeUserAvatar, updateUserAvatarLoadingStatus], ); } function useRemoveThreadAvatar(threadID: string): [() => void, boolean] { const dispatchActionPromise = useDispatchActionPromise(); const changeThreadSettingsCall = useServerCall(changeThreadSettings); const updateThreadAvatarLoadingStatus: LoadingStatus = useSelector( threadAvatarLoadingStatusSelector, ); const removeThreadAvatar = React.useCallback(() => { const removeAvatarRequest: UpdateUserAvatarRemoveRequest = { type: 'remove', }; const updateThreadRequest: UpdateThreadRequest = { threadID, changes: { avatar: removeAvatarRequest, }, }; dispatchActionPromise( changeThreadSettingsActionTypes, (async () => { try { return await changeThreadSettingsCall(updateThreadRequest); - } catch { + } catch (e) { Alert.alert('Avatar update failed', 'Unable to update avatar.'); + throw e; } })(), { customKeyName: `${changeThreadSettingsActionTypes.started}:avatar` }, ); }, [changeThreadSettingsCall, dispatchActionPromise, threadID]); return React.useMemo( () => [removeThreadAvatar, updateThreadAvatarLoadingStatus === 'loading'], [removeThreadAvatar, updateThreadAvatarLoadingStatus], ); } function useENSUserAvatar(): [() => void, boolean] { const dispatchActionPromise = useDispatchActionPromise(); const updateUserAvatarCall = useServerCall(updateUserAvatar); const updateUserAvatarLoadingStatus: LoadingStatus = useSelector( updateUserAvatarLoadingStatusSelector, ); const saveENSUserAvatar = React.useCallback(() => { const ensAvatarRequest: ENSAvatarDBContent = { type: 'ens', }; dispatchActionPromise( updateUserAvatarActionTypes, (async () => { try { return await updateUserAvatarCall(ensAvatarRequest); - } catch { + } catch (e) { Alert.alert('Avatar update failed', 'Unable to update avatar.'); + throw e; } })(), ); }, [dispatchActionPromise, updateUserAvatarCall]); return React.useMemo( () => [saveENSUserAvatar, updateUserAvatarLoadingStatus === 'loading'], [saveENSUserAvatar, updateUserAvatarLoadingStatus], ); } type ShowAvatarActionSheetOptions = { +id: 'emoji' | 'image' | 'ens' | 'cancel' | 'remove', +onPress?: () => mixed, }; function useShowAvatarActionSheet( options: $ReadOnlyArray, ): () => void { options = Platform.OS === 'ios' ? [...options, { id: 'cancel' }] : options; const insets = useSafeAreaInsets(); const { showActionSheetWithOptions } = useActionSheet(); const styles = useStyles(unboundStyles); const showAvatarActionSheet = React.useCallback(() => { const texts = options.map((option: ShowAvatarActionSheetOptions) => { if (option.id === 'emoji') { return 'Use Emoji'; } else if (option.id === 'image') { return 'Select image'; } else if (option.id === 'ens') { return 'Use ENS Avatar'; } else if (option.id === 'remove') { return 'Remove avatar'; } else { return 'Cancel'; } }); const cancelButtonIndex = options.findIndex( option => option.id === 'cancel', ); const containerStyle = { paddingBotton: insets.bottom, }; const icons = options.map(option => { if (option.id === 'emoji') { return ( ); } else if (option.id === 'image') { return ( ); } else if (option.id === 'ens') { return ( ); } else if (option.id === 'remove') { return ( ); } else { return undefined; } }); const onPressAction = (selectedIndex: ?number) => { if ( selectedIndex === null || selectedIndex === undefined || selectedIndex < 0 ) { return; } const option = options[selectedIndex]; if (option.onPress) { option.onPress(); } }; showActionSheetWithOptions( { options: texts, cancelButtonIndex, containerStyle, icons, }, onPressAction, ); }, [ insets.bottom, options, showActionSheetWithOptions, styles.bottomSheetIcon, ]); return showAvatarActionSheet; } const unboundStyles = { bottomSheetIcon: { color: '#000000', }, }; export { useUploadProcessedMedia, useProcessSelectedMedia, useShowAvatarActionSheet, useSelectFromGalleryAndUpdateUserAvatar, useSelectFromGalleryAndUpdateThreadAvatar, useRemoveUserAvatar, useRemoveThreadAvatar, useENSUserAvatar, }; diff --git a/native/calendar/calendar.react.js b/native/calendar/calendar.react.js index fd279c2d3..3c4df5930 100644 --- a/native/calendar/calendar.react.js +++ b/native/calendar/calendar.react.js @@ -1,1094 +1,1100 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _find from 'lodash/fp/find.js'; import _findIndex from 'lodash/fp/findIndex.js'; import _map from 'lodash/fp/map.js'; import _pickBy from 'lodash/fp/pickBy.js'; import _size from 'lodash/fp/size.js'; import _sum from 'lodash/fp/sum.js'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { View, Text, FlatList, AppState as NativeAppState, Platform, LayoutAnimation, TouchableWithoutFeedback, } from 'react-native'; import { updateCalendarQueryActionTypes, updateCalendarQuery, } from 'lib/actions/entry-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import type { EntryInfo, CalendarQuery, CalendarQueryUpdateResult, } from 'lib/types/entry-types.js'; import type { CalendarFilter } from 'lib/types/filter-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ConnectionStatus } from 'lib/types/socket-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import { dateString, prettyDate, dateFromString, } from 'lib/utils/date-utils.js'; import sleep from 'lib/utils/sleep.js'; import CalendarInputBar from './calendar-input-bar.react.js'; import { Entry, InternalEntry, dummyNodeForEntryHeightMeasurement, } from './entry.react.js'; import SectionFooter from './section-footer.react.js'; import ContentLoading from '../components/content-loading.react.js'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import NodeHeightMeasurer from '../components/node-height-measurer.react.js'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard.js'; import DisconnectedBar from '../navigation/disconnected-bar.react.js'; import { createIsForegroundSelector, createActiveTabSelector, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { CalendarRouteName, ThreadPickerModalRouteName, } from '../navigation/route-names.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { calendarListData } from '../selectors/calendar-selectors.js'; import type { CalendarItem, SectionHeaderItem, SectionFooterItem, LoaderItem, } from '../selectors/calendar-selectors.js'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors.js'; import { useColors, useStyles, useIndicatorStyle, type Colors, type IndicatorStyle, } from '../themes/colors.js'; import type { EventSubscription, ScrollEvent, ViewableItemsChange, KeyboardEvent, } from '../types/react-native.js'; export type EntryInfoWithHeight = { ...EntryInfo, +textHeight: number, }; type CalendarItemWithHeight = | LoaderItem | SectionHeaderItem | SectionFooterItem | { itemType: 'entryInfo', entryInfo: EntryInfoWithHeight, threadInfo: ThreadInfo, }; type ExtraData = { +activeEntries: { +[key: string]: boolean }, +visibleEntries: { +[key: string]: boolean }, }; type BaseProps = { +navigation: TabNavigationProp<'Calendar'>, +route: NavigationRoute<'Calendar'>, }; type Props = { ...BaseProps, // Nav state +calendarActive: boolean, // Redux state +listData: ?$ReadOnlyArray, +startDate: string, +endDate: string, +calendarFilters: $ReadOnlyArray, +dimensions: DerivedDimensionsInfo, +loadingStatus: LoadingStatus, +connectionStatus: ConnectionStatus, +colors: Colors, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, }; type State = { +listDataWithHeights: ?$ReadOnlyArray, +readyToShowList: boolean, +extraData: ExtraData, +currentlyEditing: $ReadOnlyArray, }; class Calendar extends React.PureComponent { flatList: ?FlatList = null; currentState: ?string = NativeAppState.currentState; appStateListener: ?EventSubscription; lastForegrounded = 0; lastCalendarReset = 0; currentScrollPosition: ?number = null; // We don't always want an extraData update to trigger a state update, so we // cache the most recent value as a member here latestExtraData: ExtraData; // For some reason, we have to delay the scrollToToday call after the first // scroll upwards firstScrollComplete = false; // When an entry becomes active, we make a note of its key so that once the // keyboard event happens, we know where to move the scrollPos to lastEntryKeyActive: ?string = null; keyboardShowListener: ?EventSubscription; keyboardDismissListener: ?EventSubscription; keyboardShownHeight: ?number = null; // If the query fails, we try it again topLoadingFromScroll: ?CalendarQuery = null; bottomLoadingFromScroll: ?CalendarQuery = null; // We wait until the loaders leave view before letting them be triggered again topLoaderWaitingToLeaveView = true; bottomLoaderWaitingToLeaveView = true; // We keep refs to the entries so CalendarInputBar can save them entryRefs = new Map(); constructor(props: Props) { super(props); this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.state = { listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, currentlyEditing: [], }; } componentDidMount() { this.appStateListener = NativeAppState.addEventListener( 'change', this.handleAppStateChange, ); this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); this.props.navigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { if (this.appStateListener) { this.appStateListener.remove(); this.appStateListener = null; } if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } this.props.navigation.removeListener('tabPress', this.onTabPress); } handleAppStateChange = (nextAppState: ?string) => { const lastState = this.currentState; this.currentState = nextAppState; if ( !lastState || !lastState.match(/inactive|background/) || this.currentState !== 'active' ) { // We're only handling foregrounding here return; } if (Date.now() - this.lastCalendarReset < 500) { // If the calendar got reset right before this callback triggered, that // indicates we should reset the scroll position this.lastCalendarReset = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that the calendar is about to get reset. We // record a timestamp here so we can scrollToToday there. this.lastForegrounded = Date.now(); } }; onTabPress = () => { if (this.props.navigation.isFocused()) { this.scrollToToday(); } }; componentDidUpdate(prevProps: Props, prevState: State) { if (!this.props.listData && this.props.listData !== prevProps.listData) { this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.setState({ listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, }); this.firstScrollComplete = false; this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; } const { loadingStatus, connectionStatus } = this.props; const { loadingStatus: prevLoadingStatus, connectionStatus: prevConnectionStatus, } = prevProps; if ( (loadingStatus === 'error' && prevLoadingStatus === 'loading') || (connectionStatus === 'connected' && prevConnectionStatus !== 'connected') ) { this.loadMoreAbove(); this.loadMoreBelow(); } const lastLDWH = prevState.listDataWithHeights; const newLDWH = this.state.listDataWithHeights; if (!newLDWH) { return; } else if (!lastLDWH) { if (!this.props.calendarActive) { // FlatList has an initialScrollIndex prop, which is usually close to // centering but can be off when there is a particularly large Entry in // the list. scrollToToday lets us actually center, but gets overriden // by initialScrollIndex if we call it right after the FlatList mounts sleep(50).then(() => this.scrollToToday()); } return; } if (newLDWH.length < lastLDWH.length) { this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; if (this.flatList) { if (!this.props.calendarActive) { // If the currentCalendarQuery gets reset we scroll to the center this.scrollToToday(); } else if (Date.now() - this.lastForegrounded < 500) { // If the app got foregrounded right before the calendar got reset, // that indicates we should reset the scroll position this.lastForegrounded = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that we got triggered before the // foreground callback. Let's record a timestamp here so we can call // scrollToToday there this.lastCalendarReset = Date.now(); } } } const { lastStartDate, newStartDate, lastEndDate, newEndDate } = Calendar.datesFromListData(lastLDWH, newLDWH); if (newStartDate > lastStartDate || newEndDate < lastEndDate) { // If there are fewer items in our new data, which happens when the // current calendar query gets reset due to inactivity, let's reset the // scroll position to the center (today) if (!this.props.calendarActive) { sleep(50).then(() => this.scrollToToday()); } this.firstScrollComplete = false; } else if (newStartDate < lastStartDate) { this.updateScrollPositionAfterPrepend(lastLDWH, newLDWH); } else if (newEndDate > lastEndDate) { this.firstScrollComplete = true; } else if (newLDWH.length > lastLDWH.length) { LayoutAnimation.easeInEaseOut(); } if (newStartDate < lastStartDate) { this.topLoadingFromScroll = null; } if (newEndDate > lastEndDate) { this.bottomLoadingFromScroll = null; } const { keyboardShownHeight, lastEntryKeyActive } = this; if (keyboardShownHeight && lastEntryKeyActive) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } } static datesFromListData( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const lastSecondItem = lastLDWH[1]; const newSecondItem = newLDWH[1]; invariant( newSecondItem.itemType === 'header' && lastSecondItem.itemType === 'header', 'second item in listData should be a header', ); const lastStartDate = dateFromString(lastSecondItem.dateString); const newStartDate = dateFromString(newSecondItem.dateString); const lastPenultimateItem = lastLDWH[lastLDWH.length - 2]; const newPenultimateItem = newLDWH[newLDWH.length - 2]; invariant( newPenultimateItem.itemType === 'footer' && lastPenultimateItem.itemType === 'footer', 'penultimate item in listData should be a footer', ); const lastEndDate = dateFromString(lastPenultimateItem.dateString); const newEndDate = dateFromString(newPenultimateItem.dateString); return { lastStartDate, newStartDate, lastEndDate, newEndDate }; } /** * When prepending list items, FlatList isn't smart about preserving scroll * position. If we're at the start of the list before prepending, FlatList * will just keep us at the front after prepending. But we want to preserve * the previous on-screen items, so we have to do a calculation to get the new * scroll position. (And deal with the inherent glitchiness of trying to time * that change with the items getting prepended... *sigh*.) */ updateScrollPositionAfterPrepend( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const existingKeys = new Set(_map(Calendar.keyExtractor)(lastLDWH)); const newItems = _filter( (item: CalendarItemWithHeight) => !existingKeys.has(Calendar.keyExtractor(item)), )(newLDWH); const heightOfNewItems = Calendar.heightOfItems(newItems); const flatList = this.flatList; invariant(flatList, 'flatList should be set'); const scrollAction = () => { invariant( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null, 'currentScrollPosition should be set', ); const currentScrollPosition = Math.max(this.currentScrollPosition, 0); const offset = currentScrollPosition + heightOfNewItems; flatList.scrollToOffset({ offset, animated: false, }); }; scrollAction(); if (!this.firstScrollComplete) { setTimeout(scrollAction, 0); this.firstScrollComplete = true; } } scrollToToday(animated: ?boolean = undefined) { if (animated === undefined) { animated = this.props.calendarActive; } const ldwh = this.state.listDataWithHeights; if (!ldwh) { return; } const todayIndex = _findIndex(['dateString', dateString(new Date())])(ldwh); invariant(this.flatList, "scrollToToday called, but flatList isn't set"); this.flatList.scrollToIndex({ index: todayIndex, animated, viewPosition: 0.5, }); } + // ESLint doesn't recognize that invariant always throws + // eslint-disable-next-line consistent-return renderItem = (row: { item: CalendarItemWithHeight, ... }) => { const item = row.item; if (item.itemType === 'loader') { return ; } else if (item.itemType === 'header') { return this.renderSectionHeader(item); } else if (item.itemType === 'entryInfo') { const key = entryKey(item.entryInfo); return ( ); } else if (item.itemType === 'footer') { return this.renderSectionFooter(item); } invariant(false, 'renderItem conditions should be exhaustive'); }; renderSectionHeader = (item: SectionHeaderItem) => { let date = prettyDate(item.dateString); if (dateString(new Date()) === item.dateString) { date += ' (today)'; } const dateObj = dateFromString(item.dateString).getDay(); const weekendStyle = dateObj === 0 || dateObj === 6 ? this.props.styles.weekendSectionHeader : null; return ( {date} ); }; renderSectionFooter = (item: SectionFooterItem) => { return ( ); }; onAdd = (dayString: string) => { this.props.navigation.navigate(ThreadPickerModalRouteName, { presentedFrom: this.props.route.key, dateString: dayString, }); }; + // ESLint doesn't recognize that invariant always throws + // eslint-disable-next-line consistent-return static keyExtractor = (item: CalendarItemWithHeight | CalendarItem) => { if (item.itemType === 'loader') { return item.key; } else if (item.itemType === 'header') { return item.dateString + '/header'; } else if (item.itemType === 'entryInfo') { return entryKey(item.entryInfo); } else if (item.itemType === 'footer') { return item.dateString + '/footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); }; static getItemLayout = ( data: ?$ReadOnlyArray, index: number, ) => { if (!data) { return { length: 0, offset: 0, index }; } const offset = Calendar.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? Calendar.itemHeight(item) : 0; return { length, offset, index }; }; + // ESLint doesn't recognize that invariant always throws + // eslint-disable-next-line consistent-return static itemHeight = (item: CalendarItemWithHeight) => { if (item.itemType === 'loader') { return 56; } else if (item.itemType === 'header') { return 31; } else if (item.itemType === 'entryInfo') { const verticalPadding = 10; return verticalPadding + item.entryInfo.textHeight; } else if (item.itemType === 'footer') { return 40; } invariant(false, 'itemHeight conditions should be exhaustive'); }; static heightOfItems = (data: $ReadOnlyArray) => { return _sum(data.map(Calendar.itemHeight)); }; render() { const { listDataWithHeights } = this.state; let flatList = null; if (listDataWithHeights) { const flatListStyle = { opacity: this.state.readyToShowList ? 1 : 0 }; const initialScrollIndex = this.initialScrollIndex(listDataWithHeights); flatList = ( ); } let loadingIndicator = null; if (!listDataWithHeights || !this.state.readyToShowList) { loadingIndicator = ( ); } const disableInputBar = this.state.currentlyEditing.length === 0; return ( <> {loadingIndicator} {flatList} ); } flatListHeight() { const { safeAreaHeight, tabBarHeight } = this.props.dimensions; return safeAreaHeight - tabBarHeight; } initialScrollIndex(data: $ReadOnlyArray) { const todayIndex = _findIndex(['dateString', dateString(new Date())])(data); const heightOfTodayHeader = Calendar.itemHeight(data[todayIndex]); let returnIndex = todayIndex; let heightLeft = (this.flatListHeight() - heightOfTodayHeader) / 2; while (heightLeft > 0) { heightLeft -= Calendar.itemHeight(data[--returnIndex]); } return returnIndex; } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; entryRef = (inEntryKey: string, entry: ?InternalEntry) => { this.entryRefs.set(inEntryKey, entry); }; makeAllEntriesInactive = () => { if (_size(this.state.extraData.activeEntries) === 0) { if (_size(this.latestExtraData.activeEntries) !== 0) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); }; makeActive = (key: string, active: boolean) => { if (!active) { const activeKeys = Object.keys(this.latestExtraData.activeEntries); if (activeKeys.length === 0) { if (Object.keys(this.state.extraData.activeEntries).length !== 0) { this.setState({ extraData: this.latestExtraData }); } return; } const activeKey = activeKeys[0]; if (activeKey === key) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); } return; } if ( _size(this.state.extraData.activeEntries) === 1 && this.state.extraData.activeEntries[key] ) { if ( _size(this.latestExtraData.activeEntries) !== 1 || !this.latestExtraData.activeEntries[key] ) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: { [key]: true }, }; this.setState({ extraData: this.latestExtraData }); }; onEnterEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const keyboardShownHeight = this.keyboardShownHeight; if (keyboardShownHeight && this.state.listDataWithHeights) { this.scrollToKey(key, keyboardShownHeight); } else { this.lastEntryKeyActive = key; } const newCurrentlyEditing = [ ...new Set([...this.state.currentlyEditing, key]), ]; if (newCurrentlyEditing.length > this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; onConcludeEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const newCurrentlyEditing = this.state.currentlyEditing.filter( k => k !== key, ); if (newCurrentlyEditing.length < this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; keyboardShow = (event: KeyboardEvent) => { // flatListHeight() factors in the size of the tab bar, // but it is hidden by the keyboard since it is at the bottom const { bottomInset, tabBarHeight } = this.props.dimensions; const inputBarHeight = Platform.OS === 'android' ? 37.7 : 35.5; const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max(event.endCoordinates.height - bottomInset, 0), }); const keyboardShownHeight = inputBarHeight + Math.max(keyboardHeight - tabBarHeight, 0); this.keyboardShownHeight = keyboardShownHeight; const lastEntryKeyActive = this.lastEntryKeyActive; if (lastEntryKeyActive && this.state.listDataWithHeights) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } }; keyboardDismiss = () => { this.keyboardShownHeight = null; }; scrollToKey(lastEntryKeyActive: string, keyboardHeight: number) { const data = this.state.listDataWithHeights; invariant(data, 'should be set'); const index = data.findIndex( (item: CalendarItemWithHeight) => Calendar.keyExtractor(item) === lastEntryKeyActive, ); if (index === -1) { return; } const itemStart = Calendar.heightOfItems(data.filter((_, i) => i < index)); const itemHeight = Calendar.itemHeight(data[index]); const entryAdditionalActiveHeight = Platform.OS === 'android' ? 21 : 20; const itemEnd = itemStart + itemHeight + entryAdditionalActiveHeight; const visibleHeight = this.flatListHeight() - keyboardHeight; if ( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null && itemStart > this.currentScrollPosition && itemEnd < this.currentScrollPosition + visibleHeight ) { return; } const offset = itemStart - (visibleHeight - itemHeight) / 2; invariant(this.flatList, 'flatList should be set'); this.flatList.scrollToOffset({ offset, animated: true }); } heightMeasurerKey = (item: CalendarItem) => { if (item.itemType !== 'entryInfo') { return null; } return item.entryInfo.text; }; heightMeasurerDummy = (item: CalendarItem) => { invariant( item.itemType === 'entryInfo', 'NodeHeightMeasurer asked for dummy for non-entryInfo item', ); return dummyNodeForEntryHeightMeasurement(item.entryInfo.text); }; heightMeasurerMergeItem = (item: CalendarItem, height: ?number) => { if (item.itemType !== 'entryInfo') { return item; } invariant(height !== null && height !== undefined, 'height should be set'); const { entryInfo } = item; return { itemType: 'entryInfo', entryInfo: Calendar.entryInfoWithHeight(entryInfo, height), threadInfo: item.threadInfo, }; }; static entryInfoWithHeight( entryInfo: EntryInfo, textHeight: number, ): EntryInfoWithHeight { // Blame Flow for not accepting object spread on exact types if (entryInfo.id && entryInfo.localID) { return { id: entryInfo.id, localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else if (entryInfo.id) { return { id: entryInfo.id, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else { return { localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; onViewableItemsChanged = (info: ViewableItemsChange) => { const ldwh = this.state.listDataWithHeights; if (!ldwh) { // This indicates the listData was cleared (set to null) right before this // callback was called. Since this leads to the FlatList getting cleared, // we'll just ignore this callback. return; } const visibleEntries = {}; for (const token of info.viewableItems) { if (token.item.itemType === 'entryInfo') { visibleEntries[entryKey(token.item.entryInfo)] = true; } } this.latestExtraData = { activeEntries: _pickBy((_, key: string) => { if (visibleEntries[key]) { return true; } // We don't automatically set scrolled-away entries to be inactive // because entries can be out-of-view at creation time if they need to // be scrolled into view (see onEnterEntryEditMode). If Entry could // distinguish the reasons its active prop gets set to false, it could // differentiate the out-of-view case from the something-pressed case, // and then we could set scrolled-away entries to be inactive without // worrying about this edge case. Until then... const foundItem = _find( item => item.entryInfo && entryKey(item.entryInfo) === key, )(ldwh); return !!foundItem; })(this.latestExtraData.activeEntries), visibleEntries, }; const topLoader = _find({ key: 'TopLoader' })(info.viewableItems); if (this.topLoaderWaitingToLeaveView && !topLoader) { this.topLoaderWaitingToLeaveView = false; this.topLoadingFromScroll = null; } const bottomLoader = _find({ key: 'BottomLoader' })(info.viewableItems); if (this.bottomLoaderWaitingToLeaveView && !bottomLoader) { this.bottomLoaderWaitingToLeaveView = false; this.bottomLoadingFromScroll = null; } if ( !this.state.readyToShowList && !this.topLoaderWaitingToLeaveView && !this.bottomLoaderWaitingToLeaveView && info.viewableItems.length > 0 ) { this.setState({ readyToShowList: true, extraData: this.latestExtraData, }); } if ( topLoader && !this.topLoaderWaitingToLeaveView && !this.topLoadingFromScroll ) { this.topLoaderWaitingToLeaveView = true; const start = dateFromString(this.props.startDate); start.setDate(start.getDate() - 31); const startDate = dateString(start); const endDate = this.props.endDate; this.topLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreAbove(); } else if ( bottomLoader && !this.bottomLoaderWaitingToLeaveView && !this.bottomLoadingFromScroll ) { this.bottomLoaderWaitingToLeaveView = true; const end = dateFromString(this.props.endDate); end.setDate(end.getDate() + 31); const endDate = dateString(end); const startDate = this.props.startDate; this.bottomLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreBelow(); } }; dispatchCalendarQueryUpdate(calendarQuery: CalendarQuery) { this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(calendarQuery), ); } loadMoreAbove = _throttle(() => { if ( this.topLoadingFromScroll && this.topLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.topLoadingFromScroll); } }, 1000); loadMoreBelow = _throttle(() => { if ( this.bottomLoadingFromScroll && this.bottomLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.bottomLoadingFromScroll); } }, 1000); onScroll = (event: ScrollEvent) => { this.currentScrollPosition = event.nativeEvent.contentOffset.y; }; // When the user "flicks" the scroll view, this callback gets triggered after // the scrolling ends onMomentumScrollEnd = () => { this.setState({ extraData: this.latestExtraData }); }; // This callback gets triggered when the user lets go of scrolling the scroll // view, regardless of whether it was a "flick" or a pan onScrollEndDrag = () => { // We need to figure out if this was a flick or not. If it's a flick, we'll // let onMomentumScrollEnd handle it once scroll position stabilizes const currentScrollPosition = this.currentScrollPosition; setTimeout(() => { if (this.currentScrollPosition === currentScrollPosition) { this.setState({ extraData: this.latestExtraData }); } }, 50); }; onSaveEntry = () => { const entryKeys = Object.keys(this.latestExtraData.activeEntries); if (entryKeys.length === 0) { return; } const entryRef = this.entryRefs.get(entryKeys[0]); if (entryRef) { entryRef.completeEdit(); } }; } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, flatList: { backgroundColor: 'listBackground', flex: 1, }, keyboardAvoidingViewContainer: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, }, keyboardAvoidingView: { position: 'absolute', left: 0, right: 0, bottom: 0, }, sectionHeader: { backgroundColor: 'panelSecondaryForeground', borderBottomWidth: 2, borderColor: 'listBackground', height: 31, }, sectionHeaderText: { color: 'listSeparatorLabel', fontWeight: 'bold', padding: 5, }, weekendSectionHeader: {}, }; const loadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const activeTabSelector = createActiveTabSelector(CalendarRouteName); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const ConnectedCalendar: React.ComponentType = React.memo( function ConnectedCalendar(props: BaseProps) { const navContext = React.useContext(NavContext); const calendarActive = activeTabSelector(navContext) || activeThreadPickerSelector(navContext); const listData = useSelector(calendarListData); const startDate = useSelector(state => state.navInfo.startDate); const endDate = useSelector(state => state.navInfo.endDate); const calendarFilters = useSelector(state => state.calendarFilters); const dimensions = useSelector(derivedDimensionsInfoSelector); const loadingStatus = useSelector(loadingStatusSelector); const connectionStatus = useSelector(state => state.connection.status); const colors = useColors(); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); return ( ); }, ); export default ConnectedCalendar; diff --git a/native/chat/chat-item-height-measurer.react.js b/native/chat/chat-item-height-measurer.react.js index 159ee1ac4..5d5612398 100644 --- a/native/chat/chat-item-height-measurer.react.js +++ b/native/chat/chat-item-height-measurer.react.js @@ -1,183 +1,185 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { messageID } from 'lib/shared/message-utils.js'; import { messageTypes, type MessageType } from 'lib/types/message-types.js'; import { entityTextToRawString } from 'lib/utils/entity-text.js'; import type { MeasurementTask } from './chat-context-provider.react.js'; import { useComposedMessageMaxWidth } from './composed-message-width.js'; import { dummyNodeForRobotextMessageHeightMeasurement } from './inner-robotext-message.react.js'; import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react.js'; import type { NativeChatMessageItem } from './message-data.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import { multimediaMessageContentSizes } from './multimedia-message-utils.js'; import { chatMessageItemKey } from './utils.js'; import NodeHeightMeasurer from '../components/node-height-measurer.react.js'; import { InputStateContext } from '../input/input-state.js'; type Props = { +measurement: MeasurementTask, }; const heightMeasurerKey = (item: NativeChatMessageItem) => { if (item.itemType !== 'message') { return null; } const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return JSON.stringify({ text: messageInfo.text }); } else if (item.robotext) { const { threadID } = item.messageInfo; return JSON.stringify({ robotext: entityTextToRawString(item.robotext, { threadID }), }); } return null; }; +// ESLint doesn't recognize that invariant always throws +// eslint-disable-next-line consistent-return const heightMeasurerDummy = (item: NativeChatMessageItem) => { invariant( item.itemType === 'message', 'NodeHeightMeasurer asked for dummy for non-message item', ); const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return dummyNodeForTextMessageHeightMeasurement(messageInfo.text); } else if (item.robotext) { return dummyNodeForRobotextMessageHeightMeasurement( item.robotext, item.messageInfo.threadID, ); } invariant(false, 'NodeHeightMeasurer asked for dummy for non-text message'); }; function ChatItemHeightMeasurer(props: Props) { const composedMessageMaxWidth = useComposedMessageMaxWidth(); const inputState = React.useContext(InputStateContext); const inputStatePendingUploads = inputState?.pendingUploads; const { measurement } = props; const { threadInfo } = measurement; const heightMeasurerMergeItem = React.useCallback( (item: NativeChatMessageItem, height: ?number) => { if (item.itemType !== 'message') { return item; } const { messageInfo } = item; const messageType: MessageType = messageInfo.type; invariant( messageType !== messageTypes.SIDEBAR_SOURCE, 'Sidebar source messages should be replaced by sourceMessage before being measured', ); if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; const id = messageID(messageInfo); const pendingUploads = inputStatePendingUploads?.[id]; const sizes = multimediaMessageContentSizes( messageInfo, composedMessageMaxWidth, ); return { itemType: 'message', messageShapeType: 'multimedia', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, threadCreatedFromMessage: item.threadCreatedFromMessage, pendingUploads, reactions: item.reactions, hasBeenEdited: item.hasBeenEdited, ...sizes, }; } invariant( height !== null && height !== undefined, 'height should be set', ); if (messageInfo.type === messageTypes.TEXT) { // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; return { itemType: 'message', messageShapeType: 'text', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, threadCreatedFromMessage: item.threadCreatedFromMessage, contentHeight: height, reactions: item.reactions, hasBeenEdited: item.hasBeenEdited, }; } invariant( item.messageInfoType !== 'composable', 'ChatItemHeightMeasurer was handed a messageInfoType=composable, but ' + `does not know how to handle MessageType ${messageInfo.type}`, ); invariant( item.messageInfoType === 'robotext', 'ChatItemHeightMeasurer was handed a messageInfoType that it does ' + `not recognize: ${item.messageInfoType}`, ); return { itemType: 'message', messageShapeType: 'robotext', messageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, threadCreatedFromMessage: item.threadCreatedFromMessage, robotext: item.robotext, contentHeight: height, reactions: item.reactions, }; }, [composedMessageMaxWidth, inputStatePendingUploads, threadInfo], ); return ( ); } const MemoizedChatItemHeightMeasurer: React.ComponentType = React.memo(ChatItemHeightMeasurer); export default MemoizedChatItemHeightMeasurer; diff --git a/native/chat/inline-engagement.react.js b/native/chat/inline-engagement.react.js index fb9108cda..4b0c51e7b 100644 --- a/native/chat/inline-engagement.react.js +++ b/native/chat/inline-engagement.react.js @@ -1,342 +1,349 @@ // @flow import { useNavigation } from '@react-navigation/native'; +import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import Animated, { Extrapolate, interpolateNode, } from 'react-native-reanimated'; import useInlineEngagementText from 'lib/hooks/inline-engagement-text.react.js'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { stringForReactionList } from 'lib/shared/reaction-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { inlineEngagementLabelStyle, inlineEngagementStyle, inlineEngagementCenterStyle, inlineEngagementRightStyle, inlineEngagementLeftStyle, composedMessageStyle, avatarOffset, } from './chat-constants.js'; import { useNavigateToThread } from './message-list-types.js'; import CommIcon from '../components/comm-icon.react.js'; import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react.js'; import { MessageReactionsModalRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; type Props = { +threadInfo: ?ThreadInfo, +reactions?: ReactionInfo, +disabled?: boolean, +positioning?: 'left' | 'right', +label?: ?string, +shouldRenderAvatars?: boolean, }; function InlineEngagement(props: Props): React.Node { const { disabled = false, reactions, threadInfo, positioning, shouldRenderAvatars, label, } = props; const repliesText = useInlineEngagementText(threadInfo); const navigateToThread = useNavigateToThread(); const { navigate } = useNavigation(); const styles = useStyles(unboundStyles); const unreadStyle = threadInfo?.currentUser.unread ? styles.unread : null; const repliesStyles = React.useMemo( () => [styles.repliesText, unreadStyle], [styles.repliesText, unreadStyle], ); const onPressThread = React.useCallback(() => { if (threadInfo && !disabled) { navigateToThread({ threadInfo }); } }, [disabled, navigateToThread, threadInfo]); const sidebarItem = React.useMemo(() => { if (!threadInfo) { return null; } return ( {repliesText} ); }, [ threadInfo, onPressThread, styles.sidebar, styles.icon, repliesStyles, repliesText, ]); const onPressReactions = React.useCallback(() => { navigate<'MessageReactionsModal'>({ name: MessageReactionsModalRouteName, params: { reactions }, }); }, [navigate, reactions]); const marginLeft = React.useMemo( () => (sidebarItem ? styles.reactionMarginLeft : null), [sidebarItem, styles.reactionMarginLeft], ); const reactionList = React.useMemo(() => { if (!reactions || Object.keys(reactions).length === 0) { return null; } const reactionText = stringForReactionList(reactions); const reactionItems = {reactionText}; return ( {reactionItems} ); }, [ marginLeft, onPressReactions, reactions, styles.reaction, styles.reactionsContainer, ]); const isLeft = positioning === 'left'; const editedLabel = React.useMemo(() => { if (!label) { return null; } const labelLeftRight = isLeft ? styles.messageLabelLeft : styles.messageLabelRight; return {label}; }, [isLeft, label, styles]); const container = React.useMemo(() => { if (!sidebarItem && !reactionList) { return null; } return ( {sidebarItem} {reactionList} ); }, [reactionList, sidebarItem, styles.container]); const inlineEngagementPositionStyle = [styles.inlineEngagement]; if (isLeft) { inlineEngagementPositionStyle.push(styles.leftInlineEngagement); } else { inlineEngagementPositionStyle.push(styles.rightInlineEngagement); } if (shouldRenderAvatars) { inlineEngagementPositionStyle.push({ marginLeft: avatarOffset }); } let body; if (isLeft) { body = ( <> {editedLabel} {container} ); } else { body = ( <> {container} {editedLabel} ); } return {body}; } const unboundStyles = { container: { flexDirection: 'row', height: inlineEngagementStyle.height, borderRadius: 16, backgroundColor: 'inlineEngagementBackground', alignSelf: 'baseline', alignItems: 'center', padding: 8, }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, rightInlineEngagement: { alignSelf: 'flex-end', position: 'relative', right: inlineEngagementRightStyle.marginRight, top: inlineEngagementRightStyle.topOffset, }, leftInlineEngagement: { justifyContent: 'flex-start', position: 'relative', top: inlineEngagementLeftStyle.topOffset, }, sidebar: { flexDirection: 'row', alignItems: 'center', }, inlineEngagement: { flexDirection: 'row', marginBottom: inlineEngagementStyle.marginBottom, marginTop: inlineEngagementStyle.marginTop, alignItems: 'center', }, icon: { color: 'inlineEngagementLabel', marginRight: 4, }, repliesText: { color: 'inlineEngagementLabel', fontSize: 14, lineHeight: 22, }, reaction: { color: 'inlineEngagementLabel', fontSize: 14, lineHeight: 22, }, reactionMarginLeft: { marginLeft: 12, }, reactionsContainer: { display: 'flex', flexDirection: 'row', alignItems: 'center', }, messageLabel: { color: 'messageLabel', paddingHorizontal: 3, fontSize: 13, top: inlineEngagementLabelStyle.topOffset, height: inlineEngagementLabelStyle.height, }, messageLabelLeft: { marginLeft: 9, marginRight: 4, }, messageLabelRight: { marginRight: 10, marginLeft: 4, }, avatarOffset: { width: avatarOffset, }, }; type TooltipInlineEngagementProps = { +item: ChatMessageInfoItemWithHeight, +isOpeningSidebar: boolean, +progress: Animated.Node, +windowWidth: number, +positioning: 'left' | 'right' | 'center', +initialCoordinates: { +x: number, +y: number, +width: number, +height: number, }, }; function TooltipInlineEngagement( props: TooltipInlineEngagementProps, ): React.Node { const { item, isOpeningSidebar, progress, windowWidth, initialCoordinates, positioning, } = props; + // ESLint doesn't recognize that invariant always throws + // eslint-disable-next-line consistent-return const inlineEngagementStyles = React.useMemo(() => { if (positioning === 'left') { return { position: 'absolute', top: inlineEngagementStyle.marginTop + inlineEngagementLeftStyle.topOffset, left: composedMessageStyle.marginLeft, }; } else if (positioning === 'right') { return { position: 'absolute', right: inlineEngagementRightStyle.marginRight + composedMessageStyle.marginRight, top: inlineEngagementStyle.marginTop + inlineEngagementRightStyle.topOffset, }; } else if (positioning === 'center') { return { alignSelf: 'center', top: inlineEngagementCenterStyle.topOffset, }; } + invariant( + false, + `${positioning} is not a valid positioning value for InlineEngagement`, + ); }, [positioning]); const inlineEngagementContainer = React.useMemo(() => { const opacity = isOpeningSidebar ? 0 : interpolateNode(progress, { inputRange: [0, 1], outputRange: [1, 0], extrapolate: Extrapolate.CLAMP, }); return { position: 'absolute', width: windowWidth, top: initialCoordinates.height, left: -initialCoordinates.x, opacity, }; }, [ initialCoordinates.height, initialCoordinates.x, isOpeningSidebar, progress, windowWidth, ]); return ( ); } export { InlineEngagement, TooltipInlineEngagement }; diff --git a/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js index 7c4d04003..8c9ca8f04 100644 --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -1,375 +1,375 @@ // @flow import { useNavigationState } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import genesis from 'lib/facts/genesis.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems } from 'lib/shared/search-utils.js'; import { useExistingThreadInfoFinder, pendingThreadType, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import { type MessagesMeasurer, useHeightMeasurer } from './chat-context.js'; import { ChatInputBar } from './chat-input-bar.react.js'; import type { ChatNavigationProp } from './chat.react.js'; import { type NativeChatMessageItem, useNativeMessageListData, } from './message-data.react.js'; import MessageListThreadSearch from './message-list-thread-search.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import MessageList from './message-list.react.js'; import ParentThreadHeader from './parent-thread-header.react.js'; import ContentLoading from '../components/content-loading.react.js'; import { InputStateContext } from '../input/input-state.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { ThreadSettingsRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; type BaseProps = { +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, // Redux state +usernameInputText: string, +updateUsernameInput: (text: string) => void, +userInfoInputArray: $ReadOnlyArray, +updateTagInput: (items: $ReadOnlyArray) => void, +resolveToUser: (user: AccountUserInfo) => void, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchResults: $ReadOnlyArray, +threadInfo: ThreadInfo, +genesisThreadInfo: ?ThreadInfo, +messageListData: ?$ReadOnlyArray, +colors: Colors, +styles: typeof unboundStyles, // withOverlayContext +overlayContext: ?OverlayContextType, +measureMessages: MessagesMeasurer, }; type State = { +listDataWithHeights: ?$ReadOnlyArray, }; class MessageListContainer extends React.PureComponent { state: State = { listDataWithHeights: null, }; pendingListDataWithHeights: ?$ReadOnlyArray; get frozen() { const { overlayContext } = this.props; invariant( overlayContext, 'MessageListContainer should have OverlayContext', ); return overlayContext.scrollBlockingModalStatus !== 'closed'; } setListData = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; componentDidMount() { this.props.measureMessages( this.props.messageListData, this.props.threadInfo, this.setListData, ); } componentDidUpdate(prevProps: Props) { const oldListData = prevProps.messageListData; const newListData = this.props.messageListData; if (!newListData && oldListData) { this.setState({ listDataWithHeights: null }); } if ( oldListData !== newListData || prevProps.threadInfo !== this.props.threadInfo || prevProps.measureMessages !== this.props.measureMessages ) { this.props.measureMessages( newListData, this.props.threadInfo, this.allHeightsMeasured, ); } if (!this.frozen && this.pendingListDataWithHeights) { this.setState({ listDataWithHeights: this.pendingListDataWithHeights }); this.pendingListDataWithHeights = undefined; } } render() { const { threadInfo, styles } = this.props; const { listDataWithHeights } = this.state; const { searching } = this.props.route.params; let searchComponent = null; if (searching) { const { userInfoInputArray, genesisThreadInfo } = this.props; // It's technically possible for the client to be missing the Genesis // ThreadInfo when it first opens up (before the server delivers it) let parentThreadHeader; if (genesisThreadInfo) { parentThreadHeader = ( ); } searchComponent = ( <> {parentThreadHeader} ); } const showMessageList = !searching || this.props.userInfoInputArray.length > 0; let messageList; if (showMessageList && listDataWithHeights) { messageList = ( ); } else if (showMessageList) { messageList = ( ); } const threadContentStyles = showMessageList ? [styles.threadContent] : [styles.hiddenThreadContent]; const pointerEvents = showMessageList ? 'auto' : 'none'; const threadContent = ( {messageList} ); return ( {searchComponent} {threadContent} ); } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { if (this.frozen) { this.pendingListDataWithHeights = listDataWithHeights; } else { this.setState({ listDataWithHeights }); } }; } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, threadContent: { flex: 1, }, hiddenThreadContent: { height: 0, opacity: 0, }, }; const ConnectedMessageListContainer: React.ComponentType = React.memo(function ConnectedMessageListContainer( props: BaseProps, ) { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const userSearchResults = React.useMemo( () => getPotentialMemberItems( usernameInputText, otherUserInfos, userSearchIndex, userInfoInputArray.map(userInfo => userInfo.id), ), [usernameInputText, otherUserInfos, userSearchIndex, userInfoInputArray], ); const [baseThreadInfo, setBaseThreadInfo] = React.useState( props.route.params.threadInfo, ); const existingThreadInfoFinder = useExistingThreadInfoFinder(baseThreadInfo); const isSearching = !!props.route.params.searching; const threadInfo = React.useMemo( () => existingThreadInfoFinder({ searching: isSearching, userInfoInputArray, }), [existingThreadInfoFinder, isSearching, userInfoInputArray], ); invariant( threadInfo, 'threadInfo must be specified in messageListContainer', ); const inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState should be set in MessageListContainer'); const isFocused = props.navigation.isFocused(); const { setPendingThreadUpdateHandler } = inputState; React.useEffect(() => { if (!isFocused) { - return; + return undefined; } setPendingThreadUpdateHandler(threadInfo.id, setBaseThreadInfo); return () => { setPendingThreadUpdateHandler(threadInfo.id, undefined); }; }, [setPendingThreadUpdateHandler, isFocused, threadInfo.id]); const { setParams } = props.navigation; const navigationStack = useNavigationState(state => state.routes); React.useEffect(() => { const topRoute = navigationStack[navigationStack.length - 1]; if (topRoute?.name !== ThreadSettingsRouteName) { return; } setBaseThreadInfo(threadInfo); if (isSearching) { setParams({ searching: false }); } }, [isSearching, navigationStack, setParams, threadInfo]); const hideSearch = React.useCallback(() => { setBaseThreadInfo(threadInfo); setParams({ searching: false }); }, [setParams, threadInfo]); React.useEffect(() => { if (!isSearching) { - return; + return undefined; } inputState.registerSendCallback(hideSearch); return () => inputState.unregisterSendCallback(hideSearch); }, [hideSearch, inputState, isSearching]); React.useEffect(() => { setParams({ threadInfo }); }, [setParams, threadInfo]); const updateTagInput = React.useCallback( (input: $ReadOnlyArray) => setUserInfoInputArray(input), [], ); const updateUsernameInput = React.useCallback( (text: string) => setUsernameInputText(text), [], ); const { editInputMessage } = inputState; const resolveToUser = React.useCallback( (user: AccountUserInfo) => { const resolvedThreadInfo = existingThreadInfoFinder({ searching: true, userInfoInputArray: [user], }); invariant( resolvedThreadInfo, 'resolvedThreadInfo must be specified in messageListContainer', ); editInputMessage({ message: '', mode: 'prepend' }); setBaseThreadInfo(resolvedThreadInfo); setParams({ searching: false, threadInfo: resolvedThreadInfo }); }, [existingThreadInfoFinder, editInputMessage, setParams], ); const messageListData = useNativeMessageListData({ searching: isSearching, userInfoInputArray, threadInfo, }); const colors = useColors(); const styles = useStyles(unboundStyles); const overlayContext = React.useContext(OverlayContext); const measureMessages = useHeightMeasurer(); const genesisThreadInfo = useSelector( state => threadInfoSelector(state)[genesis.id], ); return ( ); }); export default ConnectedMessageListContainer; diff --git a/native/chat/settings/delete-thread.react.js b/native/chat/settings/delete-thread.react.js index ca0889d5a..c752d3ed4 100644 --- a/native/chat/settings/delete-thread.react.js +++ b/native/chat/settings/delete-thread.react.js @@ -1,270 +1,271 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput as BaseTextInput, Alert, ActivityIndicator, } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { deleteThreadActionTypes, deleteThread, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { identifyInvalidatedThreads } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo, ResolvedThreadInfo, LeaveThreadPayload, } from 'lib/types/thread-types.js'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import Button from '../../components/button.react.js'; import { clearThreadsActionType } from '../../navigation/action-types.js'; import { NavContext, type NavAction, } from '../../navigation/navigation-context.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { GlobalTheme } from '../../types/themes.js'; import type { ChatNavigationProp } from '../chat.react.js'; export type DeleteThreadParams = { +threadInfo: ThreadInfo, }; type BaseProps = { +navigation: ChatNavigationProp<'DeleteThread'>, +route: NavigationRoute<'DeleteThread'>, }; type Props = { ...BaseProps, // Redux state +threadInfo: ResolvedThreadInfo, +loadingStatus: LoadingStatus, +activeTheme: ?GlobalTheme, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +deleteThread: (threadID: string) => Promise, // withNavContext +navDispatch: (action: NavAction) => void, }; class DeleteThread extends React.PureComponent { mounted = false; passwordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } guardedSetState(change, callback) { if (this.mounted) { this.setState(change, callback); } } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete chat ); const { threadInfo } = this.props; return ( {`The chat "${threadInfo.uiName}" will be permanently deleted. `} There is no way to reverse this. ); } passwordInputRef = ( passwordInput: ?React.ElementRef, ) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; submitDeletion = () => { this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThread(), ); }; async deleteThread() { const { threadInfo, navDispatch } = this.props; navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadInfo.id] }, }); try { const result = await this.props.deleteThread(threadInfo.id); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Permission not granted', 'You do not have permission to delete this thread', [{ text: 'OK' }], { cancelable: false }, ); } else { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); } + throw e; } } } const unboundStyles = { deleteButton: { backgroundColor: 'vibrantRedButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, deleteText: { color: 'white', fontSize: 18, textAlign: 'center', }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginBottom: 24, marginHorizontal: 24, textAlign: 'center', }, }; const loadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); const ConnectedDeleteThread: React.ComponentType = React.memo(function ConnectedDeleteThread(props: BaseProps) { const threadID = props.route.params.threadInfo.id; const reduxThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const threadInfo = reduxThreadInfo ?? props.route.params.threadInfo; const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); const loadingStatus = useSelector(loadingStatusSelector); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteThread = useServerCall(deleteThread); const navContext = React.useContext(NavContext); invariant(navContext, 'NavContext should be set in DeleteThread'); const navDispatch = navContext.dispatch; return ( ); }); export default ConnectedDeleteThread; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index eb75042b7..524e8aa7e 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1263 +1,1265 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Platform } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { createSelector } from 'reselect'; import tinycolor from 'tinycolor2'; import { changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions.js'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector, childThreadInfos, } from 'lib/selectors/thread-selectors.js'; import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils.js'; import { threadHasPermission, viewerIsMember, threadInChatList, getSingleOtherUser, threadIsChannel, } from 'lib/shared/thread-utils.js'; import threadWatcher from 'lib/shared/thread-watcher.js'; import type { RelationshipButton } from 'lib/types/relationship-types.js'; import { type ThreadInfo, type ResolvedThreadInfo, type RelativeMemberInfo, threadPermissions, threadTypes, } from 'lib/types/thread-types.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { useResolvedThreadInfo, useResolvedOptionalThreadInfo, useResolvedOptionalThreadInfos, } from 'lib/utils/entity-helpers.js'; import ThreadSettingsAvatar from './thread-settings-avatar.react.js'; import type { CategoryType } from './thread-settings-category.react.js'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryActionHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react.js'; import ThreadSettingsChildThread from './thread-settings-child-thread.react.js'; import ThreadSettingsColor from './thread-settings-color.react.js'; import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react.js'; import ThreadSettingsDescription from './thread-settings-description.react.js'; import ThreadSettingsEditRelationship from './thread-settings-edit-relationship.react.js'; import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react.js'; import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react.js'; import { ThreadSettingsSeeMore, ThreadSettingsAddMember, ThreadSettingsAddSubchannel, } from './thread-settings-list-action.react.js'; import ThreadSettingsMediaGallery from './thread-settings-media-gallery.react.js'; import ThreadSettingsMember from './thread-settings-member.react.js'; import ThreadSettingsName from './thread-settings-name.react.js'; import ThreadSettingsParent from './thread-settings-parent.react.js'; import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react.js'; import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react.js'; import ThreadSettingsVisibility from './thread-settings-visibility.react.js'; import ThreadAncestors from '../../components/thread-ancestors.react.js'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state.js'; import { defaultStackScreenOptions } from '../../navigation/options.js'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context.js'; import { AddUsersModalRouteName, ComposeSubchannelModalRouteName, FullScreenThreadMediaGalleryRouteName, } from '../../navigation/route-names.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import type { TabNavigationProp } from '../../navigation/tab-navigator.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import type { AppState } from '../../redux/state-types.js'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../../themes/colors.js'; import type { VerticalBounds } from '../../types/layout-types.js'; import type { ViewStyle } from '../../types/styles.js'; import { useShouldRenderAvatars } from '../../utils/avatar-utils.js'; import type { ChatNavigationProp } from '../chat.react.js'; const itemPageLength = 5; export type ThreadSettingsParams = { +threadInfo: ThreadInfo, }; export type ThreadSettingsNavigate = $PropertyType< ChatNavigationProp<'ThreadSettings'>, 'navigate', >; type ChatSettingsItem = | { +itemType: 'header', +key: string, +title: string, +categoryType: CategoryType, } | { +itemType: 'actionHeader', +key: string, +title: string, +actionText: string, +onPress: () => void, } | { +itemType: 'footer', +key: string, +categoryType: CategoryType, } | { +itemType: 'avatar', +key: string, +threadInfo: ResolvedThreadInfo, +canChangeSettings: boolean, } | { +itemType: 'name', +key: string, +threadInfo: ResolvedThreadInfo, +nameEditValue: ?string, +canChangeSettings: boolean, } | { +itemType: 'color', +key: string, +threadInfo: ResolvedThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, } | { +itemType: 'description', +key: string, +threadInfo: ResolvedThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, } | { +itemType: 'parent', +key: string, +threadInfo: ResolvedThreadInfo, +parentThreadInfo: ?ResolvedThreadInfo, } | { +itemType: 'visibility', +key: string, +threadInfo: ResolvedThreadInfo, } | { +itemType: 'pushNotifs', +key: string, +threadInfo: ResolvedThreadInfo, } | { +itemType: 'homeNotifs', +key: string, +threadInfo: ResolvedThreadInfo, } | { +itemType: 'seeMore', +key: string, +onPress: () => void, } | { +itemType: 'childThread', +key: string, +threadInfo: ResolvedThreadInfo, +firstListItem: boolean, +lastListItem: boolean, } | { +itemType: 'addSubchannel', +key: string, } | { +itemType: 'member', +key: string, +memberInfo: RelativeMemberInfo, +threadInfo: ResolvedThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, } | { +itemType: 'addMember', +key: string, } | { +itemType: 'mediaGallery', +key: string, +threadInfo: ThreadInfo, +limit: number, +verticalBounds: ?VerticalBounds, } | { +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, } | { +itemType: 'editRelationship', +key: string, +threadInfo: ResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, }; type BaseProps = { +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, }; type Props = { ...BaseProps, // Redux state +userInfos: UserInfos, +viewerID: ?string, +threadInfo: ResolvedThreadInfo, +parentThreadInfo: ?ResolvedThreadInfo, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, +canPromoteSidebar: boolean, +shouldRenderAvatars: boolean, }; type State = { +numMembersShowing: number, +numSubchannelsShowing: number, +numSidebarsShowing: number, +nameEditValue: ?string, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +colorEditValue: string, +verticalBounds: ?VerticalBounds, }; type PropsAndState = { ...Props, ...State }; class ThreadSettings extends React.PureComponent { flatListContainer: ?React.ElementRef; constructor(props: Props) { super(props); this.state = { numMembersShowing: itemPageLength, numSubchannelsShowing: itemPageLength, numSidebarsShowing: itemPageLength, nameEditValue: null, descriptionEditValue: null, descriptionTextHeight: null, colorEditValue: props.threadInfo.color, verticalBounds: null, }; } static scrollDisabled(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'ThreadSettings should have OverlayContext'); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidUpdate(prevProps: Props) { const prevThreadInfo = prevProps.threadInfo; const newThreadInfo = this.props.threadInfo; if ( !tinycolor.equals(newThreadInfo.color, prevThreadInfo.color) && tinycolor.equals(this.state.colorEditValue, prevThreadInfo.color) ) { this.setState({ colorEditValue: newThreadInfo.color }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = ThreadSettings.scrollDisabled(this.props); const scrollWasDisabled = ThreadSettings.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } threadBasicsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.nameEditValue, (propsAndState: PropsAndState) => propsAndState.colorEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionTextHeight, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, ( threadInfo: ResolvedThreadInfo, parentThreadInfo: ?ResolvedThreadInfo, nameEditValue: ?string, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, ) => { const canEditThreadAvatar = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_AVATAR, ); const canEditThreadName = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const canEditThreadDescription = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const canEditThreadColor = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_COLOR, ); const canChangeAvatar = canEditThreadAvatar && canStartEditing; const canChangeName = canEditThreadName && canStartEditing; const canChangeDescription = canEditThreadDescription && canStartEditing; const canChangeColor = canEditThreadColor && canStartEditing; const listData: ChatSettingsItem[] = []; if (this.props.shouldRenderAvatars) { listData.push({ itemType: 'header', key: 'avatarHeader', title: 'Channel Avatar', categoryType: 'unpadded', }); listData.push({ itemType: 'avatar', key: 'avatar', threadInfo, canChangeSettings: canChangeAvatar, }); listData.push({ itemType: 'footer', key: 'avatarFooter', categoryType: 'outline', }); } listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, canChangeSettings: canChangeName, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings: canChangeColor, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThreadDescription ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings: canChangeDescription, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); if (threadInfo.type !== threadTypes.SIDEBAR) { listData.push({ itemType: 'homeNotifs', key: 'homeNotifs', threadInfo, }); } listData.push({ itemType: 'footer', key: 'subscriptionFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, parentThreadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subchannelsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubchannelsShowing, ( threadInfo: ResolvedThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSubchannelsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const subchannels = childThreads?.filter(threadIsChannel) ?? []; const canCreateSubchannels = threadHasPermission( threadInfo, threadPermissions.CREATE_SUBCHANNELS, ); if (subchannels.length === 0 && !canCreateSubchannels) { return listData; } listData.push({ itemType: 'header', key: 'subchannelHeader', title: 'Subchannels', categoryType: 'unpadded', }); if (canCreateSubchannels) { listData.push({ itemType: 'addSubchannel', key: 'addSubchannel', }); } const numItems = Math.min(numSubchannelsShowing, subchannels.length); for (let i = 0; i < numItems; i++) { const subchannelInfo = subchannels[i]; listData.push({ itemType: 'childThread', key: `childThread${subchannelInfo.id}`, threadInfo: subchannelInfo, firstListItem: i === 0 && !canCreateSubchannels, lastListItem: i === numItems - 1 && numItems === subchannels.length, }); } if (numItems < subchannels.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSubchannels', onPress: this.onPressSeeMoreSubchannels, }); } listData.push({ itemType: 'footer', key: 'subchannelFooter', categoryType: 'unpadded', }); return listData; }, ); sidebarsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSidebarsShowing, ( navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSidebarsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const sidebars = childThreads?.filter( childThreadInfo => childThreadInfo.type === threadTypes.SIDEBAR, ) ?? []; if (sidebars.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'sidebarHeader', title: 'Threads', categoryType: 'unpadded', }); const numItems = Math.min(numSidebarsShowing, sidebars.length); for (let i = 0; i < numItems; i++) { const sidebarInfo = sidebars[i]; listData.push({ itemType: 'childThread', key: `childThread${sidebarInfo.id}`, threadInfo: sidebarInfo, firstListItem: i === 0, lastListItem: i === numItems - 1 && numItems === sidebars.length, }); } if (numItems < sidebars.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSidebars', onPress: this.onPressSeeMoreSidebars, }); } listData.push({ itemType: 'footer', key: 'sidebarFooter', categoryType: 'unpadded', }); return listData; }, ); threadMembersListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.numMembersShowing, (propsAndState: PropsAndState) => propsAndState.verticalBounds, ( threadInfo: ResolvedThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, numMembersShowing: number, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); if (threadInfo.members.length === 0 && !canAddMembers) { return listData; } listData.push({ itemType: 'header', key: 'memberHeader', title: 'Members', categoryType: 'unpadded', }); if (canAddMembers) { listData.push({ itemType: 'addMember', key: 'addMember', }); } const numItems = Math.min(numMembersShowing, threadInfo.members.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadInfo.members[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadInfo.members.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadInfo.members.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); mediaGalleryListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.verticalBounds, (threadInfo: ThreadInfo, verticalBounds: ?VerticalBounds) => { const listData: ChatSettingsItem[] = []; const limit = 6; listData.push({ itemType: 'actionHeader', key: 'mediaGalleryHeader', title: 'Media Gallery', actionText: 'See more', onPress: this.onPressSeeMoreMediaGallery, }); listData.push({ itemType: 'mediaGallery', key: 'mediaGallery', threadInfo, limit, verticalBounds, }); listData.push({ itemType: 'footer', key: 'mediaGalleryFooter', categoryType: 'outline', }); return listData; }, ); actionsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.styles, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, ( threadInfo: ResolvedThreadInfo, parentThreadInfo: ?ResolvedThreadInfo, navigate: ThreadSettingsNavigate, styles: typeof unboundStyles, userInfos: UserInfos, viewerID: ?string, ) => { const buttons = []; if (this.props.canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } const canLeaveThread = threadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } const canDeleteThread = threadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } const threadIsPersonal = threadInfo.type === threadTypes.PERSONAL; if (threadIsPersonal && viewerID) { const otherMemberID = getSingleOtherUser(threadInfo, viewerID); if (otherMemberID) { const otherUserInfo = userInfos[otherMemberID]; const availableRelationshipActions = getAvailableRelationshipButtons(otherUserInfo); for (const action of availableRelationshipActions) { buttons.push({ itemType: 'editRelationship', key: action, threadInfo, navigate, relationshipButton: action, }); } } } const listData: ChatSettingsItem[] = []; if (buttons.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'actionsHeader', title: 'Actions', categoryType: 'unpadded', }); for (let i = 0; i < buttons.length; i++) { // Necessary for Flow... if (buttons[i].itemType === 'editRelationship') { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } else { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } } listData.push({ itemType: 'footer', key: 'actionsFooter', categoryType: 'unpadded', }); return listData; }, ); listDataSelector = createSelector( this.threadBasicsListDataSelector, this.subchannelsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.mediaGalleryListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: ChatSettingsItem[], subchannelsListData: ChatSettingsItem[], sidebarsListData: ChatSettingsItem[], threadMembersListData: ChatSettingsItem[], mediaGalleryListData: ChatSettingsItem[], actionsListData: ChatSettingsItem[], ) => [ ...threadBasicsListData, ...subchannelsListData, ...sidebarsListData, ...threadMembersListData, ...mediaGalleryListData, ...actionsListData, ], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { return ( ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }); }; + // ESLint doesn't recognize that invariant always throws + // eslint-disable-next-line consistent-return renderItem = (row: { item: ChatSettingsItem, ... }) => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'actionHeader') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'avatar') { return ( ); } else if (item.itemType === 'name') { return ( ); } else if (item.itemType === 'color') { return ( ); } else if (item.itemType === 'description') { return ( ); } else if (item.itemType === 'parent') { return ( ); } else if (item.itemType === 'visibility') { return ; } else if (item.itemType === 'pushNotifs') { return ; } else if (item.itemType === 'homeNotifs') { return ; } else if (item.itemType === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubchannel') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'mediaGallery') { return ( ); } else if (item.itemType === 'leaveThread') { return ( ); } else if (item.itemType === 'deleteThread') { return ( ); } else if (item.itemType === 'promoteSidebar') { return ( ); } else if (item.itemType === 'editRelationship') { return ( ); } else { invariant(false, `unexpected ThreadSettings item type ${item.itemType}`); } }; setNameEditValue = (value: ?string, callback?: () => void) => { this.setState({ nameEditValue: value }, callback); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubchannel = () => { this.props.navigation.navigate(ComposeSubchannelModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressAddMember = () => { this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressSeeMoreMembers = () => { this.setState(prevState => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubchannels = () => { this.setState(prevState => ({ numSubchannelsShowing: prevState.numSubchannelsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState(prevState => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; onPressSeeMoreMediaGallery = () => { this.props.navigation.navigate(FullScreenThreadMediaGalleryRouteName, { threadInfo: this.props.threadInfo, }); }; } const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; const threadMembersChangeIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { for (const threadMember of threadMembers) { const removeUserLoadingStatus = createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${threadMember.id}`, )(state); if (removeUserLoadingStatus === 'loading') { return true; } const changeRoleLoadingStatus = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${threadMember.id}`, )(state); if (changeRoleLoadingStatus === 'loading') { return true; } } return false; }; const ConnectedThreadSettings: React.ComponentType = React.memo(function ConnectedThreadSettings(props: BaseProps) { const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const threadID = props.route.params.threadInfo.id; const reduxThreadInfo: ?ThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); React.useEffect(() => { invariant( reduxThreadInfo, 'ReduxThreadInfo should exist when ThreadSettings is opened', ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const threadInfo: ThreadInfo = reduxThreadInfo ?? props.route.params.threadInfo; const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); React.useEffect(() => { if (threadInChatList(threadInfo)) { return undefined; } threadWatcher.watchID(threadInfo.id); return () => { threadWatcher.removeID(threadInfo.id); }; }, [threadInfo]); const parentThreadID = threadInfo.parentThreadID; const parentThreadInfo: ?ThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const resolvedParentThreadInfo = useResolvedOptionalThreadInfo(parentThreadInfo); const threadMembers = threadInfo.members; const boundChildThreadInfos = useSelector( state => childThreadInfos(state)[threadID], ); const resolvedChildThreadInfos = useResolvedOptionalThreadInfos( boundChildThreadInfos, ); const somethingIsSaving = useSelector(state => { const editNameLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:name`, )(state); const editColorLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:color`, )(state); const editDescriptionLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:description`, )(state); const leaveThreadLoadingStatus = createLoadingStatusSelector( leaveThreadActionTypes, `${leaveThreadActionTypes.started}:${threadID}`, )(state); const boundThreadMembersChangeIsSaving = threadMembersChangeIsSaving( state, threadMembers, ); return ( boundThreadMembersChangeIsSaving || editNameLoadingStatus === 'loading' || editColorLoadingStatus === 'loading' || editDescriptionLoadingStatus === 'loading' || leaveThreadLoadingStatus === 'loading' ); }); const { navigation } = props; React.useEffect(() => { const tabNavigation: ?TabNavigationProp<'Chat'> = navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); const onTabPress = () => { if (navigation.isFocused() && !somethingIsSaving) { navigation.popToTop(); } }; tabNavigation.addListener('tabPress', onTabPress); return () => tabNavigation.removeListener('tabPress', onTabPress); }, [navigation, somethingIsSaving]); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const { canPromoteSidebar } = usePromoteSidebar(threadInfo); const shouldRenderAvatars = useShouldRenderAvatars(); return ( ); }); export default ConnectedThreadSettings; diff --git a/native/components/keyboard-avoiding-view.react.js b/native/components/keyboard-avoiding-view.react.js index 6e6a3226b..e9783f85e 100644 --- a/native/components/keyboard-avoiding-view.react.js +++ b/native/components/keyboard-avoiding-view.react.js @@ -1,196 +1,198 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Keyboard, Platform, LayoutAnimation, StyleSheet, } from 'react-native'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import type { ScreenRect } from '../keyboard/keyboard.js'; import type { Layout, LayoutEvent, EventSubscription, KeyboardEvent, } from '../types/react-native.js'; import type { ViewStyle } from '../types/styles.js'; type ViewProps = React.ElementConfig; type BaseProps = { ...ViewProps, +behavior: 'height' | 'position' | 'padding', +contentContainerStyle?: ?ViewStyle, }; const KeyboardAvoidingView: React.ComponentType = React.memo(function KeyboardAvoidingView(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); return ( ); }); type Props = { ...BaseProps, // withKeyboardState +keyboardState: ?KeyboardState, }; type State = { +bottom: number, }; class InnerKeyboardAvoidingView extends React.PureComponent { state: State = { bottom: 0, }; subscriptions: EventSubscription[] = []; viewFrame: ?Layout; keyboardFrame: ?ScreenRect; defaultViewFrameHeight = 0; waitingForLayout: Array<() => mixed> = []; componentDidMount() { if (Platform.OS === 'ios') { this.subscriptions.push( Keyboard.addListener('keyboardWillChangeFrame', this.onKeyboardChange), ); } else { this.subscriptions.push( Keyboard.addListener('keyboardDidHide', this.onKeyboardChange), Keyboard.addListener('keyboardDidShow', this.onKeyboardChange), ); } } componentWillUnmount() { for (const subscription of this.subscriptions) { subscription.remove(); } } onKeyboardChange = (event: ?KeyboardEvent) => { if (!event) { this.keyboardFrame = null; this.setState({ bottom: 0 }); return; } if (!this.viewFrame) { this.waitingForLayout.push(() => this.onKeyboardChange(event)); return; } const { duration, easing, endCoordinates } = event; this.keyboardFrame = endCoordinates; const { keyboardState } = this.props; const mediaGalleryOpen = keyboardState && keyboardState.mediaGalleryOpen; if ( Platform.OS === 'android' && mediaGalleryOpen && this.keyboardFrame.height > 0 && this.viewFrame ) { this.viewFrame = { ...this.viewFrame, height: this.defaultViewFrameHeight, }; } const height = this.relativeKeyboardHeight; if (height === this.state.bottom) { return; } this.setState({ bottom: height }); if (duration && easing) { LayoutAnimation.configureNext({ duration: duration > 10 ? duration : 10, update: { duration: duration > 10 ? duration : 10, type: LayoutAnimation.Types[easing] || 'keyboard', }, }); } }; get relativeKeyboardHeight() { const { viewFrame, keyboardFrame } = this; if (!viewFrame || !keyboardFrame) { return 0; } return Math.max(viewFrame.y + viewFrame.height - keyboardFrame.screenY, 0); } onLayout = (event: LayoutEvent) => { this.viewFrame = event.nativeEvent.layout; const { keyboardState } = this.props; const keyboardShowing = keyboardState && keyboardState.keyboardShowing; if (!keyboardShowing) { this.defaultViewFrameHeight = this.viewFrame.height; } for (const callback of this.waitingForLayout) { callback(); } this.waitingForLayout = []; }; + // ESLint doesn't recognize that invariant always throws + // eslint-disable-next-line consistent-return render() { const { behavior, children, contentContainerStyle, style, keyboardState, ...props } = this.props; const { bottom } = this.state; if (behavior === 'height') { let heightStyle; if (this.viewFrame && bottom > 0) { heightStyle = { height: this.defaultViewFrameHeight - bottom, flex: 0, }; } const composedStyle = StyleSheet.compose(style, heightStyle); return ( {children} ); } else if (behavior === 'position') { const composedStyle = StyleSheet.compose(contentContainerStyle, { bottom, }); const { pointerEvents } = props; return ( {children} ); } else if (behavior === 'padding') { const composedStyle = StyleSheet.compose(style, { paddingBottom: bottom, }); return ( {children} ); } invariant(false, `invalid KeyboardAvoidingView behavior ${behavior}`); } } export default KeyboardAvoidingView; diff --git a/native/ios/assign-build.js b/native/ios/assign-build.js index b920dff1d..48f469220 100644 --- a/native/ios/assign-build.js +++ b/native/ios/assign-build.js @@ -1,160 +1,160 @@ // @flow const fs = require('fs'); const { request } = require('gaxios'); const jwt = require('jsonwebtoken'); const IOS_APP_ID = process.env.IOS_APP_ID; const IOS_STAFF_GROUP_ID = process.env.IOS_STAFF_GROUP_ID; const GIT_REF = process.env.GITHUB_REF ?? ""; const GIT_TAG = GIT_REF.replace(/^refs\/tags\/v/, ""); const getAuthToken = () => { const p8 = fs.readFileSync('./AUTH_KEY.p8'); const apiKeyID = process.env.AUTH_KEY_ID; const issuerID = process.env.AUTH_KEY_ISSUER_ID; if (!p8 || !apiKeyID || !issuerID) { console.log('ERROR: credentials needed to generate authToken not found.'); process.exit(1); } const jwtPayload = { iss: issuerID, exp: Math.round(Date.now() / 1000) + 60, aud: "appstoreconnect-v1", }; const jwtSigningConfig = { algorithm: "ES256", header: { alg: "ES256", kid: apiKeyID, typ: "JWT" } }; return jwt.sign(jwtPayload, p8, jwtSigningConfig); }; const getPreReleaseVersions = async (authToken) => { if (!IOS_APP_ID) { console.log('ERROR: No IOS_APP_ID found in env.'); process.exit(1); - return; + return undefined; } const res = await request({ url: `https://api.appstoreconnect.apple.com/v1/apps/${IOS_APP_ID}/preReleaseVersions`, headers: { Authorization: authToken, }, }); if (res.status !== 200) { console.log( "ERROR: GET preReleaseVersions from AppStoreConnect API failed." ); process.exit(1); } return res.data.data; }; const getCurrentVersionInfo = (preReleaseVersions, currentVersion) => { if (!preReleaseVersions) { console.log('ERROR: No preReleaseVersions found.'); process.exit(1); - return; + return undefined; } return preReleaseVersions.filter( (each) => each.attributes.version === currentVersion )[0]; }; const getCurrentVersionID = (currentVersionInfo) => { if (!currentVersionInfo || !currentVersionInfo.id) { console.log('ERROR: currentVersionID could not be determined.'); process.exit(1); - return; + return undefined; } return currentVersionInfo.id; }; const getBuildInfoForVersionID = async (authToken, versionID) => { if (!versionID) { console.log('ERROR: versionID is undefined.'); process.exit(1); - return; + return undefined; } const res = await request({ url: `https://api.appstoreconnect.apple.com/v1/preReleaseVersions/${versionID}/relationships/builds`, headers: { Authorization: authToken, }, }); if (res.status !== 200) { console.log('ERROR: GET currentVersion/builds from AppStoreConnect API failed.'); process.exit(1); } return res.data.data[0]; }; const getBuildID = (buildInfo) => { if (!buildInfo || !buildInfo.id) { console.log('ERROR: buildID could not be determined.'); process.exit(1); - return; + return undefined; } return buildInfo.id; }; const assignBuildToStaffGroup = async (authToken, buildID) => { if (!IOS_STAFF_GROUP_ID) { console.log('ERROR: No IOS_STAFF_GROUP_ID found in env.'); process.exit(1); return; } if (!buildID) { console.log('ERROR: buildID is undefined.'); process.exit(1); return; } const res = await request({ method: 'POST', url: `https://api.appstoreconnect.apple.com/v1/builds/${buildID}/relationships/betaGroups`, headers: { Authorization: authToken, 'Content-Type': 'application/json' }, data: { data: [ { id: IOS_STAFF_GROUP_ID, type: 'betaGroups' } ] } }); if (res.status !== 204) { console.log('ERROR: Unable to assign build to staff group.'); process.exit(1); } }; async function main() { const authToken = getAuthToken(); const preReleaseVersions = await getPreReleaseVersions(authToken); const currentVersionInfo = getCurrentVersionInfo(preReleaseVersions, GIT_TAG); const currentVersionID = getCurrentVersionID(currentVersionInfo); const buildInfo = await getBuildInfoForVersionID(authToken, currentVersionID); const buildID = getBuildID(buildInfo); await assignBuildToStaffGroup(authToken, buildID); } main(); diff --git a/native/media/multimedia.react.js b/native/media/multimedia.react.js index dd47458a9..0b76d5b4f 100644 --- a/native/media/multimedia.react.js +++ b/native/media/multimedia.react.js @@ -1,200 +1,202 @@ // @flow import { Image } from 'expo-image'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import type { MediaInfo, AvatarMediaInfo } from 'lib/types/media-types.js'; import EncryptedImage from './encrypted-image.react.js'; import RemoteImage from './remote-image.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; type Source = | { +kind: 'uri', +uri: string, } | { +kind: 'encrypted', +holder: string, +encryptionKey: string, }; type BaseProps = { +mediaInfo: MediaInfo | AvatarMediaInfo, +spinnerColor: string, }; type Props = { ...BaseProps, // withInputState +inputState: ?InputState, }; type State = { +currentSource: Source, +departingSource: ?Source, }; class Multimedia extends React.PureComponent { static defaultProps = { spinnerColor: 'black', }; constructor(props: Props) { super(props); this.state = { currentSource: Multimedia.sourceFromMediaInfo(props.mediaInfo), departingSource: null, }; } get inputState() { const { inputState } = this.props; invariant(inputState, 'inputState should be set in Multimedia'); return inputState; } componentDidMount() { this.reportSourceDisplayed(this.state.currentSource, true); } componentWillUnmount() { const { currentSource, departingSource } = this.state; this.reportSourceDisplayed(currentSource, false); if (departingSource) { this.reportSourceDisplayed(departingSource, false); } } componentDidUpdate(prevProps: Props, prevState: State) { const newSource = Multimedia.sourceFromMediaInfo(this.props.mediaInfo); const oldSource = this.state.currentSource; if (!_isEqual(newSource)(oldSource)) { this.reportSourceDisplayed(newSource, true); const { departingSource } = this.state; if (departingSource && !_isEqual(oldSource)(departingSource)) { // If there's currently an existing departingSource, that means that // oldSource hasn't loaded yet. Since it's being replaced anyways // we don't need to display it anymore, so we can unlink it now this.reportSourceDisplayed(oldSource, false); this.setState({ currentSource: newSource }); } else { this.setState({ currentSource: newSource, departingSource: oldSource }); } } const newDepartingSource = this.state.departingSource; const oldDepartingSource = prevState.departingSource; if ( oldDepartingSource && !_isEqual(oldDepartingSource)(newDepartingSource) ) { this.reportSourceDisplayed(oldDepartingSource, false); } } render() { const images = []; const { currentSource, departingSource } = this.state; if (departingSource) { images.push(this.renderSource(currentSource, true)); images.push(this.renderSource(departingSource, true)); } else { images.push(this.renderSource(currentSource)); } return {images}; } renderSource(source: Source, invisibleLoad?: boolean = false) { if (source.kind === 'encrypted') { return ( ); } const { uri } = source; if (uri.startsWith('http')) { return ( ); } else { return ( ); } } onLoad = () => { this.setState({ departingSource: null }); }; reportSourceDisplayed = (source: Source, isLoaded: boolean) => { if (source.kind === 'uri') { this.inputState.reportURIDisplayed(source.uri, isLoaded); } }; + // ESLint doesn't recognize that invariant always throws + // eslint-disable-next-line consistent-return static sourceFromMediaInfo(mediaInfo: MediaInfo | AvatarMediaInfo): Source { if (mediaInfo.type === 'photo') { return { kind: 'uri', uri: mediaInfo.uri }; } else if (mediaInfo.type === 'video') { return { kind: 'uri', uri: mediaInfo.thumbnailURI }; } else if (mediaInfo.type === 'encrypted_photo') { return { kind: 'encrypted', holder: mediaInfo.holder, encryptionKey: mediaInfo.encryptionKey, }; } else if (mediaInfo.type === 'encrypted_video') { return { kind: 'encrypted', holder: mediaInfo.thumbnailHolder, encryptionKey: mediaInfo.thumbnailEncryptionKey, }; } else { invariant(false, 'Invalid mediaInfo type'); } } } const styles = StyleSheet.create({ container: { flex: 1, }, image: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, }); const ConnectedMultimedia: React.ComponentType = React.memo(function ConnectedMultimedia(props: BaseProps) { const inputState = React.useContext(InputStateContext); return ; }); export default ConnectedMultimedia; diff --git a/native/media/video-playback-modal.react.js b/native/media/video-playback-modal.react.js index b465a86cc..83063a0c8 100644 --- a/native/media/video-playback-modal.react.js +++ b/native/media/video-playback-modal.react.js @@ -1,817 +1,817 @@ // @flow import Icon from '@expo/vector-icons/MaterialCommunityIcons.js'; import invariant from 'invariant'; import * as React from 'react'; import { useState } from 'react'; import { View, Text, TouchableOpacity } from 'react-native'; import filesystem from 'react-native-fs'; import { TapGestureHandler } from 'react-native-gesture-handler'; import * as Progress from 'react-native-progress'; import Animated from 'react-native-reanimated'; import { SafeAreaView } from 'react-native-safe-area-context'; import Video from 'react-native-video'; import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js'; import { useIsAppBackgroundedOrInactive } from 'lib/shared/lifecycle-utils.js'; import type { MediaInfo } from 'lib/types/media-types.js'; import { decryptMedia } from './encryption-utils.js'; import { formatDuration } from './video-utils.js'; import ConnectedStatusBar from '../connected-status-bar.react.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { derivedDimensionsInfoSelector } from '../selectors/dimensions-selectors.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js'; import type { VerticalBounds, LayoutCoordinates, } from '../types/layout-types.js'; import type { NativeMethods } from '../types/react-native.js'; import { gestureJustEnded, animateTowards } from '../utils/animation-utils.js'; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; /* eslint-disable import/no-named-as-default-member */ const { Extrapolate, and, or, block, cond, eq, ceil, call, set, add, sub, multiply, divide, not, max, min, lessThan, greaterThan, abs, interpolateNode, useValue, event, } = Animated; export type VideoPlaybackModalParams = { +presentedFrom: string, +mediaInfo: MediaInfo, +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +item: ChatMultimediaMessageInfoItem, }; type Props = { +navigation: AppNavigationProp<'VideoPlaybackModal'>, +route: NavigationRoute<'VideoPlaybackModal'>, }; function VideoPlaybackModal(props: Props): React.Node { const { mediaInfo } = props.route.params; const { uri: videoUri, holder, encryptionKey } = mediaInfo; const [videoSource, setVideoSource] = React.useState( videoUri ? { uri: videoUri } : undefined, ); const mediaCache = React.useContext(MediaCacheContext); React.useEffect(() => { // skip for unencrypted videos if (!holder || !encryptionKey) { - return; + return undefined; } let isMounted = true; let uriToDispose; setVideoSource(undefined); const loadDecrypted = async () => { const cached = await mediaCache?.get(holder); if (cached && isMounted) { setVideoSource({ uri: cached }); return; } const { result } = await decryptMedia(holder, encryptionKey, { destination: 'file', }); if (result.success) { const { uri } = result; const cacheSetPromise = mediaCache?.set(holder, uri); if (isMounted) { uriToDispose = uri; setVideoSource({ uri }); } else { // dispose of the temporary file immediately when unmounted // but wait for the cache to be set await cacheSetPromise; filesystem.unlink(uri); } } }; loadDecrypted(); return () => { isMounted = false; if (uriToDispose) { // remove the temporary file created by decryptMedia filesystem.unlink(uriToDispose); } }; }, [holder, encryptionKey, mediaCache]); const closeButtonX = useValue(-1); const closeButtonY = useValue(-1); const closeButtonWidth = useValue(-1); const closeButtonHeight = useValue(-1); const closeButtonRef = React.useRef>(); const closeButton = closeButtonRef.current; const onCloseButtonLayoutCalledRef = React.useRef(false); const onCloseButtonLayout = React.useCallback(() => { onCloseButtonLayoutCalledRef.current = true; }, []); const onCloseButtonLayoutCalled = onCloseButtonLayoutCalledRef.current; React.useEffect(() => { if (!closeButton || !onCloseButtonLayoutCalled) { return; } closeButton.measure((x, y, width, height, pageX, pageY) => { closeButtonX.setValue(pageX); closeButtonY.setValue(pageY); closeButtonWidth.setValue(width); closeButtonHeight.setValue(height); }); }, [ closeButton, onCloseButtonLayoutCalled, closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, ]); const footerX = useValue(-1); const footerY = useValue(-1); const footerWidth = useValue(-1); const footerHeight = useValue(-1); const footerRef = React.useRef(); const footer = footerRef.current; const onFooterLayoutCalledRef = React.useRef(false); const onFooterLayout = React.useCallback(() => { onFooterLayoutCalledRef.current = true; }, []); const onFooterLayoutCalled = onFooterLayoutCalledRef.current; React.useEffect(() => { if (!footer || !onFooterLayoutCalled) { return; } footer.measure((x, y, width, height, pageX, pageY) => { footerX.setValue(pageX); footerY.setValue(pageY); footerWidth.setValue(width); footerHeight.setValue(height); }); }, [ footer, onFooterLayoutCalled, footerX, footerY, footerWidth, footerHeight, ]); const controlsShowing = useValue(1); const outsideButtons = React.useCallback( (x, y) => and( or( eq(controlsShowing, 0), lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( eq(controlsShowing, 0), lessThan(x, footerX), greaterThan(x, add(footerX, footerWidth)), lessThan(y, footerY), greaterThan(y, add(footerY, footerHeight)), ), ), [ controlsShowing, closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, footerX, footerY, footerWidth, footerHeight, ], ); /* ===== START FADE CONTROL ANIMATION ===== */ const singleTapState = useValue(-1); const singleTapX = useValue(0); const singleTapY = useValue(0); const singleTapEvent = React.useMemo( () => event([ { nativeEvent: { state: singleTapState, x: singleTapX, y: singleTapY, }, }, ]), [singleTapState, singleTapX, singleTapY], ); const lastTapX = useValue(-1); const lastTapY = useValue(-1); const activeControlsOpacity = React.useMemo( () => animateTowards( block([ cond( and( gestureJustEnded(singleTapState), outsideButtons(lastTapX, lastTapY), ), set(controlsShowing, not(controlsShowing)), ), set(lastTapX, singleTapX), set(lastTapY, singleTapY), controlsShowing, ]), 150, ), [ singleTapState, controlsShowing, outsideButtons, lastTapX, lastTapY, singleTapX, singleTapY, ], ); const [controlsEnabled, setControlsEnabled] = React.useState(true); const enableControls = React.useCallback(() => setControlsEnabled(true), []); const disableControls = React.useCallback( () => setControlsEnabled(false), [], ); const previousOpacityCeiling = useValue(-1); const opacityCeiling = React.useMemo( () => ceil(activeControlsOpacity), [activeControlsOpacity], ); const opacityJustChanged = React.useMemo( () => cond(eq(previousOpacityCeiling, opacityCeiling), 0, [ set(previousOpacityCeiling, opacityCeiling), 1, ]), [previousOpacityCeiling, opacityCeiling], ); const toggleControls = React.useMemo( () => [ cond( and(eq(opacityJustChanged, 1), eq(opacityCeiling, 0)), call([], disableControls), ), cond( and(eq(opacityJustChanged, 1), eq(opacityCeiling, 1)), call([], enableControls), ), ], [opacityJustChanged, opacityCeiling, disableControls, enableControls], ); /* ===== END FADE CONTROL ANIMATION ===== */ const mediaDimensions = mediaInfo.dimensions; const screenDimensions = useSelector(derivedDimensionsInfoSelector); const frame = React.useMemo( () => ({ width: screenDimensions.width, height: screenDimensions.safeAreaHeight, }), [screenDimensions], ); const mediaDisplayDimensions = React.useMemo(() => { let { height: maxHeight, width: maxWidth } = frame; if (maxHeight > maxWidth) { maxHeight -= 100; } else { maxWidth -= 100; } if ( mediaDimensions.height < maxHeight && mediaDimensions.width < maxWidth ) { return mediaDimensions; } const heightRatio = maxHeight / mediaDimensions.height; const widthRatio = maxWidth / mediaDimensions.width; if (heightRatio < widthRatio) { return { height: maxHeight, width: mediaDimensions.width * heightRatio, }; } else { return { width: maxWidth, height: mediaDimensions.height * widthRatio, }; } }, [frame, mediaDimensions]); const centerX = useValue(frame.width / 2); const centerY = useValue(frame.height / 2 + screenDimensions.topInset); const frameWidth = useValue(frame.width); const frameHeight = useValue(frame.height); const imageWidth = useValue(mediaDisplayDimensions.width); const imageHeight = useValue(mediaDisplayDimensions.height); React.useEffect(() => { const { width: frameW, height: frameH } = frame; const { topInset } = screenDimensions; frameWidth.setValue(frameW); frameHeight.setValue(frameH); centerX.setValue(frameW / 2); centerY.setValue(frameH / 2 + topInset); const { width, height } = mediaDisplayDimensions; imageWidth.setValue(width); imageHeight.setValue(height); }, [ screenDimensions, frame, mediaDisplayDimensions, frameWidth, frameHeight, centerX, centerY, imageWidth, imageHeight, ]); const left = React.useMemo( () => sub(centerX, divide(imageWidth, 2)), [centerX, imageWidth], ); const top = React.useMemo( () => sub(centerY, divide(imageHeight, 2)), [centerY, imageHeight], ); const { initialCoordinates } = props.route.params; const initialScale = React.useMemo( () => divide(initialCoordinates.width, imageWidth), [initialCoordinates, imageWidth], ); const initialTranslateX = React.useMemo( () => sub( initialCoordinates.x + initialCoordinates.width / 2, add(left, divide(imageWidth, 2)), ), [initialCoordinates, left, imageWidth], ); const initialTranslateY = React.useMemo( () => sub( initialCoordinates.y + initialCoordinates.height / 2, add(top, divide(imageHeight, 2)), ), [initialCoordinates, top, imageHeight], ); // The all-important outputs const curScale = useValue(1); const curX = useValue(0); const curY = useValue(0); const curBackdropOpacity = useValue(1); const progressiveOpacity = React.useMemo( () => max( min( sub(1, abs(divide(curX, frameWidth))), sub(1, abs(divide(curY, frameHeight))), ), 0, ), [curX, curY, frameWidth, frameHeight], ); const updates = React.useMemo( () => [toggleControls, set(curBackdropOpacity, progressiveOpacity)], [curBackdropOpacity, progressiveOpacity, toggleControls], ); const updatedScale = React.useMemo( () => [updates, curScale], [updates, curScale], ); const updatedCurX = React.useMemo(() => [updates, curX], [updates, curX]); const updatedCurY = React.useMemo(() => [updates, curY], [updates, curY]); const updatedBackdropOpacity = React.useMemo( () => [updates, curBackdropOpacity], [updates, curBackdropOpacity], ); const updatedActiveControlsOpacity = React.useMemo( () => block([updates, activeControlsOpacity]), [updates, activeControlsOpacity], ); const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'VideoPlaybackModal should have OverlayContext'); const navigationProgress = overlayContext.position; const reverseNavigationProgress = React.useMemo( () => sub(1, navigationProgress), [navigationProgress], ); const dismissalButtonOpacity = interpolateNode(updatedBackdropOpacity, { inputRange: [0.95, 1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const controlsOpacity = multiply( navigationProgress, dismissalButtonOpacity, updatedActiveControlsOpacity, ); const scale = React.useMemo( () => add( multiply(reverseNavigationProgress, initialScale), multiply(navigationProgress, updatedScale), ), [reverseNavigationProgress, initialScale, navigationProgress, updatedScale], ); const x = React.useMemo( () => add( multiply(reverseNavigationProgress, initialTranslateX), multiply(navigationProgress, updatedCurX), ), [ reverseNavigationProgress, initialTranslateX, navigationProgress, updatedCurX, ], ); const y = React.useMemo( () => add( multiply(reverseNavigationProgress, initialTranslateY), multiply(navigationProgress, updatedCurY), ), [ reverseNavigationProgress, initialTranslateY, navigationProgress, updatedCurY, ], ); const backdropOpacity = React.useMemo( () => multiply(navigationProgress, updatedBackdropOpacity), [navigationProgress, updatedBackdropOpacity], ); const imageContainerOpacity = React.useMemo( () => interpolateNode(navigationProgress, { inputRange: [0, 0.1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), [navigationProgress], ); const { verticalBounds } = props.route.params; const videoContainerStyle = React.useMemo(() => { const { height, width } = mediaDisplayDimensions; const { height: frameH, width: frameW } = frame; return { height, width, marginTop: (frameH - height) / 2 + screenDimensions.topInset - verticalBounds.y, marginLeft: (frameW - width) / 2, opacity: imageContainerOpacity, transform: [{ translateX: x }, { translateY: y }, { scale: scale }], }; }, [ mediaDisplayDimensions, frame, screenDimensions.topInset, verticalBounds.y, imageContainerOpacity, x, y, scale, ]); const styles = useStyles(unboundStyles); const [paused, setPaused] = useState(false); const [percentElapsed, setPercentElapsed] = useState(0); const [spinnerVisible, setSpinnerVisible] = useState(true); const [timeElapsed, setTimeElapsed] = useState('0:00'); const [totalDuration, setTotalDuration] = useState('0:00'); const videoRef = React.useRef(); const backgroundedOrInactive = useIsAppBackgroundedOrInactive(); React.useEffect(() => { if (backgroundedOrInactive) { setPaused(true); controlsShowing.setValue(1); } }, [backgroundedOrInactive, controlsShowing]); const { navigation } = props; const togglePlayback = React.useCallback(() => { setPaused(!paused); }, [paused]); const resetVideo = React.useCallback(() => { invariant(videoRef.current, 'videoRef.current should be set in resetVideo'); videoRef.current.seek(0); }, []); const progressCallback = React.useCallback(res => { setTimeElapsed(formatDuration(res.currentTime)); setTotalDuration(formatDuration(res.seekableDuration)); setPercentElapsed( Math.ceil((res.currentTime / res.seekableDuration) * 100), ); }, []); const readyForDisplayCallback = React.useCallback(() => { setSpinnerVisible(false); }, []); const statusBar = overlayContext.isDismissing ? null : (