diff --git a/.buildkite/tunnelbroker_client.yml b/.buildkite/tunnelbroker_client.yml index 614a961da..413d66856 100644 --- a/.buildkite/tunnelbroker_client.yml +++ b/.buildkite/tunnelbroker_client.yml @@ -1,10 +1,10 @@ env: - PATH: "$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH" + PATH: '$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH' steps: - label: ':nix: Tunnelbroker Client (Shared)' command: - 'cd shared/tunnelbroker-client' - 'rm -rf target' - 'nix develop --accept-flake-config -c cargo test' agents: - 'mac=true' diff --git a/keyserver/docker-compose.yml b/keyserver/docker-compose.yml index 1105f6608..b1cba8527 100644 --- a/keyserver/docker-compose.yml +++ b/keyserver/docker-compose.yml @@ -1,53 +1,53 @@ -version: "3.9" +version: '3.9' services: node: build: dockerfile: keyserver/Dockerfile context: ../ args: - HOST_UID=${HOST_UID} - HOST_GID=${HOST_GID} - COMM_ALCHEMY_KEY=${COMM_ALCHEMY_KEY} - COMM_WALLETCONNECT_KEY=${COMM_WALLETCONNECT_KEY} image: commapp/node-keyserver:1.0 restart: always ports: - - "3000:3000" + - '3000:3000' env_file: - .env environment: - REDIS_URL=redis://cache - COMM_LISTEN_ADDR=0.0.0.0 - COMM_DATABASE_HOST=${COMM_DATABASE_HOST:-database} - COMM_DATABASE_DATABASE - COMM_DATABASE_USER - COMM_DATABASE_PASSWORD - COMM_DATABASE_TYPE=mariadb10.8 depends_on: - cache - database database: image: mariadb:10.8.3-jammy restart: always expose: - - "3306" + - '3306' command: > --max-allowed-packet=256M --local-infile=0 --sql-mode=STRICT_ALL_TABLES --innodb-buffer-pool-size=1600M environment: - MARIADB_RANDOM_ROOT_PASSWORD=yes - MARIADB_DATABASE=$COMM_DATABASE_DATABASE - MARIADB_USER=$COMM_DATABASE_USER - MARIADB_PASSWORD=$COMM_DATABASE_PASSWORD volumes: - mysqldata:/var/lib/mysql cache: image: redis:6.2.6-bullseye restart: always expose: - - "6379" + - '6379' command: redis-server --loglevel warning volumes: mysqldata: diff --git a/keyserver/src/creators/account-creator.js b/keyserver/src/creators/account-creator.js index 2780cd2da..2a28926b2 100644 --- a/keyserver/src/creators/account-creator.js +++ b/keyserver/src/creators/account-creator.js @@ -1,304 +1,300 @@ // @flow import invariant from 'invariant'; import bcrypt from 'twin-bcrypt'; import ashoat from 'lib/facts/ashoat.js'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { policyTypes } from 'lib/facts/policies.js'; import { validUsernameRegex, oldValidUsernameRegex, } from 'lib/shared/account-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { RegisterResponse, RegisterRequest, } from 'lib/types/account-types.js'; import type { PlatformDetails, DeviceTokenUpdateRequest, } from 'lib/types/device-types.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import { messageTypes } from 'lib/types/message-types.js'; import type { SIWESocialProof } from 'lib/types/siwe-types.js'; import { threadTypes } from 'lib/types/thread-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { reservedUsernamesSet } from 'lib/utils/reserved-users.js'; import { isValidEthereumAddress } from 'lib/utils/siwe-utils.js'; import createIDs from './id-creator.js'; import createMessages from './message-creator.js'; import { createThread, createPrivateThread, privateThreadDescription, } from './thread-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchLoggedInUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers.js'; import { verifyCalendarQueryThreadIDs } from '../responders/entry-responders.js'; import { createNewUserCookie, setNewSession } from '../session/cookies.js'; import { createScriptViewer } from '../session/scripts.js'; import type { Viewer } from '../session/viewer.js'; import { updateThread } from '../updaters/thread-updaters.js'; import { viewerAcknowledgmentUpdater } from '../updaters/viewer-acknowledgment-updater.js'; const { commbot } = bots; const ashoatMessages = [ 'welcome to Comm!', 'as you inevitably discover bugs, have feature requests, or design ' + 'suggestions, feel free to message them to me in the app.', ]; const privateMessages = [privateThreadDescription]; async function createAccount( viewer: Viewer, request: RegisterRequest, ): Promise { if (request.password.trim() === '') { throw new ServerError('empty_password'); } const usernameRegex = hasMinCodeVersion(viewer.platformDetails, 69) ? validUsernameRegex : oldValidUsernameRegex; if (request.username.search(usernameRegex) === -1) { throw new ServerError('invalid_username'); } const usernameQuery = SQL` SELECT COUNT(id) AS count FROM users WHERE LCASE(username) = LCASE(${request.username}) `; const promises = [dbQuery(usernameQuery)]; const { calendarQuery } = request; if (calendarQuery) { promises.push(verifyCalendarQueryThreadIDs(calendarQuery)); } const [[usernameResult]] = await Promise.all(promises); if ( reservedUsernamesSet.has(request.username.toLowerCase()) || isValidEthereumAddress(request.username.toLowerCase()) ) { if (hasMinCodeVersion(viewer.platformDetails, 120)) { throw new ServerError('username_reserved'); } else { throw new ServerError('username_taken'); } } if (usernameResult[0].count !== 0) { throw new ServerError('username_taken'); } const hash = bcrypt.hashSync(request.password); const time = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [id] = await createIDs('users', 1); const newUserRow = [id, request.username, hash, time]; const newUserQuery = SQL` INSERT INTO users(id, username, hash, creation_time) VALUES ${[newUserRow]} `; const { primaryIdentityPublicKey } = request; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, primaryIdentityPublicKey, }), deleteCookie(viewer.cookieID), dbQuery(newUserQuery), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, 0); } await Promise.all([ updateThread( createScriptViewer(ashoat.id), { threadID: genesis.id, changes: { newMemberIDs: [id] }, }, { forceAddMembers: true, silenceMessages: true, ignorePermissions: true }, ), viewerAcknowledgmentUpdater(viewer, policyTypes.tosAndPrivacyPolicy), ]); const [privateThreadResult, ashoatThreadResult] = await Promise.all([ createPrivateThread(viewer, request.username), createThread( viewer, { type: threadTypes.PERSONAL, initialMemberIDs: [ashoat.id], }, { forceAddMembers: true }, ), ]); const ashoatThreadID = ashoatThreadResult.newThreadInfo ? ashoatThreadResult.newThreadInfo.id : ashoatThreadResult.newThreadID; const privateThreadID = privateThreadResult.newThreadInfo ? privateThreadResult.newThreadInfo.id : privateThreadResult.newThreadID; invariant( ashoatThreadID && privateThreadID, 'createThread should return either newThreadInfo or newThreadID', ); let messageTime = Date.now(); const ashoatMessageDatas = ashoatMessages.map(message => ({ type: messageTypes.TEXT, threadID: ashoatThreadID, creatorID: ashoat.id, time: messageTime++, text: message, })); const privateMessageDatas = privateMessages.map(message => ({ type: messageTypes.TEXT, threadID: privateThreadID, creatorID: commbot.userID, time: messageTime++, text: message, })); const messageDatas = [...ashoatMessageDatas, ...privateMessageDatas]; - const [ - messageInfos, - threadsResult, - userInfos, - currentUserInfo, - ] = await Promise.all([ - createMessages(viewer, messageDatas), - fetchThreadInfos(viewer), - fetchKnownUserInfos(viewer), - fetchLoggedInUserInfo(viewer), - ]); + const [messageInfos, threadsResult, userInfos, currentUserInfo] = + await Promise.all([ + createMessages(viewer, messageDatas), + fetchThreadInfos(viewer), + fetchKnownUserInfos(viewer), + fetchLoggedInUserInfo(viewer), + ]); const rawMessageInfos = [ ...ashoatThreadResult.newMessageInfos, ...privateThreadResult.newMessageInfos, ...messageInfos, ]; return { id, rawMessageInfos, currentUserInfo, cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: values(userInfos), }, }; } export type ProcessSIWEAccountCreationRequest = { +address: string, +calendarQuery: CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +primaryIdentityPublicKey: ?string, +socialProof: SIWESocialProof, }; // Note: `processSIWEAccountCreation(...)` assumes that the validity of // `ProcessSIWEAccountCreationRequest` was checked at call site. async function processSIWEAccountCreation( viewer: Viewer, request: ProcessSIWEAccountCreationRequest, ): Promise { const { calendarQuery } = request; await verifyCalendarQueryThreadIDs(calendarQuery); const time = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [id] = await createIDs('users', 1); const newUserRow = [id, request.address, request.address, time]; const newUserQuery = SQL` INSERT INTO users(id, username, ethereum_address, creation_time) VALUES ${[newUserRow]} `; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, primaryIdentityPublicKey: request.primaryIdentityPublicKey, socialProof: request.socialProof, }), deleteCookie(viewer.cookieID), dbQuery(newUserQuery), ]); viewer.setNewCookie(userViewerData); await setNewSession(viewer, calendarQuery, 0); await Promise.all([ updateThread( createScriptViewer(ashoat.id), { threadID: genesis.id, changes: { newMemberIDs: [id] }, }, { forceAddMembers: true, silenceMessages: true, ignorePermissions: true }, ), viewerAcknowledgmentUpdater(viewer, policyTypes.tosAndPrivacyPolicy), ]); const [privateThreadResult, ashoatThreadResult] = await Promise.all([ createPrivateThread(viewer, request.address), createThread( viewer, { type: threadTypes.PERSONAL, initialMemberIDs: [ashoat.id], }, { forceAddMembers: true }, ), ]); const ashoatThreadID = ashoatThreadResult.newThreadInfo ? ashoatThreadResult.newThreadInfo.id : ashoatThreadResult.newThreadID; const privateThreadID = privateThreadResult.newThreadInfo ? privateThreadResult.newThreadInfo.id : privateThreadResult.newThreadID; invariant( ashoatThreadID && privateThreadID, 'createThread should return either newThreadInfo or newThreadID', ); let messageTime = Date.now(); const ashoatMessageDatas = ashoatMessages.map(message => ({ type: messageTypes.TEXT, threadID: ashoatThreadID, creatorID: ashoat.id, time: messageTime++, text: message, })); const privateMessageDatas = privateMessages.map(message => ({ type: messageTypes.TEXT, threadID: privateThreadID, creatorID: commbot.userID, time: messageTime++, text: message, })); const messageDatas = [...ashoatMessageDatas, ...privateMessageDatas]; await Promise.all([createMessages(viewer, messageDatas)]); return id; } export { createAccount, processSIWEAccountCreation }; diff --git a/keyserver/src/creators/message-creator.js b/keyserver/src/creators/message-creator.js index 9b806e271..31e5da5b0 100644 --- a/keyserver/src/creators/message-creator.js +++ b/keyserver/src/creators/message-creator.js @@ -1,634 +1,630 @@ // @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 { 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 { fetchMessageInfoForLocalID, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { fetchOtherSessionsForViewer } from '../fetchers/session-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { sendPushNotifs } 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, { +deviceType: 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 messageInfos: RawMessageInfo[] = []; const newMessageDatas: MessageData[] = []; const existingMessages = await Promise.all( messageDatas.map(messageData => fetchMessageInfoForLocalID(viewer, messageDataLocalID(messageData)), ), ); for (let i = 0; i < existingMessages.length; i++) { const existingMessage = existingMessages[i]; if (existingMessage) { messageInfos.push(existingMessage); } else { newMessageDatas.push(messageDatas[i]); } } if (newMessageDatas.length === 0) { return shimUnsupportedRawMessageInfos(messageInfos, viewer.platformDetails); } const ids = await createIDs('messages', newMessageDatas.length); const subthreadPermissionsToCheck: Set = new Set(); const threadsToMessageIndices: Map = new Map(); const messageInsertRows = []; for (let i = 0; i < newMessageDatas.length; i++) { const messageData = newMessageDatas[i]; const threadID = messageData.threadID; const creatorID = messageData.creatorID; if (messageData.type === messageTypes.CREATE_SUB_THREAD) { subthreadPermissionsToCheck.add(messageData.childThreadID); } let messageIndices = threadsToMessageIndices.get(threadID); if (!messageIndices) { messageIndices = []; threadsToMessageIndices.set(threadID, messageIndices); } messageIndices.push(i); - const content = messageSpecs[messageData.type].messageContentForServerDB?.( - messageData, - ); + 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([ ids[i], threadID, creatorID, messageData.type, content, messageData.time, creation, targetMessageID, ]); messageInfos.push(rawMessageInfoFromMessageData(messageData, ids[i])); } if (viewer.isScriptViewer) { await postMessageSend( viewer, threadsToMessageIndices, subthreadPermissionsToCheck, stripLocalIDs(messageInfos), updatesForCurrentSession, ); } else { // We aren't awaiting because this function calls external services and we // don't want to delay the response handleAsyncPromise( postMessageSend( viewer, threadsToMessageIndices, subthreadPermissionsToCheck, stripLocalIDs(messageInfos), updatesForCurrentSession, ), ); } const messageInsertQuery = SQL` INSERT INTO messages(id, thread, user, type, content, time, creation, target_message) VALUES ${messageInsertRows} `; await Promise.all([ dbQuery(messageInsertQuery), updateRepliesCount(threadsToMessageIndices, newMessageDatas), ]); if (updatesForCurrentSession !== 'return') { return []; } return shimUnsupportedRawMessageInfos(messageInfos, 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 async function postMessageSend( viewer: Viewer, threadsToMessageIndices: Map, subthreadPermissionsToCheck: Set, messageInfos: RawMessageInfo[], updatesForCurrentSession: UpdatesForCurrentSession, ) { 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, { deviceType: 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 = {}; 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 userPushInfoMessageInfoPromises = []; for (const threadID of preUserPushInfo.notFocusedThreadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant(messageIndices, `indices should exist for thread ${threadID}`); userPushInfoMessageInfoPromises.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 doesGenerateNotif = await generatesNotifs(messageInfo, { notifTargetUserID: userID, userNotMemberOfSubthreads, fetchMessageInfoByID: (messageID: string) => fetchMessageInfoByID(viewer, messageID), }); return doesGenerateNotif === pushTypes.NOTIF ? messageInfo : undefined; }), ); } const userPushInfoPromise = (async () => { const pushMessageInfos = await Promise.all( userPushInfoMessageInfoPromises, ); const filteredMessageInfos = pushMessageInfos.filter(Boolean); if (filteredMessageInfos.length === 0) { return undefined; } return { devices: userDevices, messageInfos: filteredMessageInfos, }; })(); userPushInfoPromises[userID] = userPushInfoPromise; } const latestMessages = flattenLatestMessagesPerUser(latestMessagesPerUser); const [pushInfo] = await Promise.all([ promiseAll(userPushInfoPromises), createReadStatusUpdates(latestMessages), redisPublish(viewer, messageInfosPerUser, updatesForCurrentSession), updateLatestMessages(latestMessages), ]); await sendPushNotifs(_pickBy(Boolean)(pushInfo)); } 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 { 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); } 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); } export default createMessages; diff --git a/keyserver/src/creators/thread-creator.js b/keyserver/src/creators/thread-creator.js index f66a9cca8..9b5c86abd 100644 --- a/keyserver/src/creators/thread-creator.js +++ b/keyserver/src/creators/thread-creator.js @@ -1,509 +1,504 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { generatePendingThreadColor, generateRandomColor, getThreadTypeParentRequirement, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Shape } from 'lib/types/core.js'; import { messageTypes } from 'lib/types/message-types.js'; import { type ServerNewThreadRequest, type NewThreadResponse, threadTypes, threadPermissions, threadTypeIsCommunityRoot, } from 'lib/types/thread-types.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { pushAll } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import createIDs from './id-creator.js'; import createMessages from './message-creator.js'; import { createInitialRolesForNewThread, getRolePermissionBlobs, } from './role-creator.js'; import type { UpdatesForCurrentSession } from './update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchMessageInfoByID } from '../fetchers/message-fetchers.js'; import { determineThreadAncestry, personalThreadQuery, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, getChangesetCommitResultForExistingThread, } from '../updaters/thread-permission-updaters.js'; import { joinThread } from '../updaters/thread-updaters.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; const { commbot } = bots; const privateThreadDescription: string = 'This is your private chat, ' + 'where you can set reminders and jot notes in private!'; type CreateThreadOptions = Shape<{ +forceAddMembers: boolean, +updatesForCurrentSession: UpdatesForCurrentSession, +silentlyFailMembers: boolean, }>; // If forceAddMembers is set, we will allow the viewer to add random users who // they aren't friends with. We will only fail if the viewer is trying to add // somebody who they have blocked or has blocked them. On the other hand, if // forceAddMembers is not set, we will fail if the viewer tries to add somebody // who they aren't friends with and doesn't have a membership row with a // nonnegative role for the parent thread. async function createThread( viewer: Viewer, request: ServerNewThreadRequest, options?: CreateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const updatesForCurrentSession = options?.updatesForCurrentSession ?? 'return'; const silentlyFailMembers = options?.silentlyFailMembers ?? false; const threadType = request.type; const shouldCreateRelationships = forceAddMembers || threadType === threadTypes.PERSONAL; let parentThreadID = request.parentThreadID ? request.parentThreadID : null; const initialMemberIDsFromRequest = request.initialMemberIDs && request.initialMemberIDs.length > 0 ? [...new Set(request.initialMemberIDs)] : null; const ghostMemberIDsFromRequest = request.ghostMemberIDs && request.ghostMemberIDs.length > 0 ? [...new Set(request.ghostMemberIDs)] : null; const sourceMessageID = request.sourceMessageID ? request.sourceMessageID : null; invariant( threadType !== threadTypes.SIDEBAR || sourceMessageID, 'sourceMessageID should be set for sidebar', ); const parentRequirement = getThreadTypeParentRequirement(threadType); if ( (parentRequirement === 'required' && !parentThreadID) || (parentRequirement === 'disabled' && parentThreadID) ) { throw new ServerError('invalid_parameters'); } if ( threadType === threadTypes.PERSONAL && request.initialMemberIDs?.length !== 1 ) { throw new ServerError('invalid_parameters'); } const requestParentThreadID = parentThreadID; const confirmParentPermissionPromise = (async () => { if (!requestParentThreadID) { return; } const hasParentPermission = await checkThreadPermission( viewer, requestParentThreadID, threadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_credentials'); } })(); // This is a temporary hack until we release actual E2E-encrypted local // conversations. For now we are hosting all root threads on Ashoat's // keyserver, so we set them to the have the Genesis community as their // parent thread. if (!parentThreadID && !threadTypeIsCommunityRoot(threadType)) { parentThreadID = genesis.id; } const determineThreadAncestryPromise = determineThreadAncestry( parentThreadID, threadType, ); const validateMembersPromise = (async () => { const threadAncestry = await determineThreadAncestryPromise; const defaultRolePermissions = getRolePermissionBlobs(threadType).Members; const { initialMemberIDs, ghostMemberIDs } = await validateCandidateMembers( viewer, { initialMemberIDs: initialMemberIDsFromRequest, ghostMemberIDs: ghostMemberIDsFromRequest, }, { threadType, parentThreadID, containingThreadID: threadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !shouldCreateRelationships }, ); if ( !silentlyFailMembers && (Number(initialMemberIDs?.length) < Number(initialMemberIDsFromRequest?.length) || Number(ghostMemberIDs?.length) < Number(ghostMemberIDsFromRequest?.length)) ) { throw new ServerError('invalid_credentials'); } return { initialMemberIDs, ghostMemberIDs }; })(); const checkPromises = {}; checkPromises.confirmParentPermission = confirmParentPermissionPromise; checkPromises.threadAncestry = determineThreadAncestryPromise; checkPromises.validateMembers = validateMembersPromise; if (sourceMessageID) { checkPromises.sourceMessage = fetchMessageInfoByID(viewer, sourceMessageID); } const { sourceMessage, threadAncestry, validateMembers: { initialMemberIDs, ghostMemberIDs }, } = await promiseAll(checkPromises); if (sourceMessage && sourceMessage.type === messageTypes.REACTION) { throw new ServerError('invalid_parameters'); } let { id } = request; if (id === null || id === undefined) { const ids = await createIDs('threads', 1); id = ids[0]; } const newRoles = await createInitialRolesForNewThread(id, threadType); const name = request.name ? firstLine(request.name) : null; const description = request.description ? request.description : null; let color = request.color ? request.color.toLowerCase() : generateRandomColor(); if (threadType === threadTypes.PERSONAL) { color = generatePendingThreadColor([ ...(request.initialMemberIDs ?? []), viewer.id, ]); } const time = Date.now(); const row = [ id, threadType, name, description, viewer.userID, time, color, parentThreadID, threadAncestry.containingThreadID, threadAncestry.community, threadAncestry.depth, newRoles.default.id, sourceMessageID, ]; let existingThreadQuery = null; if (threadType === threadTypes.PERSONAL) { const otherMemberID = initialMemberIDs?.[0]; invariant( otherMemberID, 'Other member id should be set for a PERSONAL thread', ); existingThreadQuery = personalThreadQuery(viewer.userID, otherMemberID); } else if (sourceMessageID) { existingThreadQuery = SQL` SELECT t.id FROM threads t WHERE t.source_message = ${sourceMessageID} `; } if (existingThreadQuery) { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, default_role, source_message) SELECT ${row} WHERE NOT EXISTS (`; query.append(existingThreadQuery).append(SQL`)`); const [result] = await dbQuery(query); if (result.affectedRows === 0) { const deleteRoles = SQL` DELETE FROM roles WHERE id IN (${newRoles.default.id}, ${newRoles.creator.id}) `; const deleteIDs = SQL` DELETE FROM ids WHERE id IN (${id}, ${newRoles.default.id}, ${newRoles.creator.id}) `; const [[existingThreadResult]] = await Promise.all([ dbQuery(existingThreadQuery), dbQuery(deleteRoles), dbQuery(deleteIDs), ]); invariant(existingThreadResult.length > 0, 'thread should exist'); const existingThreadID = existingThreadResult[0].id.toString(); let calendarQuery; if (hasMinCodeVersion(viewer.platformDetails, 87)) { invariant(request.calendarQuery, 'calendar query should exist'); calendarQuery = { ...request.calendarQuery, filters: [ ...request.calendarQuery.filters, { type: 'threads', threadIDs: [existingThreadID] }, ], }; } let joinUpdateInfos = []; let userInfos: UserInfos = {}; let newMessageInfos = []; if (threadType !== threadTypes.PERSONAL) { const joinThreadResult = await joinThread(viewer, { threadID: existingThreadID, calendarQuery, }); joinUpdateInfos = joinThreadResult.updatesResult.newUpdates; userInfos = joinThreadResult.userInfos; newMessageInfos = joinThreadResult.rawMessageInfos; } - const { - viewerUpdates: newUpdates, - userInfos: changesetUserInfos, - } = await getChangesetCommitResultForExistingThread( - viewer, - existingThreadID, - joinUpdateInfos, - { calendarQuery, updatesForCurrentSession }, - ); + const { viewerUpdates: newUpdates, userInfos: changesetUserInfos } = + await getChangesetCommitResultForExistingThread( + viewer, + existingThreadID, + joinUpdateInfos, + { calendarQuery, updatesForCurrentSession }, + ); userInfos = { ...userInfos, ...changesetUserInfos }; return { newThreadID: existingThreadID, updatesResult: { newUpdates, }, userInfos, newMessageInfos, }; } } else { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, default_role, source_message) VALUES ${[row]} `; await dbQuery(query); } let initialMemberPromise; if (initialMemberIDs) { initialMemberPromise = changeRole(id, initialMemberIDs, null, { setNewMembersToUnread: true, }); } let ghostMemberPromise; if (ghostMemberIDs) { ghostMemberPromise = changeRole(id, ghostMemberIDs, -1); } const [ creatorChangeset, initialMembersChangeset, ghostMembersChangeset, recalculatePermissionsChangeset, ] = await Promise.all([ changeRole(id, [viewer.userID], newRoles.creator.id), initialMemberPromise, ghostMemberPromise, recalculateThreadPermissions(id), ]); const { membershipRows: creatorMembershipRows, relationshipChangeset: creatorRelationshipChangeset, } = creatorChangeset; const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; const membershipRows = [ ...creatorMembershipRows, ...recalculateMembershipRows, ]; const relationshipChangeset = new RelationshipChangeset(); relationshipChangeset.addAll(creatorRelationshipChangeset); relationshipChangeset.addAll(recalculateRelationshipChangeset); if (initialMembersChangeset) { const { membershipRows: initialMembersMembershipRows, relationshipChangeset: initialMembersRelationshipChangeset, } = initialMembersChangeset; pushAll(membershipRows, initialMembersMembershipRows); relationshipChangeset.addAll(initialMembersRelationshipChangeset); } if (ghostMembersChangeset) { const { membershipRows: ghostMembersMembershipRows, relationshipChangeset: ghostMembersRelationshipChangeset, } = ghostMembersChangeset; pushAll(membershipRows, ghostMembersMembershipRows); relationshipChangeset.addAll(ghostMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; - const { - threadInfos, - viewerUpdates, - userInfos, - } = await commitMembershipChangeset(viewer, changeset, { - updatesForCurrentSession, - }); + const { threadInfos, viewerUpdates, userInfos } = + await commitMembershipChangeset(viewer, changeset, { + updatesForCurrentSession, + }); const initialMemberAndCreatorIDs = initialMemberIDs ? [...initialMemberIDs, viewer.userID] : [viewer.userID]; const messageDatas = []; if (threadType !== threadTypes.SIDEBAR) { messageDatas.push({ type: messageTypes.CREATE_THREAD, threadID: id, creatorID: viewer.userID, time, initialThreadState: { type: threadType, name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }); } else { invariant(parentThreadID, 'parentThreadID should be set for sidebar'); if (!sourceMessage || sourceMessage.type === messageTypes.SIDEBAR_SOURCE) { throw new ServerError('invalid_parameters'); } messageDatas.push( { type: messageTypes.SIDEBAR_SOURCE, threadID: id, creatorID: viewer.userID, time, sourceMessage, }, { type: messageTypes.CREATE_SIDEBAR, threadID: id, creatorID: viewer.userID, time, sourceMessageAuthorID: sourceMessage.creatorID, initialThreadState: { name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }, ); } if ( parentThreadID && threadType !== threadTypes.SIDEBAR && (parentThreadID !== genesis.id || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD) ) { messageDatas.push({ type: messageTypes.CREATE_SUB_THREAD, threadID: parentThreadID, creatorID: viewer.userID, time, childThreadID: id, }); } const newMessageInfos = await createMessages( viewer, messageDatas, updatesForCurrentSession, ); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { newThreadID: id, updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } return { newThreadInfo: threadInfos[id], updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } function createPrivateThread( viewer: Viewer, username: string, ): Promise { return createThread( viewer, { type: threadTypes.PRIVATE, name: username, description: privateThreadDescription, ghostMemberIDs: [commbot.userID], }, { forceAddMembers: true, }, ); } export { createThread, createPrivateThread, privateThreadDescription }; diff --git a/keyserver/src/cron/daily-updates.js b/keyserver/src/cron/daily-updates.js index 8f909f811..6aca5ffcc 100644 --- a/keyserver/src/cron/daily-updates.js +++ b/keyserver/src/cron/daily-updates.js @@ -1,101 +1,99 @@ // @flow import invariant from 'invariant'; import ashoat from 'lib/facts/ashoat.js'; import { messageTypes } from 'lib/types/message-types.js'; import { threadTypes } from 'lib/types/thread-types.js'; import { getDate, dateString, prettyDateWithoutYear, prettyDateWithoutDay, } from 'lib/utils/date-utils.js'; import createMessages from '../creators/message-creator.js'; import { createThread } from '../creators/thread-creator.js'; import { fetchEntryInfosForThreadThisWeek } from '../fetchers/entry-fetchers.js'; import { createScriptViewer } from '../session/scripts.js'; const devUpdateThread = '1358777'; const weeklyDevSyncScheduleThread = '4138372'; const dailyUpdateMessage = (dateWithoutYear: string, dateWithoutDay: string) => `### ${dateWithoutDay} update Share your updates for ${dateWithoutYear} here please!`; const dateIsWeekend = (date: Date) => date.getDay() === 0 || date.getDay() === 6; // This function will do something four days a week. It skips Saturday and // Sunday. The hard part is the third skipped day, which is the day of the // weekly dev sync. By default this is Monday, but if the dev sync is on a // different day, then an admin will put a calendar entry in the // weeklyDevSyncScheduleThread indicating which day to skip. async function createDailyUpdatesThread() { if (!process.env.RUN_COMM_TEAM_DEV_SCRIPTS) { // This is a job that the Comm internal team uses return; } const viewer = createScriptViewer(ashoat.id); const now = new Date(); if (dateIsWeekend(now)) { // nothing happens on Saturday or Sunday return; } // Figure out which day the dev sync is on let devSyncDay = 1; // default to Monday - const entryInfosInDevSyncScheduleThreadThisWeek = await fetchEntryInfosForThreadThisWeek( - viewer, - weeklyDevSyncScheduleThread, - ); + const entryInfosInDevSyncScheduleThreadThisWeek = + await fetchEntryInfosForThreadThisWeek(viewer, weeklyDevSyncScheduleThread); for (const entryInfo of entryInfosInDevSyncScheduleThreadThisWeek) { const entryInfoDate = getDate( entryInfo.year, entryInfo.month, entryInfo.day, ); if (dateIsWeekend(entryInfoDate)) { // Ignore calendar entries on weekend continue; } devSyncDay = entryInfoDate.getDay(); // Use the newest entryInfo. fetchEntryInfos sorts by creation time break; } if (devSyncDay === now.getDay()) { // Skip the dev sync day return; } const dayString = dateString(now); const dateWithoutYear = prettyDateWithoutYear(dayString); const dateWithoutDay = prettyDateWithoutDay(dayString); const [{ id: messageID }] = await createMessages(viewer, [ { type: messageTypes.TEXT, threadID: devUpdateThread, creatorID: ashoat.id, time: Date.now(), text: dailyUpdateMessage(dateWithoutYear, dateWithoutDay), }, ]); invariant( messageID, 'message returned from createMessages always has ID set', ); await createThread(viewer, { type: threadTypes.SIDEBAR, parentThreadID: devUpdateThread, name: `${dateWithoutDay} update`, sourceMessageID: messageID, }); } export { createDailyUpdatesThread }; diff --git a/keyserver/src/deleters/thread-deleters.js b/keyserver/src/deleters/thread-deleters.js index 3de251379..bda5d2799 100644 --- a/keyserver/src/deleters/thread-deleters.js +++ b/keyserver/src/deleters/thread-deleters.js @@ -1,158 +1,156 @@ // @flow import { permissionLookup } from 'lib/permissions/thread-permissions.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { type ThreadDeletionRequest, type LeaveThreadResult, 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 } from '../database/database.js'; import { fetchThreadInfos, fetchServerThreadInfos, } from '../fetchers/thread-fetchers.js'; import { fetchThreadPermissionsBlob } from '../fetchers/thread-permission-fetchers.js'; import { fetchUpdateInfoForThreadDeletion } from '../fetchers/update-fetchers.js'; import { rescindPushNotifs } from '../push/rescind.js'; import type { Viewer } from '../session/viewer.js'; async function deleteThread( viewer: Viewer, threadDeletionRequest: ThreadDeletionRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { threadID } = threadDeletionRequest; - const [ - permissionsBlob, - { threadInfos: serverThreadInfos }, - ] = await Promise.all([ - fetchThreadPermissionsBlob(viewer, threadID), - fetchServerThreadInfos(SQL`t.id = ${threadID}`), - ]); + const [permissionsBlob, { threadInfos: serverThreadInfos }] = + await Promise.all([ + fetchThreadPermissionsBlob(viewer, threadID), + fetchServerThreadInfos(SQL`t.id = ${threadID}`), + ]); if (!permissionsBlob) { // This should only occur if the first request goes through but the client // never receives the response const [{ updateInfos }, fetchThreadInfoResult] = await Promise.all([ fetchUpdateInfoForThreadDeletion(viewer, threadID), hasMinCodeVersion(viewer.platformDetails, 62) ? undefined : fetchThreadInfos(viewer), ]); if (fetchThreadInfoResult) { const { threadInfos } = fetchThreadInfoResult; return { threadInfos, updatesResult: { newUpdates: updateInfos } }; } return { updatesResult: { newUpdates: updateInfos } }; } const hasPermission = permissionLookup( permissionsBlob, threadPermissions.DELETE_THREAD, ); if (!hasPermission) { throw new ServerError('invalid_credentials'); } await rescindPushNotifs( SQL`n.thread = ${threadID}`, SQL`IF(m.thread = ${threadID}, NULL, m.thread)`, ); // TODO: if org, delete all descendant threads as well. make sure to warn user // TODO: handle descendant thread permission update correctly. // thread-permission-updaters should be used for descendant threads. const query = SQL` DELETE t, ic, d, id, e, ie, re, ire, mm, r, ir, ms, im, up, iu, f, n, ino FROM threads t LEFT JOIN ids ic ON ic.id = t.id LEFT JOIN days d ON d.thread = t.id LEFT JOIN ids id ON id.id = d.id LEFT JOIN entries e ON e.day = d.id LEFT JOIN ids ie ON ie.id = e.id LEFT JOIN revisions re ON re.entry = e.id LEFT JOIN ids ire ON ire.id = re.id LEFT JOIN memberships mm ON mm.thread = t.id LEFT JOIN roles r ON r.thread = t.id LEFT JOIN ids ir ON ir.id = r.id LEFT JOIN messages ms ON ms.thread = t.id LEFT JOIN ids im ON im.id = ms.id LEFT JOIN uploads up ON up.container = ms.id LEFT JOIN ids iu ON iu.id = up.id LEFT JOIN focused f ON f.thread = t.id LEFT JOIN notifications n ON n.thread = t.id LEFT JOIN ids ino ON ino.id = n.id WHERE t.id = ${threadID} `; const serverThreadInfo = serverThreadInfos[threadID]; const time = Date.now(); const updateDatas = []; for (const memberInfo of serverThreadInfo.members) { updateDatas.push({ type: updateTypes.DELETE_THREAD, userID: memberInfo.id, time, threadID, }); } const [{ viewerUpdates }] = await Promise.all([ createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'return' }), dbQuery(query), ]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates } }; } const { threadInfos } = await fetchThreadInfos(viewer); return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } async function deleteInaccessibleThreads(): Promise { // A thread is considered "inaccessible" if it has no membership rows. Note // that membership rows exist whenever a user can see a thread, even if they // are not technically a member (in which case role=0). For now, we're also // excluding threads with children, since to properly delete those we would // need to update their parent_thread_id, and possibly change their type. await dbQuery(SQL` DELETE t, i, m2, d, id, e, ie, re, ire, r, ir, ms, im, up, iu, f, n, ino FROM threads t LEFT JOIN ids i ON i.id = t.id LEFT JOIN memberships m1 ON m1.thread = t.id AND m1.role > -1 LEFT JOIN threads c ON c.parent_thread_id = t.id LEFT JOIN memberships m2 ON m2.thread = t.id LEFT JOIN days d ON d.thread = t.id LEFT JOIN ids id ON id.id = d.id LEFT JOIN entries e ON e.day = d.id LEFT JOIN ids ie ON ie.id = e.id LEFT JOIN revisions re ON re.entry = e.id LEFT JOIN ids ire ON ire.id = re.id LEFT JOIN roles r ON r.thread = t.id LEFT JOIN ids ir ON ir.id = r.id LEFT JOIN messages ms ON ms.thread = t.id LEFT JOIN ids im ON im.id = ms.id LEFT JOIN uploads up ON up.container = ms.id LEFT JOIN ids iu ON iu.id = up.id LEFT JOIN focused f ON f.thread = t.id LEFT JOIN notifications n ON n.thread = t.id LEFT JOIN ids ino ON ino.id = n.id WHERE m1.thread IS NULL AND c.id IS NULL `); } export { deleteThread, deleteInaccessibleThreads }; diff --git a/keyserver/src/fetchers/message-fetchers.js b/keyserver/src/fetchers/message-fetchers.js index ae475386b..f1e44a004 100644 --- a/keyserver/src/fetchers/message-fetchers.js +++ b/keyserver/src/fetchers/message-fetchers.js @@ -1,720 +1,719 @@ // @flow import invariant from 'invariant'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, } from 'lib/shared/message-utils.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { notifCollapseKeyForRawMessageInfo } from 'lib/shared/notif-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { type RawMessageInfo, type RawComposableMessageInfo, type RawRobotextMessageInfo, messageTypes, type MessageType, assertMessageType, type MessageSelectionCriteria, type MessageTruncationStatus, messageTruncationStatus, type FetchMessageInfosResult, defaultMaxMessageAge, } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { constructMediaFromMediaMessageContentsAndUploadRows, mediaFromRow, } 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 (const rawMessageInfo of pushInfo[userID].messageInfos) { const collapseKey = notifCollapseKeyForRawMessageInfo(rawMessageInfo); 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(mediaFromRow); } 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 { 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, }; } 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 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 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); } } const messagesByID = new Map< string, RawComposableMessageInfo | RawRobotextMessageInfo, >(); if (requiredIDs.size === 0) { return messagesByID; } const result = await fetchMessageRowsByIDs([...requiredIDs]); const messages = await parseMessageSQLResult(result, new Map(), viewer); for (const message of messages) { const { rawMessageInfo } = message; if (rawMessageInfo.id) { invariant( rawMessageInfo.type !== messageTypes.SIDEBAR_SOURCE && rawMessageInfo.type !== messageTypes.REACTION, 'SIDEBAR_SOURCE should not point to a SIDEBAR_SOURCE or REACTION', ); 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); } export { fetchCollapsableNotifs, fetchMessageInfos, fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, fetchMessageInfoForLocalID, fetchMessageInfoForEntryAction, fetchMessageInfoByID, }; diff --git a/keyserver/src/fetchers/thread-permission-fetchers.js b/keyserver/src/fetchers/thread-permission-fetchers.js index 940a82823..bca2ece06 100644 --- a/keyserver/src/fetchers/thread-permission-fetchers.js +++ b/keyserver/src/fetchers/thread-permission-fetchers.js @@ -1,366 +1,363 @@ // @flow import { permissionLookup, makePermissionsBlob, getRoleForPermissions, } from 'lib/permissions/thread-permissions.js'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils.js'; import { threadFrozenDueToBlock, permissionsDisabledByBlock, } from 'lib/shared/thread-utils.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; import type { ThreadType, ThreadPermission, ThreadPermissionsBlob, ThreadRolePermissionsBlob, } from 'lib/types/thread-types.js'; import { fetchThreadInfos } from './thread-fetchers.js'; import { fetchKnownUserInfos } from './user-fetchers.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; // Note that it's risky to verify permissions by inspecting the blob directly. // There are other factors that can override permissions in the permissions // blob, such as when one user blocks another. It's always better to go through // checkThreads and friends, or by looking at the ThreadInfo through // threadHasPermission. async function fetchThreadPermissionsBlob( viewer: Viewer, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT permissions FROM memberships WHERE thread = ${threadID} AND user = ${viewerID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return JSON.parse(row.permissions); } function checkThreadPermission( viewer: Viewer, threadID: string, permission: ThreadPermission, ): Promise { return checkThread(viewer, threadID, [{ check: 'permission', permission }]); } function viewerIsMember(viewer: Viewer, threadID: string): Promise { return checkThread(viewer, threadID, [{ check: 'is_member' }]); } type Check = | { +check: 'is_member' } | { +check: 'permission', +permission: ThreadPermission }; function isThreadValid( permissions: ?ThreadPermissionsBlob, role: number, checks: $ReadOnlyArray, ): boolean { for (const check of checks) { if (check.check === 'is_member') { if (role <= 0) { return false; } } else if (check.check === 'permission') { if (!permissionLookup(permissions, check.permission)) { return false; } } } return true; } async function checkThreads( viewer: Viewer, threadIDs: $ReadOnlyArray, checks: $ReadOnlyArray, ): Promise> { if (viewer.isScriptViewer) { // script viewers are all-powerful return new Set(threadIDs); } const threadRows = await getValidThreads(viewer, threadIDs, checks); return new Set(threadRows.map(row => row.threadID)); } type PartialMembershipRow = { +threadID: string, +role: number, +permissions: ThreadPermissionsBlob, }; async function getValidThreads( viewer: Viewer, threadIDs: $ReadOnlyArray, checks: $ReadOnlyArray, ): Promise { const query = SQL` SELECT thread AS threadID, permissions, role FROM memberships WHERE thread IN (${threadIDs}) AND user = ${viewer.userID} `; const permissionsToCheck = []; for (const check of checks) { if (check.check === 'permission') { permissionsToCheck.push(check.permission); } } const [[result], disabledThreadIDs] = await Promise.all([ dbQuery(query), checkThreadsFrozen(viewer, permissionsToCheck, threadIDs), ]); return result .map(row => ({ ...row, threadID: row.threadID.toString(), permissions: JSON.parse(row.permissions), })) .filter( row => isThreadValid(row.permissions, row.role, checks) && !disabledThreadIDs.has(row.threadID), ); } async function checkThreadsFrozen( viewer: Viewer, permissionsToCheck: $ReadOnlyArray, threadIDs: $ReadOnlyArray, ) { const threadIDsWithDisabledPermissions = new Set(); const permissionMightBeDisabled = permissionsToCheck.some(permission => permissionsDisabledByBlock.has(permission), ); if (!permissionMightBeDisabled) { return threadIDsWithDisabledPermissions; } const [{ threadInfos }, userInfos] = await Promise.all([ fetchThreadInfos(viewer, SQL`t.id IN (${[...threadIDs]})`), fetchKnownUserInfos(viewer), ]); for (const threadID in threadInfos) { const blockedThread = threadFrozenDueToBlock( threadInfos[threadID], viewer.id, userInfos, ); if (blockedThread) { threadIDsWithDisabledPermissions.add(threadID); } } return threadIDsWithDisabledPermissions; } async function checkIfThreadIsBlocked( viewer: Viewer, threadID: string, permission: ThreadPermission, ): Promise { const disabledThreadIDs = await checkThreadsFrozen( viewer, [permission], [threadID], ); return disabledThreadIDs.has(threadID); } async function checkThread( viewer: Viewer, threadID: string, checks: $ReadOnlyArray, ): Promise { const validThreads = await checkThreads(viewer, [threadID], checks); return validThreads.has(threadID); } // We pass this into getRoleForPermissions in order to check if a hypothetical // permissions blob would block membership by returning a non-positive result. // It doesn't matter what value we pass in, as long as it's positive. const arbitraryPositiveRole = '1'; type CandidateMembers = { +[key: string]: ?$ReadOnlyArray, ... }; type ValidateCandidateMembersParams = { +threadType: ThreadType, +parentThreadID: ?string, +containingThreadID: ?string, +defaultRolePermissions: ThreadRolePermissionsBlob, }; type ValidateCandidateMembersOptions = { +requireRelationship?: boolean }; async function validateCandidateMembers( viewer: Viewer, candidates: CandidateMembers, params: ValidateCandidateMembersParams, options?: ValidateCandidateMembersOptions, ): Promise { const requireRelationship = options?.requireRelationship ?? true; const allCandidatesSet = new Set(); for (const key in candidates) { const candidateGroup = candidates[key]; if (!candidateGroup) { continue; } for (const candidate of candidateGroup) { allCandidatesSet.add(candidate); } } const allCandidates = [...allCandidatesSet]; const fetchMembersPromise = fetchKnownUserInfos(viewer, allCandidates); const parentPermissionsPromise = (async () => { const parentPermissions = {}; if (!params.parentThreadID || allCandidates.length === 0) { return parentPermissions; } const parentPermissionsQuery = SQL` SELECT user, permissions FROM memberships WHERE thread = ${params.parentThreadID} AND user IN (${allCandidates}) `; const [result] = await dbQuery(parentPermissionsQuery); for (const row of result) { parentPermissions[row.user.toString()] = JSON.parse(row.permissions); } return parentPermissions; })(); const memberOfContainingThreadPromise: Promise< Map, > = (async () => { const results = new Map(); if (allCandidates.length === 0) { return results; } if (!params.containingThreadID) { for (const userID of allCandidates) { results.set(userID, 'no-containing-thread'); } return results; } const memberOfContainingThreadQuery = SQL` SELECT user, role AS containing_role FROM memberships WHERE thread = ${params.containingThreadID} AND user IN (${allCandidates}) `; const [result] = await dbQuery(memberOfContainingThreadQuery); for (const row of result) { results.set( row.user.toString(), row.containing_role > 0 ? 'member' : 'non-member', ); } return results; })(); - const [ - fetchedMembers, - parentPermissions, - memberOfContainingThread, - ] = await Promise.all([ - fetchMembersPromise, - parentPermissionsPromise, - memberOfContainingThreadPromise, - ]); + const [fetchedMembers, parentPermissions, memberOfContainingThread] = + await Promise.all([ + fetchMembersPromise, + parentPermissionsPromise, + memberOfContainingThreadPromise, + ]); const ignoreMembers = new Set(); for (const memberID of allCandidates) { const member = fetchedMembers[memberID]; if (!member && requireRelationship) { ignoreMembers.add(memberID); continue; } const relationshipStatus = member?.relationshipStatus; const memberRelationshipHasBlock = !!( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ); if (memberRelationshipHasBlock) { ignoreMembers.add(memberID); continue; } const permissionsFromParent = parentPermissions[memberID]; if (memberOfContainingThread.get(memberID) === 'non-member') { ignoreMembers.add(memberID); continue; } if ( memberOfContainingThread.get(memberID) === 'no-containing-thread' && relationshipStatus !== userRelationshipStatus.FRIEND && requireRelationship ) { ignoreMembers.add(memberID); continue; } const permissions = makePermissionsBlob( params.defaultRolePermissions, permissionsFromParent, '-1', params.threadType, ); if (!permissions) { ignoreMembers.add(memberID); continue; } const targetRole = getRoleForPermissions( arbitraryPositiveRole, permissions, ); if (Number(targetRole) <= 0) { ignoreMembers.add(memberID); continue; } } if (ignoreMembers.size === 0) { return candidates; } const result = {}; for (const key in candidates) { const candidateGroup = candidates[key]; if (!candidateGroup) { result[key] = candidates[key]; continue; } result[key] = []; for (const candidate of candidateGroup) { if (!ignoreMembers.has(candidate)) { result[key].push(candidate); } } } return result; } export { fetchThreadPermissionsBlob, checkThreadPermission, viewerIsMember, checkThreads, getValidThreads, checkThread, checkIfThreadIsBlocked, validateCandidateMembers, }; diff --git a/keyserver/src/fetchers/upload-fetchers.js b/keyserver/src/fetchers/upload-fetchers.js index 6920b7216..6a778623d 100644 --- a/keyserver/src/fetchers/upload-fetchers.js +++ b/keyserver/src/fetchers/upload-fetchers.js @@ -1,209 +1,208 @@ // @flow import _keyBy from 'lodash/fp/keyBy.js'; import type { Media } from 'lib/types/media-types.js'; import type { MediaMessageServerDBContent } from 'lib/types/messages/media.js'; import { getUploadIDsFromMediaMessageServerDBContents } from 'lib/types/messages/media.js'; import { ServerError } from 'lib/utils/errors.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; import { getAndAssertCommAppURLFacts } from '../utils/urls.js'; type UploadInfo = { content: Buffer, mime: string, }; async function fetchUpload( viewer: Viewer, id: string, secret: string, ): Promise { const query = SQL` SELECT content, mime FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { content, mime } = row; return { content, mime }; } async function fetchUploadChunk( id: string, secret: string, pos: number, len: number, ): Promise { // We use pos + 1 because SQL is 1-indexed whereas js is 0-indexed const query = SQL` SELECT SUBSTRING(content, ${pos + 1}, ${len}) AS content, mime FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { content, mime } = row; return { content, mime, }; } // Returns total size in bytes. async function getUploadSize(id: string, secret: string): Promise { const query = SQL` SELECT LENGTH(content) AS length FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { length } = row; return length; } function getUploadURL(id: string, secret: string): string { const { baseDomain, basePath } = getAndAssertCommAppURLFacts(); return `${baseDomain}${basePath}upload/${id}/${secret}`; } function mediaFromRow(row: Object): Media { const uploadExtra = JSON.parse(row.uploadExtra); const { width, height, loop } = uploadExtra; const { uploadType: type, uploadSecret: secret } = row; const id = row.uploadID.toString(); const dimensions = { width, height }; const uri = getUploadURL(id, secret); if (type === 'photo') { return { id, type: 'photo', uri, dimensions }; } else if (loop) { // $FlowFixMe add thumbnailID, thumbnailURI once they're in DB return { id, type: 'video', uri, dimensions, loop }; } else { // $FlowFixMe add thumbnailID, thumbnailURI once they're in DB return { id, type: 'video', uri, dimensions }; } } async function fetchMedia( viewer: Viewer, mediaIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const query = SQL` SELECT id AS uploadID, secret AS uploadSecret, type AS uploadType, extra AS uploadExtra FROM uploads WHERE id IN (${mediaIDs}) AND uploader = ${viewer.id} AND container IS NULL `; const [result] = await dbQuery(query); return result.map(mediaFromRow); } async function fetchUploadsForMessage( viewer: Viewer, mediaMessageContents: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { - const uploadIDs = getUploadIDsFromMediaMessageServerDBContents( - mediaMessageContents, - ); + const uploadIDs = + getUploadIDsFromMediaMessageServerDBContents(mediaMessageContents); const query = SQL` SELECT id AS uploadID, secret AS uploadSecret, type AS uploadType, extra AS uploadExtra FROM uploads WHERE id IN (${uploadIDs}) AND uploader = ${viewer.id} AND container IS NULL `; const [uploads] = await dbQuery(query); return uploads; } async function fetchMediaFromMediaMessageContent( viewer: Viewer, mediaMessageContents: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const uploads = await fetchUploadsForMessage(viewer, mediaMessageContents); return constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents, uploads, ); } function constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents: $ReadOnlyArray, uploadRows: $ReadOnlyArray, ): $ReadOnlyArray { const uploadMap = _keyBy('uploadID')(uploadRows); const media: Media[] = []; for (const mediaMessageContent of mediaMessageContents) { const primaryUploadID = mediaMessageContent.uploadID; const primaryUpload = uploadMap[primaryUploadID]; const primaryUploadSecret = primaryUpload.uploadSecret; const primaryUploadURI = getUploadURL(primaryUploadID, primaryUploadSecret); const uploadExtra = JSON.parse(primaryUpload.uploadExtra); const { width, height, loop } = uploadExtra; const dimensions = { width, height }; if (mediaMessageContent.type === 'photo') { media.push({ type: 'photo', id: primaryUploadID, uri: primaryUploadURI, dimensions, }); continue; } const thumbnailUploadID = mediaMessageContent.thumbnailUploadID; const thumbnailUpload = uploadMap[thumbnailUploadID]; const thumbnailUploadSecret = thumbnailUpload.uploadSecret; const thumbnailUploadURI = getUploadURL( thumbnailUploadID, thumbnailUploadSecret, ); const video = { type: 'video', id: primaryUploadID, uri: primaryUploadURI, dimensions, thumbnailID: thumbnailUploadID, thumbnailURI: thumbnailUploadURI, }; media.push(loop ? { ...video, loop } : video); } return media; } export { fetchUpload, fetchUploadChunk, getUploadSize, getUploadURL, mediaFromRow, fetchMedia, fetchMediaFromMediaMessageContent, constructMediaFromMediaMessageContentsAndUploadRows, }; diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js index 5c97efefb..c2990b9ec 100644 --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -1,844 +1,842 @@ // @flow import apn from '@parse/node-apn'; import type { ResponseFailure } from '@parse/node-apn'; import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep.js'; import _flow from 'lodash/fp/flow.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _pickBy from 'lodash/fp/pickBy.js'; import uuidv4 from 'uuid/v4.js'; import { oldValidUsernameRegex } from 'lib/shared/account-utils.js'; import { createMessageInfo, sortMessageInfoList, shimUnsupportedRawMessageInfos, } from 'lib/shared/message-utils.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { notifTextsForMessageInfo } from 'lib/shared/notif-utils.js'; import { rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, } from 'lib/shared/thread-utils.js'; import type { DeviceType } from 'lib/types/device-types.js'; import { type RawMessageInfo, type MessageInfo, messageTypes, } from 'lib/types/message-types.js'; import type { ServerThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types.js'; import { promiseAll } from 'lib/utils/promises.js'; import { getAPNsNotificationTopic } from './providers.js'; import { apnPush, fcmPush, getUnreadCounts, apnMaxNotificationPayloadByteSize, fcmMaxNotificationPayloadByteSize, } from './utils.js'; import createIDs from '../creators/id-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL, mergeOrConditions } from '../database/database.js'; import type { CollapsableNotifInfo } from '../fetchers/message-fetchers.js'; import { fetchCollapsableNotifs } from '../fetchers/message-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { getENSNames } from '../utils/ens-cache.js'; type Device = { +deviceType: DeviceType, +deviceToken: string, +codeVersion: ?number, }; type PushUserInfo = { +devices: Device[], +messageInfos: RawMessageInfo[], }; type Delivery = IOSDelivery | AndroidDelivery | { collapsedInto: string }; type NotificationRow = { +dbID: string, +userID: string, +threadID?: ?string, +messageID?: ?string, +collapseKey?: ?string, +deliveries: Delivery[], }; export type PushInfo = { [userID: string]: PushUserInfo }; async function sendPushNotifs(pushInfo: PushInfo) { if (Object.keys(pushInfo).length === 0) { return; } const [ unreadCounts, { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }, dbIDs, ] = await Promise.all([ getUnreadCounts(Object.keys(pushInfo)), fetchInfos(pushInfo), createDBIDs(pushInfo), ]); const deliveryPromises = []; const notifications: Map = new Map(); for (const userID in usersToCollapsableNotifInfo) { const threadInfos = _flow( _mapValues((serverThreadInfo: ServerThreadInfo) => { const rawThreadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, userID, ); if (!rawThreadInfo) { return null; } return threadInfoFromRawThreadInfo(rawThreadInfo, userID, userInfos); }), _pickBy(threadInfo => threadInfo), )(serverThreadInfos); for (const notifInfo of usersToCollapsableNotifInfo[userID]) { const hydrateMessageInfo = (rawMessageInfo: RawMessageInfo) => createMessageInfo(rawMessageInfo, userID, userInfos, threadInfos); const newMessageInfos = []; const newRawMessageInfos = []; for (const newRawMessageInfo of notifInfo.newMessageInfos) { const newMessageInfo = hydrateMessageInfo(newRawMessageInfo); if (newMessageInfo) { newMessageInfos.push(newMessageInfo); newRawMessageInfos.push(newRawMessageInfo); } } if (newMessageInfos.length === 0) { continue; } const existingMessageInfos = notifInfo.existingMessageInfos .map(hydrateMessageInfo) .filter(Boolean); const allMessageInfos = sortMessageInfoList([ ...newMessageInfos, ...existingMessageInfos, ]); - const [ - firstNewMessageInfo, - ...remainingNewMessageInfos - ] = newMessageInfos; + const [firstNewMessageInfo, ...remainingNewMessageInfos] = + newMessageInfos; const threadID = firstNewMessageInfo.threadID; const threadInfo = threadInfos[threadID]; const updateBadge = threadInfo.currentUser.subscription.home; const displayBanner = threadInfo.currentUser.subscription.pushNotifs; const username = userInfos[userID] && userInfos[userID].username; const userWasMentioned = username && threadInfo.currentUser.role && oldValidUsernameRegex.test(username) && firstNewMessageInfo.type === messageTypes.TEXT && new RegExp(`\\B@${username}\\b`, 'i').test(firstNewMessageInfo.text); if (!updateBadge && !displayBanner && !userWasMentioned) { continue; } const badgeOnly = !displayBanner && !userWasMentioned; const dbID = dbIDs.shift(); invariant(dbID, 'should have sufficient DB IDs'); const byDeviceType = getDevicesByDeviceType(pushInfo[userID].devices); const firstMessageID = firstNewMessageInfo.id; invariant(firstMessageID, 'RawMessageInfo.id should be set on server'); const notificationInfo = { source: 'new_message', dbID, userID, threadID, messageID: firstMessageID, collapseKey: notifInfo.collapseKey, }; const iosVersionsToTokens = byDeviceType.get('ios'); if (iosVersionsToTokens) { for (const [codeVer, deviceTokens] of iosVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, { platform: 'ios', codeVersion }, ); const deliveryPromise = (async () => { const notification = await prepareIOSNotification( allMessageInfos, shimmedNewRawMessageInfos, threadInfo, notifInfo.collapseKey, badgeOnly, unreadCounts[userID], codeVersion, ); return await sendIOSNotification(notification, [...deviceTokens], { ...notificationInfo, codeVersion, }); })(); deliveryPromises.push(deliveryPromise); } } const androidVersionsToTokens = byDeviceType.get('android'); if (androidVersionsToTokens) { for (const [codeVer, deviceTokens] of androidVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, { platform: 'android', codeVersion }, ); const deliveryPromise = (async () => { const notification = await prepareAndroidNotification( allMessageInfos, shimmedNewRawMessageInfos, threadInfo, notifInfo.collapseKey, badgeOnly, unreadCounts[userID], dbID, codeVersion, ); return await sendAndroidNotification( notification, [...deviceTokens], { ...notificationInfo, codeVersion, }, ); })(); deliveryPromises.push(deliveryPromise); } } for (const newMessageInfo of remainingNewMessageInfos) { const newDBID = dbIDs.shift(); invariant(newDBID, 'should have sufficient DB IDs'); const messageID = newMessageInfo.id; invariant(messageID, 'RawMessageInfo.id should be set on server'); notifications.set(newDBID, { dbID: newDBID, userID, threadID: newMessageInfo.threadID, messageID, collapseKey: notifInfo.collapseKey, deliveries: [{ collapsedInto: dbID }], }); } } } const cleanUpPromises = []; if (dbIDs.length > 0) { const query = SQL`DELETE FROM ids WHERE id IN (${dbIDs})`; cleanUpPromises.push(dbQuery(query)); } const [deliveryResults] = await Promise.all([ Promise.all(deliveryPromises), Promise.all(cleanUpPromises), ]); await saveNotifResults(deliveryResults, notifications, true); } // The results in deliveryResults will be combined with the rows // in rowsToSave and then written to the notifications table async function saveNotifResults( deliveryResults: $ReadOnlyArray, inputRowsToSave: Map, rescindable: boolean, ) { const rowsToSave = new Map(inputRowsToSave); const allInvalidTokens = []; for (const deliveryResult of deliveryResults) { const { info, delivery, invalidTokens } = deliveryResult; const { dbID, userID } = info; const curNotifRow = rowsToSave.get(dbID); if (curNotifRow) { curNotifRow.deliveries.push(delivery); } else { // Ternary expressions for Flow const threadID = info.threadID ? info.threadID : null; const messageID = info.messageID ? info.messageID : null; const collapseKey = info.collapseKey ? info.collapseKey : null; rowsToSave.set(dbID, { dbID, userID, threadID, messageID, collapseKey, deliveries: [delivery], }); } if (invalidTokens) { allInvalidTokens.push({ userID, tokens: invalidTokens, }); } } const notificationRows = []; for (const notification of rowsToSave.values()) { notificationRows.push([ notification.dbID, notification.userID, notification.threadID, notification.messageID, notification.collapseKey, JSON.stringify(notification.deliveries), Number(!rescindable), ]); } const dbPromises = []; if (allInvalidTokens.length > 0) { dbPromises.push(removeInvalidTokens(allInvalidTokens)); } if (notificationRows.length > 0) { const query = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${notificationRows} `; dbPromises.push(dbQuery(query)); } if (dbPromises.length > 0) { await Promise.all(dbPromises); } } async function fetchInfos(pushInfo: PushInfo) { const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(pushInfo); const threadIDs = new Set(); const threadWithChangedNamesToMessages = new Map(); const addThreadIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { const threadID = rawMessageInfo.threadID; threadIDs.add(threadID); const messageSpec = messageSpecs[rawMessageInfo.type]; if (messageSpec.threadIDs) { for (const id of messageSpec.threadIDs(rawMessageInfo)) { threadIDs.add(id); } } if ( rawMessageInfo.type === messageTypes.CHANGE_SETTINGS && rawMessageInfo.field === 'name' ) { const messages = threadWithChangedNamesToMessages.get(threadID); if (messages) { messages.push(rawMessageInfo.id); } else { threadWithChangedNamesToMessages.set(threadID, [rawMessageInfo.id]); } } }; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } } } const promises = {}; // These threadInfos won't have currentUser set promises.threadResult = fetchServerThreadInfos( SQL`t.id IN (${[...threadIDs]})`, ); if (threadWithChangedNamesToMessages.size > 0) { const typesThatAffectName = [ messageTypes.CHANGE_SETTINGS, messageTypes.CREATE_THREAD, ]; const oldNameQuery = SQL` SELECT IF( JSON_TYPE(JSON_EXTRACT(m.content, "$.name")) = 'NULL', "", JSON_UNQUOTE(JSON_EXTRACT(m.content, "$.name")) ) AS name, m.thread FROM ( SELECT MAX(id) AS id FROM messages WHERE type IN (${typesThatAffectName}) AND JSON_EXTRACT(content, "$.name") IS NOT NULL AND`; const threadClauses = []; for (const [threadID, messages] of threadWithChangedNamesToMessages) { threadClauses.push( SQL`(thread = ${threadID} AND id NOT IN (${messages}))`, ); } oldNameQuery.append(mergeOrConditions(threadClauses)); oldNameQuery.append(SQL` GROUP BY thread ) x LEFT JOIN messages m ON m.id = x.id `); promises.oldNames = dbQuery(oldNameQuery); } const { threadResult, oldNames } = await promiseAll(promises); const serverThreadInfos = threadResult.threadInfos; if (oldNames) { const [result] = oldNames; for (const row of result) { const threadID = row.thread.toString(); serverThreadInfos[threadID].name = row.name; } } const userInfos = await fetchNotifUserInfos( serverThreadInfos, usersToCollapsableNotifInfo, ); return { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }; } async function fetchNotifUserInfos( serverThreadInfos: { +[threadID: string]: ServerThreadInfo }, usersToCollapsableNotifInfo: { +[userID: string]: CollapsableNotifInfo[] }, ) { const missingUserIDs = new Set(); for (const threadID in serverThreadInfos) { const serverThreadInfo = serverThreadInfos[threadID]; for (const member of serverThreadInfo.members) { missingUserIDs.add(member.id); } } const addUserIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { missingUserIDs.add(rawMessageInfo.creatorID); const userIDs = messageSpecs[rawMessageInfo.type].userIDs?.(rawMessageInfo) ?? []; for (const userID of userIDs) { missingUserIDs.add(userID); } }; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } } } return await fetchUserInfos([...missingUserIDs]); } async function createDBIDs(pushInfo: PushInfo): Promise { let numIDsNeeded = 0; for (const userID in pushInfo) { numIDsNeeded += pushInfo[userID].messageInfos.length; } return await createIDs('notifications', numIDsNeeded); } function getDevicesByDeviceType( devices: Device[], ): Map>> { const byDeviceType = new Map(); for (const device of devices) { let innerMap = byDeviceType.get(device.deviceType); if (!innerMap) { innerMap = new Map(); byDeviceType.set(device.deviceType, innerMap); } const codeVersion: number = device.codeVersion !== null && device.codeVersion !== undefined ? device.codeVersion : -1; let innerMostSet = innerMap.get(codeVersion); if (!innerMostSet) { innerMostSet = new Set(); innerMap.set(codeVersion, innerMostSet); } innerMostSet.add(device.deviceToken); } return byDeviceType; } async function prepareIOSNotification( allMessageInfos: MessageInfo[], newRawMessageInfos: RawMessageInfo[], threadInfo: ThreadInfo, collapseKey: ?string, badgeOnly: boolean, unreadCount: number, codeVersion: number, ): Promise { const uniqueID = uuidv4(); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic(codeVersion); const { merged, ...rest } = await notifTextsForMessageInfo( allMessageInfos, threadInfo, getENSNames, ); if (!badgeOnly) { notification.body = merged; notification.sound = 'default'; } notification.payload = { ...notification.payload, ...rest, }; notification.badge = unreadCount; notification.threadId = threadInfo.id; notification.id = uniqueID; notification.pushType = 'alert'; notification.payload.id = uniqueID; notification.payload.threadID = threadInfo.id; if (codeVersion > 1000) { notification.mutableContent = true; } if (collapseKey) { notification.collapseId = collapseKey; } const messageInfos = JSON.stringify(newRawMessageInfos); // We make a copy before checking notification's length, because calling // length compiles the notification and makes it immutable. Further // changes to its properties won't be reflected in the final plaintext // data that is sent. const copyWithMessageInfos = _cloneDeep(notification); copyWithMessageInfos.payload = { ...copyWithMessageInfos.payload, messageInfos, }; if (copyWithMessageInfos.length() <= apnMaxNotificationPayloadByteSize) { notification.payload.messageInfos = messageInfos; return notification; } const notificationCopy = _cloneDeep(notification); if (notificationCopy.length() > apnMaxNotificationPayloadByteSize) { console.warn( `iOS notification ${uniqueID} exceeds size limit, even with messageInfos omitted`, ); } return notification; } async function prepareAndroidNotification( allMessageInfos: MessageInfo[], newRawMessageInfos: RawMessageInfo[], threadInfo: ThreadInfo, collapseKey: ?string, badgeOnly: boolean, unreadCount: number, dbID: string, codeVersion: number, ): Promise { const notifID = collapseKey ? collapseKey : dbID; const { merged, ...rest } = await notifTextsForMessageInfo( allMessageInfos, threadInfo, getENSNames, ); const notification = { data: { badge: unreadCount.toString(), ...rest, threadID: threadInfo.id, }, }; // The reason we only include `badgeOnly` for newer clients is because older // clients don't know how to parse it. The reason we only include `id` for // newer clients is that if the older clients see that field, they assume // the notif has a full payload, and then crash when trying to parse it. // By skipping `id` we allow old clients to still handle in-app notifs and // badge updating. if (!badgeOnly || codeVersion >= 69) { notification.data = { ...notification.data, id: notifID, badgeOnly: badgeOnly ? '1' : '0', }; } const messageInfos = JSON.stringify(newRawMessageInfos); const copyWithMessageInfos = { ...notification, data: { ...notification.data, messageInfos }, }; if ( Buffer.byteLength(JSON.stringify(copyWithMessageInfos)) <= fcmMaxNotificationPayloadByteSize ) { return copyWithMessageInfos; } if ( Buffer.byteLength(JSON.stringify(notification)) > fcmMaxNotificationPayloadByteSize ) { console.warn( `Android notification ${notifID} exceeds size limit, even with messageInfos omitted`, ); } return notification; } type NotificationInfo = | { +source: 'new_message', +dbID: string, +userID: string, +threadID: string, +messageID: string, +collapseKey: ?string, +codeVersion: number, } | { +source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', +dbID: string, +userID: string, +codeVersion: number, }; type IOSDelivery = { source: $PropertyType, deviceType: 'ios', iosID: string, deviceTokens: $ReadOnlyArray, codeVersion: number, errors?: $ReadOnlyArray, }; type IOSResult = { info: NotificationInfo, delivery: IOSDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendIOSNotification( notification: apn.Notification, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion } = notificationInfo; const response = await apnPush({ notification, deviceTokens, codeVersion }); const delivery: IOSDelivery = { source, deviceType: 'ios', iosID: notification.id, deviceTokens, codeVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: IOSResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type AndroidDelivery = { source: $PropertyType, deviceType: 'android', androidIDs: $ReadOnlyArray, deviceTokens: $ReadOnlyArray, codeVersion: number, errors?: $ReadOnlyArray, }; type AndroidResult = { info: NotificationInfo, delivery: AndroidDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendAndroidNotification( notification: Object, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const collapseKey = notificationInfo.collapseKey ? notificationInfo.collapseKey : null; // for Flow... const { source, codeVersion } = notificationInfo; const response = await fcmPush({ notification, deviceTokens, collapseKey, codeVersion, }); const androidIDs = response.fcmIDs ? response.fcmIDs : []; const delivery: AndroidDelivery = { source, deviceType: 'android', androidIDs, deviceTokens, codeVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: AndroidResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type InvalidToken = { +userID: string, +tokens: $ReadOnlyArray, }; async function removeInvalidTokens( invalidTokens: $ReadOnlyArray, ): Promise { const sqlTuples = invalidTokens.map( invalidTokenUser => SQL`( user = ${invalidTokenUser.userID} AND device_token IN (${invalidTokenUser.tokens}) )`, ); const sqlCondition = mergeOrConditions(sqlTuples); const selectQuery = SQL` SELECT id, user, device_token FROM cookies WHERE `; selectQuery.append(sqlCondition); const [result] = await dbQuery(selectQuery); const userCookiePairsToInvalidDeviceTokens = new Map(); for (const row of result) { const userCookiePair = `${row.user}|${row.id}`; const existing = userCookiePairsToInvalidDeviceTokens.get(userCookiePair); if (existing) { existing.add(row.device_token); } else { userCookiePairsToInvalidDeviceTokens.set( userCookiePair, new Set([row.device_token]), ); } } const time = Date.now(); const promises = []; for (const entry of userCookiePairsToInvalidDeviceTokens) { const [userCookiePair, deviceTokens] = entry; const [userID, cookieID] = userCookiePair.split('|'); const updateDatas = [...deviceTokens].map(deviceToken => ({ type: updateTypes.BAD_DEVICE_TOKEN, userID, time, deviceToken, targetCookie: cookieID, })); promises.push(createUpdates(updateDatas)); } const updateQuery = SQL` UPDATE cookies SET device_token = NULL WHERE `; updateQuery.append(sqlCondition); promises.push(dbQuery(updateQuery)); await Promise.all(promises); } async function updateBadgeCount( viewer: Viewer, source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', ) { const { userID } = viewer; const deviceTokenQuery = SQL` SELECT platform, device_token, versions FROM cookies WHERE user = ${userID} AND device_token IS NOT NULL `; if (viewer.data.cookieID) { deviceTokenQuery.append(SQL`AND id != ${viewer.cookieID} `); } const [unreadCounts, [deviceTokenResult], [dbID]] = await Promise.all([ getUnreadCounts([userID]), dbQuery(deviceTokenQuery), createIDs('notifications', 1), ]); const unreadCount = unreadCounts[userID]; const devices = deviceTokenResult.map(row => ({ deviceType: row.platform, deviceToken: row.device_token, codeVersion: JSON.parse(row.versions)?.codeVersion, })); const byDeviceType = getDevicesByDeviceType(devices); const deliveryPromises = []; const iosVersionsToTokens = byDeviceType.get('ios'); if (iosVersionsToTokens) { for (const [codeVer, deviceTokens] of iosVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic(codeVersion); notification.badge = unreadCount; notification.pushType = 'alert'; deliveryPromises.push( sendIOSNotification(notification, [...deviceTokens], { source, dbID, userID, codeVersion, }), ); } } const androidVersionsToTokens = byDeviceType.get('android'); if (androidVersionsToTokens) { for (const [codeVer, deviceTokens] of androidVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const notificationData = codeVersion < 69 ? { badge: unreadCount.toString() } : { badge: unreadCount.toString(), badgeOnly: '1' }; const notification = { data: notificationData }; deliveryPromises.push( sendAndroidNotification(notification, [...deviceTokens], { source, dbID, userID, codeVersion, }), ); } } const deliveryResults = await Promise.all(deliveryPromises); await saveNotifResults(deliveryResults, new Map(), false); } export { sendPushNotifs, updateBadgeCount }; diff --git a/keyserver/src/responders/entry-responders.js b/keyserver/src/responders/entry-responders.js index e2c828a05..1ec49e652 100644 --- a/keyserver/src/responders/entry-responders.js +++ b/keyserver/src/responders/entry-responders.js @@ -1,255 +1,252 @@ // @flow import t from 'tcomb'; import type { TInterface } from 'tcomb'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js'; import type { CalendarQuery, SaveEntryRequest, CreateEntryRequest, DeleteEntryRequest, DeleteEntryResponse, RestoreEntryRequest, RestoreEntryResponse, FetchEntryInfosResponse, DeltaEntryInfosResult, SaveEntryResponse, } from 'lib/types/entry-types.js'; import { calendarThreadFilterTypes } from 'lib/types/filter-types.js'; import type { FetchEntryRevisionInfosResult, FetchEntryRevisionInfosRequest, } from 'lib/types/history-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { tString, tShape, tDate } from 'lib/utils/validation-utils.js'; import createEntry from '../creators/entry-creator.js'; import { deleteEntry, restoreEntry } from '../deleters/entry-deleters.js'; import { fetchEntryInfos, fetchEntryRevisionInfo, fetchEntriesForSession, } from '../fetchers/entry-fetchers.js'; import { verifyThreadIDs } from '../fetchers/thread-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { updateEntry, compareNewCalendarQuery, } from '../updaters/entry-updaters.js'; import { commitSessionUpdate } from '../updaters/session-updaters.js'; import { validateInput } from '../utils/validation-utils.js'; const entryQueryInputValidator: TInterface = tShape({ navID: t.maybe(t.String), startDate: tDate, endDate: tDate, includeDeleted: t.maybe(t.Boolean), filters: t.maybe( t.list( t.union([ tShape({ type: tString(calendarThreadFilterTypes.NOT_DELETED), }), tShape({ type: tString(calendarThreadFilterTypes.THREAD_LIST), threadIDs: t.list(t.String), }), ]), ), ), }); const newEntryQueryInputValidator: TInterface = tShape({ startDate: tDate, endDate: tDate, filters: t.list( t.union([ tShape({ type: tString(calendarThreadFilterTypes.NOT_DELETED), }), tShape({ type: tString(calendarThreadFilterTypes.THREAD_LIST), threadIDs: t.list(t.String), }), ]), ), }); function normalizeCalendarQuery(input: any): CalendarQuery { if (input.filters) { return { startDate: input.startDate, endDate: input.endDate, filters: input.filters, }; } const filters = []; if (!input.includeDeleted) { filters.push({ type: calendarThreadFilterTypes.NOT_DELETED }); } if (input.navID !== 'home') { filters.push({ type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: [input.navID], }); } return { startDate: input.startDate, endDate: input.endDate, filters, }; } async function verifyCalendarQueryThreadIDs( request: CalendarQuery, ): Promise { const threadIDsToFilterTo = filteredThreadIDs(request.filters); if (threadIDsToFilterTo && threadIDsToFilterTo.size > 0) { const verifiedThreadIDs = await verifyThreadIDs([...threadIDsToFilterTo]); if (verifiedThreadIDs.length !== threadIDsToFilterTo.size) { throw new ServerError('invalid_parameters'); } } } async function entryFetchResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, entryQueryInputValidator, input); const request = normalizeCalendarQuery(input); await verifyCalendarQueryThreadIDs(request); const response = await fetchEntryInfos(viewer, [request]); return { ...response, userInfos: {} }; } const entryRevisionHistoryFetchInputValidator = tShape({ id: t.String, }); async function entryRevisionFetchResponder( viewer: Viewer, input: any, ): Promise { const request: FetchEntryRevisionInfosRequest = input; await validateInput(viewer, entryRevisionHistoryFetchInputValidator, request); const entryHistory = await fetchEntryRevisionInfo(viewer, request.id); return { result: entryHistory }; } const createEntryRequestInputValidator = tShape({ text: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, date: tDate, threadID: t.String, localID: t.maybe(t.String), calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryCreationResponder( viewer: Viewer, input: any, ): Promise { const request: CreateEntryRequest = input; await validateInput(viewer, createEntryRequestInputValidator, request); return await createEntry(viewer, request); } const saveEntryRequestInputValidator = tShape({ entryID: t.String, text: t.String, prevText: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SaveEntryRequest = input; await validateInput(viewer, saveEntryRequestInputValidator, request); return await updateEntry(viewer, request); } const deleteEntryRequestInputValidator = tShape({ entryID: t.String, prevText: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteEntryRequest = input; await validateInput(viewer, deleteEntryRequestInputValidator, request); return await deleteEntry(viewer, request); } const restoreEntryRequestInputValidator = tShape({ entryID: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryRestorationResponder( viewer: Viewer, input: any, ): Promise { const request: RestoreEntryRequest = input; await validateInput(viewer, restoreEntryRequestInputValidator, request); return await restoreEntry(viewer, request); } async function calendarQueryUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: CalendarQuery = input; await validateInput(viewer, newEntryQueryInputValidator, input); await verifyCalendarQueryThreadIDs(request); if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } - const { - difference, - oldCalendarQuery, - sessionUpdate, - } = compareNewCalendarQuery(viewer, request); + const { difference, oldCalendarQuery, sessionUpdate } = + compareNewCalendarQuery(viewer, request); const [response] = await Promise.all([ fetchEntriesForSession(viewer, difference, oldCalendarQuery), commitSessionUpdate(viewer, sessionUpdate), ]); return { rawEntryInfos: response.rawEntryInfos, deletedEntryIDs: response.deletedEntryIDs, // Old clients expect userInfos object userInfos: [], }; } export { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, entryFetchResponder, entryRevisionFetchResponder, entryCreationResponder, entryUpdateResponder, entryDeletionResponder, entryRestorationResponder, calendarQueryUpdateResponder, }; diff --git a/keyserver/src/responders/message-responders.js b/keyserver/src/responders/message-responders.js index dd09b132b..51dfad1a7 100644 --- a/keyserver/src/responders/message-responders.js +++ b/keyserver/src/responders/message-responders.js @@ -1,275 +1,272 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import { onlyOneEmojiRegex } from 'lib/shared/emojis.js'; import { createMediaMessageData, trimMessage, } from 'lib/shared/message-utils.js'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils.js'; import type { Media } from 'lib/types/media-types.js'; import { messageTypes, type SendTextMessageRequest, type SendMultimediaMessageRequest, type SendReactionMessageRequest, type FetchMessageInfosResponse, type FetchMessageInfosRequest, defaultNumberPerThread, type SendMessageResponse, } from 'lib/types/message-types.js'; import type { ReactionMessageData } from 'lib/types/messages/reaction.js'; import type { TextMessageData } from 'lib/types/messages/text.js'; import { threadPermissions } from 'lib/types/thread-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { tRegex, tShape, tMediaMessageMedia, } from 'lib/utils/validation-utils.js'; import createMessages from '../creators/message-creator.js'; import { SQL } from '../database/database.js'; import { fetchMessageInfos, fetchMessageInfoForLocalID, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { fetchMedia, fetchMediaFromMediaMessageContent, } from '../fetchers/upload-fetchers.js'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { assignMedia, assignMessageContainerToMedia, } from '../updaters/upload-updaters.js'; import { validateInput } from '../utils/validation-utils.js'; const sendTextMessageRequestInputValidator = tShape({ threadID: t.String, localID: t.maybe(t.String), text: t.String, }); async function textMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendTextMessageRequest = input; await validateInput(viewer, sendTextMessageRequestInputValidator, request); const { threadID, localID, text: rawText } = request; const text = trimMessage(rawText); if (!text) { throw new ServerError('invalid_parameters'); } const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.VOICED, ); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const messageData: TextMessageData = { type: messageTypes.TEXT, threadID, creatorID: viewer.id, time: Date.now(), text, }; if (localID) { messageData.localID = localID; } const rawMessageInfos = await createMessages(viewer, [messageData]); return { newMessageInfo: rawMessageInfos[0] }; } const fetchMessageInfosRequestInputValidator = tShape({ cursors: t.dict(t.String, t.maybe(t.String)), numberPerThread: t.maybe(t.Number), }); async function messageFetchResponder( viewer: Viewer, input: any, ): Promise { const request: FetchMessageInfosRequest = input; await validateInput(viewer, fetchMessageInfosRequestInputValidator, request); const response = await fetchMessageInfos( viewer, { threadCursors: request.cursors }, request.numberPerThread ? request.numberPerThread : defaultNumberPerThread, ); return { ...response, userInfos: {} }; } const sendMultimediaMessageRequestInputValidator = t.union([ tShape({ threadID: t.String, localID: t.String, mediaIDs: t.list(t.String), }), tShape({ threadID: t.String, localID: t.String, mediaMessageContents: t.list(tMediaMessageMedia), }), ]); async function multimediaMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendMultimediaMessageRequest = input; await validateInput( viewer, sendMultimediaMessageRequestInputValidator, request, ); if ( (request.mediaIDs && request.mediaIDs.length === 0) || (request.mediaMessageContents && request.mediaMessageContents.length === 0) ) { throw new ServerError('invalid_parameters'); } const { threadID, localID } = request; const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.VOICED, ); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const existingMessageInfoPromise = fetchMessageInfoForLocalID( viewer, localID, ); const mediaPromise: Promise<$ReadOnlyArray> = request.mediaIDs ? fetchMedia(viewer, request.mediaIDs) : fetchMediaFromMediaMessageContent(viewer, request.mediaMessageContents); const [existingMessageInfo, media] = await Promise.all([ existingMessageInfoPromise, mediaPromise, ]); if (media.length === 0 && !existingMessageInfo) { throw new ServerError('invalid_parameters'); } const messageData = createMediaMessageData({ localID, threadID, creatorID: viewer.id, media, }); const [newMessageInfo] = await createMessages(viewer, [messageData]); const { id } = newMessageInfo; invariant( id !== null && id !== undefined, 'serverID should be set in createMessages result', ); if (request.mediaIDs) { await assignMedia(viewer, request.mediaIDs, id); } else { await assignMessageContainerToMedia( viewer, request.mediaMessageContents, id, ); } return { newMessageInfo }; } const sendReactionMessageRequestInputValidator = tShape({ threadID: t.String, localID: t.maybe(t.String), targetMessageID: t.String, reaction: tRegex(onlyOneEmojiRegex), action: t.enums.of(['add_reaction', 'remove_reaction']), }); async function reactionMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendReactionMessageRequest = input; await validateInput(viewer, sendReactionMessageRequestInputValidator, input); const { threadID, localID, targetMessageID, reaction, action } = request; if (!targetMessageID || !reaction) { throw new ServerError('invalid_parameters'); } const targetMessageInfo = await fetchMessageInfoByID(viewer, targetMessageID); if (!targetMessageInfo || !targetMessageInfo.id) { throw new ServerError('invalid_parameters'); } - const [ - serverThreadInfos, - hasPermission, - targetMessageUserInfos, - ] = await Promise.all([ - fetchServerThreadInfos(SQL`t.id = ${threadID}`), - checkThreadPermission(viewer, threadID, threadPermissions.VOICED), - fetchKnownUserInfos(viewer, [targetMessageInfo.creatorID]), - ]); + const [serverThreadInfos, hasPermission, targetMessageUserInfos] = + await Promise.all([ + fetchServerThreadInfos(SQL`t.id = ${threadID}`), + checkThreadPermission(viewer, threadID, threadPermissions.VOICED), + fetchKnownUserInfos(viewer, [targetMessageInfo.creatorID]), + ]); const targetMessageThreadInfo = serverThreadInfos.threadInfos[threadID]; if (targetMessageThreadInfo.sourceMessageID === targetMessageID) { throw new ServerError('invalid_parameters'); } const targetMessageCreator = targetMessageUserInfos[targetMessageInfo.creatorID]; const targetMessageCreatorRelationship = targetMessageCreator?.relationshipStatus; const creatorRelationshipHasBlock = targetMessageCreatorRelationship && relationshipBlockedInEitherDirection(targetMessageCreatorRelationship); if (!hasPermission || creatorRelationshipHasBlock) { throw new ServerError('invalid_parameters'); } let messageData: ReactionMessageData = { type: messageTypes.REACTION, threadID, creatorID: viewer.id, time: Date.now(), targetMessageID, reaction, action, }; if (localID) { messageData = { ...messageData, localID }; } const rawMessageInfos = await createMessages(viewer, [messageData]); return { newMessageInfo: rawMessageInfos[0] }; } export { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, reactionMessageCreationResponder, }; diff --git a/keyserver/src/responders/user-responders.js b/keyserver/src/responders/user-responders.js index d4b655323..7ccedb769 100644 --- a/keyserver/src/responders/user-responders.js +++ b/keyserver/src/responders/user-responders.js @@ -1,553 +1,548 @@ // @flow import invariant from 'invariant'; import { ErrorTypes, SiweMessage } from 'siwe'; import t from 'tcomb'; import bcrypt from 'twin-bcrypt'; import { baseLegalPolicies, policies } from 'lib/facts/policies.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { ResetPasswordRequest, LogOutResponse, DeleteAccountRequest, RegisterResponse, RegisterRequest, LogInResponse, LogInRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, } from 'lib/types/account-types.js'; import { userSettingsTypes, notificationTypeValues, logInActionSources, } from 'lib/types/account-types.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import { defaultNumberPerThread } from 'lib/types/message-types.js'; import type { SIWEAuthRequest, SIWEMessage, SIWESocialProof, } from 'lib/types/siwe-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResponse, } from 'lib/types/subscription-types.js'; import type { PasswordUpdate } from 'lib/types/user-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { promiseAll } from 'lib/utils/promises.js'; import { getPublicKeyFromSIWEStatement, isValidSIWEMessage, isValidSIWEStatementWithPublicKey, primaryIdentityPublicKeyRegex, } from 'lib/utils/siwe-utils.js'; import { tShape, tPlatformDetails, tPassword, tEmail, tOldValidUsername, tRegex, } from 'lib/utils/validation-utils.js'; import { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders.js'; import { createAccount, processSIWEAccountCreation, } from '../creators/account-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { deleteAccount } from '../deleters/account-deleters.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { checkAndInvalidateSIWENonceEntry } from '../deleters/siwe-nonce-deleters.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfos } from '../fetchers/message-fetchers.js'; import { fetchNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchKnownUserInfos, fetchLoggedInUserInfo, fetchUserIDForEthereumAddress, } from '../fetchers/user-fetchers.js'; import { createNewAnonymousCookie, createNewUserCookie, setNewSession, } from '../session/cookies.js'; import type { Viewer } from '../session/viewer.js'; import { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, updateUserSettings, } from '../updaters/account-updaters.js'; import { userSubscriptionUpdater } from '../updaters/user-subscription-updaters.js'; import { viewerAcknowledgmentUpdater } from '../updaters/viewer-acknowledgment-updater.js'; import { validateInput } from '../utils/validation-utils.js'; const subscriptionUpdateRequestInputValidator = tShape({ threadID: t.String, updatedFields: tShape({ pushNotifs: t.maybe(t.Boolean), home: t.maybe(t.Boolean), }), }); async function userSubscriptionUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SubscriptionUpdateRequest = input; await validateInput(viewer, subscriptionUpdateRequestInputValidator, request); const threadSubscription = await userSubscriptionUpdater(viewer, request); return { threadSubscription }; } const accountUpdateInputValidator = tShape({ updatedFields: tShape({ email: t.maybe(tEmail), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function passwordUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: PasswordUpdate = input; await validateInput(viewer, accountUpdateInputValidator, request); await accountUpdater(viewer, request); } async function sendVerificationEmailResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); await checkAndSendVerificationEmail(viewer); } const resetPasswordRequestInputValidator = tShape({ usernameOrEmail: t.union([tEmail, tOldValidUsername]), }); async function sendPasswordResetEmailResponder( viewer: Viewer, input: any, ): Promise { const request: ResetPasswordRequest = input; await validateInput(viewer, resetPasswordRequestInputValidator, request); await checkAndSendPasswordResetEmail(request); } async function logOutResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); if (viewer.loggedIn) { const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails: viewer.platformDetails, deviceToken: viewer.deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(anonymousViewerData); } return { currentUserInfo: { id: viewer.id, anonymous: true, }, }; } const deleteAccountRequestInputValidator = tShape({ password: t.maybe(tPassword), }); async function accountDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteAccountRequest = input; await validateInput(viewer, deleteAccountRequestInputValidator, request); const result = await deleteAccount(viewer, request); invariant(result, 'deleteAccount should return result if handed request'); return result; } const deviceTokenUpdateRequestInputValidator = tShape({ deviceType: t.maybe(t.enums.of(['ios', 'android'])), deviceToken: t.String, }); const registerRequestInputValidator = tShape({ username: t.String, email: t.maybe(tEmail), password: tPassword, calendarQuery: t.maybe(newEntryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, primaryIdentityPublicKey: t.maybe(tRegex(primaryIdentityPublicKeyRegex)), }); async function accountCreationResponder( viewer: Viewer, input: any, ): Promise { const request: RegisterRequest = input; await validateInput(viewer, registerRequestInputValidator, request); return await createAccount(viewer, request); } type ProcessSuccessfulLoginParams = { +viewer: Viewer, +input: any, +userID: string, +calendarQuery: ?CalendarQuery, +primaryIdentityPublicKey?: ?string, +socialProof?: ?SIWESocialProof, }; async function processSuccessfulLogin( params: ProcessSuccessfulLoginParams, ): Promise { const { viewer, input, userID, calendarQuery, primaryIdentityPublicKey, socialProof, } = params; const request: LogInRequest = input; const newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [userViewerData, notAcknowledgedPolicies] = await Promise.all([ createNewUserCookie(userID, { platformDetails: request.platformDetails, deviceToken, primaryIdentityPublicKey, socialProof, }), fetchNotAcknowledgedPolicies(userID, baseLegalPolicies), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(userViewerData); if ( notAcknowledgedPolicies.length && hasMinCodeVersion(viewer.platformDetails, 181) ) { const currentUserInfo = await fetchLoggedInUserInfo(viewer); return { notAcknowledgedPolicies, currentUserInfo: currentUserInfo, rawMessageInfos: [], truncationStatuses: {}, userInfos: [], rawEntryInfos: [], serverTime: 0, cookieChange: { threadInfos: {}, userInfos: [], }, }; } if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const threadCursors = {}; for (const watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const messageSelectionCriteria = { threadCursors, joinedThreads: true }; const [ threadsResult, messagesResult, entriesResult, userInfos, currentUserInfo, ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; const response: LogInResponse = { currentUserInfo, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { return { ...response, rawEntryInfos, }; } return response; } const logInRequestInputValidator = tShape({ username: t.maybe(t.String), usernameOrEmail: t.maybe(t.union([tEmail, tOldValidUsername])), password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, source: t.maybe(t.enums.of(values(logInActionSources))), primaryIdentityPublicKey: t.maybe(tRegex(primaryIdentityPublicKeyRegex)), }); async function logInResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, logInRequestInputValidator, input); const request: LogInRequest = input; const calendarQuery = request.calendarQuery ? normalizeCalendarQuery(request.calendarQuery) : null; const promises = {}; if (calendarQuery) { - promises.verifyCalendarQueryThreadIDs = verifyCalendarQueryThreadIDs( - calendarQuery, - ); + promises.verifyCalendarQueryThreadIDs = + verifyCalendarQueryThreadIDs(calendarQuery); } const username = request.username ?? request.usernameOrEmail; if (!username) { if (hasMinCodeVersion(viewer.platformDetails, 150)) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userQuery = SQL` SELECT id, hash, username FROM users WHERE LCASE(username) = LCASE(${username}) `; promises.userQuery = dbQuery(userQuery); const { userQuery: [userResult], } = await promiseAll(promises); if (userResult.length === 0) { if (hasMinCodeVersion(viewer.platformDetails, 150)) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userRow = userResult[0]; if (!userRow.hash || !bcrypt.compareSync(request.password, userRow.hash)) { throw new ServerError('invalid_credentials'); } const id = userRow.id.toString(); const { primaryIdentityPublicKey } = input; return await processSuccessfulLogin({ viewer, input, userID: id, calendarQuery, primaryIdentityPublicKey, }); } const siweAuthRequestInputValidator = tShape({ signature: t.String, message: t.String, calendarQuery: entryQueryInputValidator, deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, watchedIDs: t.list(t.String), }); async function siweAuthResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, siweAuthRequestInputValidator, input); const request: SIWEAuthRequest = input; - const { - message, - signature, - deviceTokenUpdateRequest, - platformDetails, - } = request; + const { message, signature, deviceTokenUpdateRequest, platformDetails } = + request; const calendarQuery = normalizeCalendarQuery(request.calendarQuery); // 1. Ensure that `message` is a well formed Comm SIWE Auth message. const siweMessage: SIWEMessage = new SiweMessage(message); if (!isValidSIWEMessage(siweMessage)) { throw new ServerError('invalid_parameters'); } // 2. Ensure that the `nonce` exists in the `siwe_nonces` table // AND hasn't expired. If those conditions are met, delete the entry to // ensure that the same `nonce` can't be re-used in a future request. const wasNonceCheckedAndInvalidated = await checkAndInvalidateSIWENonceEntry( siweMessage.nonce, ); if (!wasNonceCheckedAndInvalidated) { throw new ServerError('invalid_parameters'); } // 3. Validate SIWEMessage signature and handle possible errors. try { await siweMessage.validate(signature); } catch (error) { if (error === ErrorTypes.EXPIRED_MESSAGE) { // Thrown when the `expirationTime` is present and in the past. throw new ServerError('expired_message'); } else if (error === ErrorTypes.INVALID_SIGNATURE) { // Thrown when the `validate()` function can't verify the message. throw new ServerError('invalid_signature'); } else if (error === ErrorTypes.MALFORMED_SESSION) { // Thrown when some required field is missing. throw new ServerError('malformed_session'); } else { throw new ServerError('unknown_error'); } } // 4. Pull `primaryIdentityPublicKey` out from SIWEMessage `statement` // if it was included. We expect it to be included for native clients, // and we expect it to be EXCLUDED for web clients. const { statement } = siweMessage; const primaryIdentityPublicKey = statement && isValidSIWEStatementWithPublicKey(statement) ? getPublicKeyFromSIWEStatement(statement) : null; // 5. Construct `SIWESocialProof` object with the stringified // SIWEMessage and the corresponding signature. const socialProof: SIWESocialProof = { siweMessage: siweMessage.toMessage(), siweMessageSignature: signature, }; // 6. Create account with call to `processSIWEAccountCreation(...)` // if address does not correspond to an existing user. let userID = await fetchUserIDForEthereumAddress(siweMessage.address); if (!userID) { const siweAccountCreationRequest = { address: siweMessage.address, calendarQuery, deviceTokenUpdateRequest, platformDetails, primaryIdentityPublicKey, socialProof, }; userID = await processSIWEAccountCreation( viewer, siweAccountCreationRequest, ); } // 7. Complete login with call to `processSuccessfulLogin(...)`. return await processSuccessfulLogin({ viewer, input, userID, calendarQuery, primaryIdentityPublicKey, socialProof, }); } const updatePasswordRequestInputValidator = tShape({ code: t.String, password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function oldPasswordUpdateResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, updatePasswordRequestInputValidator, input); const request: UpdatePasswordRequest = input; if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } return await updatePassword(viewer, request); } const updateUserSettingsInputValidator = tShape({ name: t.irreducible( userSettingsTypes.DEFAULT_NOTIFICATIONS, x => x === userSettingsTypes.DEFAULT_NOTIFICATIONS, ), data: t.enums.of(notificationTypeValues), }); async function updateUserSettingsResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateUserSettingsRequest = input; await validateInput(viewer, updateUserSettingsInputValidator, request); return await updateUserSettings(viewer, request); } const policyAcknowledgmentRequestInputValidator = tShape({ policy: t.maybe(t.enums.of(policies)), }); async function policyAcknowledgmentResponder( viewer: Viewer, input: any, ): Promise { const request: PolicyAcknowledgmentRequest = input; await validateInput( viewer, policyAcknowledgmentRequestInputValidator, request, ); await viewerAcknowledgmentUpdater(viewer, request.policy); } export { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, }; diff --git a/keyserver/src/responders/website-responders.js b/keyserver/src/responders/website-responders.js index 131ea1894..8a2855136 100644 --- a/keyserver/src/responders/website-responders.js +++ b/keyserver/src/responders/website-responders.js @@ -1,404 +1,400 @@ // @flow import html from 'common-tags/lib/html/index.js'; import type { $Response, $Request } from 'express'; import fs from 'fs'; import _keyBy from 'lodash/fp/keyBy.js'; import * as React from 'react'; // eslint-disable-next-line import/extensions import ReactDOMServer from 'react-dom/server'; import { promisify } from 'util'; import { baseLegalPolicies } from 'lib/facts/policies.js'; import { daysToEntriesFromEntryInfos } from 'lib/reducers/entry-reducer.js'; import { freshMessageStore } from 'lib/reducers/message-reducer.js'; import { mostRecentlyReadThread } from 'lib/selectors/thread-selectors.js'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils.js'; import { threadHasPermission, threadIsPending, parsePendingThreadID, createPendingThread, } from 'lib/shared/thread-utils.js'; import { defaultWebEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { defaultNumberPerThread } from 'lib/types/message-types.js'; import { defaultEnabledReports } from 'lib/types/report-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import { threadPermissions, threadTypes } from 'lib/types/thread-types.js'; import { currentDateInTimeZone } from 'lib/utils/date-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import getTitle from 'web/title/getTitle.js'; import { navInfoFromURL } from 'web/url-utils.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfos } from '../fetchers/message-fetchers.js'; import { hasAnyNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchCurrentUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers.js'; import { setNewSession } from '../session/cookies.js'; import { Viewer } from '../session/viewer.js'; import { streamJSON, waitForStream } from '../utils/json-stream.js'; import { getAppURLFactsFromRequestURL } from '../utils/urls.js'; const { renderToNodeStream } = ReactDOMServer; const access = promisify(fs.access); const readFile = promisify(fs.readFile); const googleFontsURL = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600&display=swap'; const localFontsURL = 'fonts/local-fonts.css'; async function getFontsURL() { try { await access(localFontsURL); return localFontsURL; } catch { return googleFontsURL; } } type AssetInfo = { jsURL: string, fontsURL: string, cssInclude: string }; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'development') { const fontsURL = await getFontsURL(); assetInfo = { jsURL: 'http://localhost:8080/dev.build.js', fontsURL, cssInclude: '', }; return assetInfo; } try { const assetsString = await readFile('../web/dist/assets.json', 'utf8'); const assets = JSON.parse(assetsString); assetInfo = { jsURL: `compiled/${assets.browser.js}`, fontsURL: googleFontsURL, cssInclude: html` `, }; return assetInfo; } catch { throw new Error( 'Could not load assets.json for web build. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } let webpackCompiledRootComponent: ?React.ComponentType<{}> = null; async function getWebpackCompiledRootComponentForSSR() { if (webpackCompiledRootComponent) { return webpackCompiledRootComponent; } try { // $FlowFixMe web/dist doesn't always exist const webpackBuild = await import('web/dist/app.build.cjs'); webpackCompiledRootComponent = webpackBuild.app.default; return webpackCompiledRootComponent; } catch { throw new Error( 'Could not load app.build.cjs. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } async function websiteResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { const appURLFacts = getAppURLFactsFromRequestURL(req.originalUrl); const { basePath, baseDomain } = appURLFacts; const baseURL = basePath.replace(/\/$/, ''); const baseHref = baseDomain + baseURL; const loadingPromise = getWebpackCompiledRootComponentForSSR(); const hasNotAcknowledgedPoliciesPromise = hasAnyNotAcknowledgedPolicies( viewer.id, baseLegalPolicies, ); let initialNavInfo; try { initialNavInfo = navInfoFromURL(req.url, { now: currentDateInTimeZone(viewer.timeZone), }); } catch (e) { throw new ServerError(e.message); } const calendarQuery = { startDate: initialNavInfo.startDate, endDate: initialNavInfo.endDate, filters: defaultCalendarFilters, }; const messageSelectionCriteria = { joinedThreads: true }; const initialTime = Date.now(); const assetInfoPromise = getAssetInfo(); const threadInfoPromise = fetchThreadInfos(viewer); const messageInfoPromise = fetchMessageInfos( viewer, messageSelectionCriteria, defaultNumberPerThread, ); const entryInfoPromise = fetchEntryInfos(viewer, [calendarQuery]); const currentUserInfoPromise = fetchCurrentUserInfo(viewer); const userInfoPromise = fetchKnownUserInfos(viewer); const sessionIDPromise = (async () => { if (viewer.loggedIn) { await setNewSession(viewer, calendarQuery, initialTime); } return viewer.sessionID; })(); const threadStorePromise = (async () => { const [{ threadInfos }, hasNotAcknowledgedPolicies] = await Promise.all([ threadInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); return { threadInfos: hasNotAcknowledgedPolicies ? {} : threadInfos }; })(); const messageStorePromise = (async () => { const [ { threadInfos }, { rawMessageInfos, truncationStatuses }, hasNotAcknowledgedPolicies, ] = await Promise.all([ threadInfoPromise, messageInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); if (hasNotAcknowledgedPolicies) { return { messages: {}, threads: {}, local: {}, currentAsOf: 0, }; } const { messageStore: freshStore } = freshMessageStore( rawMessageInfos, truncationStatuses, mostRecentMessageTimestamp(rawMessageInfos, initialTime), threadInfos, ); return freshStore; })(); const entryStorePromise = (async () => { const [{ rawEntryInfos }, hasNotAcknowledgedPolicies] = await Promise.all([ entryInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); if (hasNotAcknowledgedPolicies) { return { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }; } return { entryInfos: _keyBy('id')(rawEntryInfos), daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos), lastUserInteractionCalendar: initialTime, }; })(); const userStorePromise = (async () => { const [userInfos, hasNotAcknowledgedPolicies] = await Promise.all([ userInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); return { userInfos: hasNotAcknowledgedPolicies ? {} : userInfos, inconsistencyReports: [], }; })(); const navInfoPromise = (async () => { - const [ - { threadInfos }, - messageStore, - currentUserInfo, - userStore, - ] = await Promise.all([ - threadInfoPromise, - messageStorePromise, - currentUserInfoPromise, - userStorePromise, - ]); + const [{ threadInfos }, messageStore, currentUserInfo, userStore] = + await Promise.all([ + threadInfoPromise, + messageStorePromise, + currentUserInfoPromise, + userStorePromise, + ]); const finalNavInfo = initialNavInfo; const requestedActiveChatThreadID = finalNavInfo.activeChatThreadID; if ( requestedActiveChatThreadID && !threadIsPending(requestedActiveChatThreadID) && !threadHasPermission( threadInfos[requestedActiveChatThreadID], threadPermissions.VISIBLE, ) ) { finalNavInfo.activeChatThreadID = null; } if (!finalNavInfo.activeChatThreadID) { const mostRecentThread = mostRecentlyReadThread( messageStore, threadInfos, ); if (mostRecentThread) { finalNavInfo.activeChatThreadID = mostRecentThread; } } if ( finalNavInfo.activeChatThreadID && threadIsPending(finalNavInfo.activeChatThreadID) && finalNavInfo.pendingThread?.id !== finalNavInfo.activeChatThreadID ) { const pendingThreadData = parsePendingThreadID( finalNavInfo.activeChatThreadID, ); if ( pendingThreadData && pendingThreadData.threadType !== threadTypes.SIDEBAR && currentUserInfo.id ) { const { userInfos } = userStore; const members = pendingThreadData.memberIDs .map(id => userInfos[id]) .filter(Boolean); const newPendingThread = createPendingThread({ viewerID: currentUserInfo.id, threadType: pendingThreadData.threadType, members, }); finalNavInfo.activeChatThreadID = newPendingThread.id; finalNavInfo.pendingThread = newPendingThread; } } return finalNavInfo; })(); const currentAsOfPromise = (async () => { const hasNotAcknowledgedPolicies = await hasNotAcknowledgedPoliciesPromise; return hasNotAcknowledgedPolicies ? 0 : initialTime; })(); const { jsURL, fontsURL, cssInclude } = await assetInfoPromise; // prettier-ignore res.write(html` ${getTitle(0)} ${cssInclude}
`); const Loading = await loadingPromise; const reactStream = renderToNodeStream(); reactStream.pipe(res, { end: false }); await waitForStream(reactStream); res.write(html`
`); } export { websiteResponder }; diff --git a/keyserver/src/socket/socket.js b/keyserver/src/socket/socket.js index 3f89c36ec..b7fafaff6 100644 --- a/keyserver/src/socket/socket.js +++ b/keyserver/src/socket/socket.js @@ -1,821 +1,811 @@ // @flow import type { $Request } from 'express'; import invariant from 'invariant'; import _debounce from 'lodash/debounce.js'; import t from 'tcomb'; import WebSocket from 'ws'; import { baseLegalPolicies } from 'lib/facts/policies.js'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils.js'; import { serverRequestSocketTimeout, serverResponseTimeout, } from 'lib/shared/timeouts.js'; import { mostRecentUpdateTimestamp } from 'lib/shared/update-utils.js'; import type { Shape } from 'lib/types/core.js'; import { endpointIsSocketSafe } from 'lib/types/endpoints.js'; import { defaultNumberPerThread } from 'lib/types/message-types.js'; import { redisMessageTypes, type RedisMessage } from 'lib/types/redis-types.js'; import { cookieSources, sessionCheckFrequency, stateCheckInactivityActivationInterval, } from 'lib/types/session-types.js'; import { type ClientSocketMessage, type InitialClientSocketMessage, type ResponsesClientSocketMessage, type ServerStateSyncFullSocketPayload, type ServerServerSocketMessage, type ErrorServerSocketMessage, type AuthErrorServerSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, clientSocketMessageTypes, stateSyncPayloadTypes, serverSocketMessageTypes, } from 'lib/types/socket-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { promiseAll } from 'lib/utils/promises.js'; import SequentialPromiseResolver from 'lib/utils/sequential-promise-resolver.js'; import sleep from 'lib/utils/sleep.js'; import { tShape, tCookie } from 'lib/utils/validation-utils.js'; import { RedisSubscriber } from './redis.js'; import { clientResponseInputValidator, processClientResponses, initializeSession, checkState, } from './session-utils.js'; import { fetchUpdateInfosWithRawUpdateInfos } from '../creators/update-creator.js'; import { deleteActivityForViewerSession } from '../deleters/activity-deleters.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { deleteUpdatesBeforeTimeTargetingSession } from '../deleters/update-deleters.js'; import { jsonEndpoints } from '../endpoints.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, } from '../fetchers/message-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchUpdateInfos } from '../fetchers/update-fetchers.js'; import { fetchCurrentUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers.js'; import { newEntryQueryInputValidator, verifyCalendarQueryThreadIDs, } from '../responders/entry-responders.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import { fetchViewerForSocket, extendCookieLifespan, createNewAnonymousCookie, } from '../session/cookies.js'; import { Viewer } from '../session/viewer.js'; import { commitSessionUpdate } from '../updaters/session-updaters.js'; import { assertSecureRequest } from '../utils/security-utils.js'; import { checkInputValidator, checkClientSupported, policiesValidator, } from '../utils/validation-utils.js'; const clientSocketMessageInputValidator = t.union([ tShape({ type: t.irreducible( 'clientSocketMessageTypes.INITIAL', x => x === clientSocketMessageTypes.INITIAL, ), id: t.Number, payload: tShape({ sessionIdentification: tShape({ cookie: t.maybe(tCookie), sessionID: t.maybe(t.String), }), sessionState: tShape({ calendarQuery: newEntryQueryInputValidator, messagesCurrentAsOf: t.Number, updatesCurrentAsOf: t.Number, watchedIDs: t.list(t.String), }), clientResponses: t.list(clientResponseInputValidator), }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.RESPONSES', x => x === clientSocketMessageTypes.RESPONSES, ), id: t.Number, payload: tShape({ clientResponses: t.list(clientResponseInputValidator), }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.PING', x => x === clientSocketMessageTypes.PING, ), id: t.Number, }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.ACK_UPDATES', x => x === clientSocketMessageTypes.ACK_UPDATES, ), id: t.Number, payload: tShape({ currentAsOf: t.Number, }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.API_REQUEST', x => x === clientSocketMessageTypes.API_REQUEST, ), id: t.Number, payload: tShape({ endpoint: t.String, input: t.Object, }), }), ]); function onConnection(ws: WebSocket, req: $Request) { assertSecureRequest(req); new Socket(ws, req); } type StateCheckConditions = { activityRecentlyOccurred: boolean, stateCheckOngoing: boolean, }; class Socket { ws: WebSocket; httpRequest: $Request; viewer: ?Viewer; redis: ?RedisSubscriber; redisPromiseResolver: SequentialPromiseResolver; stateCheckConditions: StateCheckConditions = { activityRecentlyOccurred: true, stateCheckOngoing: false, }; stateCheckTimeoutID: ?TimeoutID; constructor(ws: WebSocket, httpRequest: $Request) { this.ws = ws; this.httpRequest = httpRequest; ws.on('message', this.onMessage); ws.on('close', this.onClose); this.resetTimeout(); this.redisPromiseResolver = new SequentialPromiseResolver(this.sendMessage); } onMessage = async ( messageString: string | Buffer | ArrayBuffer | Array, ) => { invariant(typeof messageString === 'string', 'message should be string'); let clientSocketMessage: ?ClientSocketMessage; try { this.resetTimeout(); clientSocketMessage = JSON.parse(messageString); checkInputValidator( clientSocketMessageInputValidator, clientSocketMessage, ); if (clientSocketMessage.type === clientSocketMessageTypes.INITIAL) { if (this.viewer) { // This indicates that the user sent multiple INITIAL messages. throw new ServerError('socket_already_initialized'); } this.viewer = await fetchViewerForSocket( this.httpRequest, clientSocketMessage, ); if (!this.viewer) { // This indicates that the cookie was invalid, but the client is using // cookieSources.HEADER and thus can't accept a new cookie over // WebSockets. See comment under catch block for socket_deauthorized. throw new ServerError('socket_deauthorized'); } } const { viewer } = this; if (!viewer) { // This indicates a non-INITIAL message was sent by the client before // the INITIAL message. throw new ServerError('socket_uninitialized'); } if (viewer.sessionChanged) { // This indicates that the cookie was invalid, and we've assigned a new // anonymous one. throw new ServerError('socket_deauthorized'); } if (!viewer.loggedIn) { // This indicates that the specified cookie was an anonymous one. throw new ServerError('not_logged_in'); } await checkClientSupported( viewer, clientSocketMessageInputValidator, clientSocketMessage, ); await policiesValidator(viewer, baseLegalPolicies); const serverResponses = await this.handleClientSocketMessage( clientSocketMessage, ); if (!this.redis) { this.redis = new RedisSubscriber( { userID: viewer.userID, sessionID: viewer.session }, this.onRedisMessage, ); } if (viewer.sessionChanged) { // This indicates that something has caused the session to change, which // shouldn't happen from inside a WebSocket since we can't handle cookie // invalidation. throw new ServerError('session_mutated_from_socket'); } if (clientSocketMessage.type !== clientSocketMessageTypes.PING) { handleAsyncPromise(extendCookieLifespan(viewer.cookieID)); } for (const response of serverResponses) { this.sendMessage(response); } if (clientSocketMessage.type === clientSocketMessageTypes.INITIAL) { this.onSuccessfulConnection(); } } catch (error) { console.warn(error); if (!(error instanceof ServerError)) { const errorMessage: ErrorServerSocketMessage = { type: serverSocketMessageTypes.ERROR, message: error.message, }; const responseTo = clientSocketMessage ? clientSocketMessage.id : null; if (responseTo !== null) { errorMessage.responseTo = responseTo; } this.markActivityOccurred(); this.sendMessage(errorMessage); return; } invariant(clientSocketMessage, 'should be set'); const responseTo = clientSocketMessage.id; if (error.message === 'socket_deauthorized') { const authErrorMessage: AuthErrorServerSocketMessage = { type: serverSocketMessageTypes.AUTH_ERROR, responseTo, message: error.message, }; if (this.viewer) { // viewer should only be falsey for cookieSources.HEADER (web) // clients. Usually if the cookie is invalid we construct a new // anonymous Viewer with a new cookie, and then pass the cookie down // in the error. But we can't pass HTTP cookies in WebSocket messages. authErrorMessage.sessionChange = { cookie: this.viewer.cookiePairString, currentUserInfo: { id: this.viewer.cookieID, anonymous: true, }, }; } this.sendMessage(authErrorMessage); this.ws.close(4100, error.message); return; } else if (error.message === 'client_version_unsupported') { const { viewer } = this; invariant(viewer, 'should be set'); const promises = {}; promises.deleteCookie = deleteCookie(viewer.cookieID); if (viewer.cookieSource !== cookieSources.BODY) { promises.anonymousViewerData = createNewAnonymousCookie({ platformDetails: error.platformDetails, deviceToken: viewer.deviceToken, }); } const { anonymousViewerData } = await promiseAll(promises); const authErrorMessage: AuthErrorServerSocketMessage = { type: serverSocketMessageTypes.AUTH_ERROR, responseTo, message: error.message, }; if (anonymousViewerData) { // It is normally not safe to pass the result of // createNewAnonymousCookie to the Viewer constructor. That is because // createNewAnonymousCookie leaves several fields of // AnonymousViewerData unset, and consequently Viewer will throw when // access is attempted. It is only safe here because we can guarantee // that only cookiePairString and cookieID are accessed on anonViewer // below. const anonViewer = new Viewer(anonymousViewerData); authErrorMessage.sessionChange = { cookie: anonViewer.cookiePairString, currentUserInfo: { id: anonViewer.cookieID, anonymous: true, }, }; } this.sendMessage(authErrorMessage); this.ws.close(4101, error.message); return; } if (error.payload) { this.sendMessage({ type: serverSocketMessageTypes.ERROR, responseTo, message: error.message, payload: error.payload, }); } else { this.sendMessage({ type: serverSocketMessageTypes.ERROR, responseTo, message: error.message, }); } if (error.message === 'not_logged_in') { this.ws.close(4102, error.message); } else if (error.message === 'session_mutated_from_socket') { this.ws.close(4103, error.message); } else { this.markActivityOccurred(); } } }; onClose = async () => { this.clearStateCheckTimeout(); this.resetTimeout.cancel(); this.debouncedAfterActivity.cancel(); if (this.viewer && this.viewer.hasSessionInfo) { await deleteActivityForViewerSession(this.viewer); } if (this.redis) { this.redis.quit(); this.redis = null; } }; sendMessage = (message: ServerServerSocketMessage) => { invariant( this.ws.readyState > 0, "shouldn't send message until connection established", ); if (this.ws.readyState === 1) { this.ws.send(JSON.stringify(message)); } }; async handleClientSocketMessage( message: ClientSocketMessage, ): Promise { const resultPromise = (async () => { if (message.type === clientSocketMessageTypes.INITIAL) { this.markActivityOccurred(); return await this.handleInitialClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.RESPONSES) { this.markActivityOccurred(); return await this.handleResponsesClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.PING) { return this.handlePingClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { this.markActivityOccurred(); return await this.handleAckUpdatesClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.API_REQUEST) { this.markActivityOccurred(); return await this.handleAPIRequestClientSocketMessage(message); } return []; })(); const timeoutPromise = (async () => { await sleep(serverResponseTimeout); throw new ServerError('socket_response_timeout'); })(); return await Promise.race([resultPromise, timeoutPromise]); } async handleInitialClientSocketMessage( message: InitialClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const responses = []; const { sessionState, clientResponses } = message.payload; const { calendarQuery, updatesCurrentAsOf: oldUpdatesCurrentAsOf, messagesCurrentAsOf: oldMessagesCurrentAsOf, watchedIDs, } = sessionState; await verifyCalendarQueryThreadIDs(calendarQuery); const sessionInitializationResult = await initializeSession( viewer, calendarQuery, oldUpdatesCurrentAsOf, ); const threadCursors = {}; for (const watchedThreadID of watchedIDs) { threadCursors[watchedThreadID] = null; } const messageSelectionCriteria = { threadCursors, joinedThreads: true, newerThan: oldMessagesCurrentAsOf, }; - const [ - fetchMessagesResult, - { serverRequests, activityUpdateResult }, - ] = await Promise.all([ - fetchMessageInfosSince( - viewer, - messageSelectionCriteria, - defaultNumberPerThread, - ), - processClientResponses(viewer, clientResponses), - ]); + const [fetchMessagesResult, { serverRequests, activityUpdateResult }] = + await Promise.all([ + fetchMessageInfosSince( + viewer, + messageSelectionCriteria, + defaultNumberPerThread, + ), + processClientResponses(viewer, clientResponses), + ]); const messagesResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, currentAsOf: mostRecentMessageTimestamp( fetchMessagesResult.rawMessageInfos, oldMessagesCurrentAsOf, ), }; if (!sessionInitializationResult.sessionContinued) { - const [ - threadsResult, - entriesResult, - currentUserInfo, - knownUserInfos, - ] = await Promise.all([ - fetchThreadInfos(viewer), - fetchEntryInfos(viewer, [calendarQuery]), - fetchCurrentUserInfo(viewer), - fetchKnownUserInfos(viewer), - ]); + const [threadsResult, entriesResult, currentUserInfo, knownUserInfos] = + await Promise.all([ + fetchThreadInfos(viewer), + fetchEntryInfos(viewer, [calendarQuery]), + fetchCurrentUserInfo(viewer), + fetchKnownUserInfos(viewer), + ]); const payload: ServerStateSyncFullSocketPayload = { type: stateSyncPayloadTypes.FULL, messagesResult, threadInfos: threadsResult.threadInfos, currentUserInfo, rawEntryInfos: entriesResult.rawEntryInfos, userInfos: values(knownUserInfos), updatesCurrentAsOf: oldUpdatesCurrentAsOf, }; if (viewer.sessionChanged) { // If initializeSession encounters, // sessionIdentifierTypes.BODY_SESSION_ID but the session // is unspecified or expired, // it will set a new sessionID and specify viewer.sessionChanged const { sessionID } = viewer; invariant( sessionID !== null && sessionID !== undefined, 'should be set', ); payload.sessionID = sessionID; viewer.sessionChanged = false; } responses.push({ type: serverSocketMessageTypes.STATE_SYNC, responseTo: message.id, payload, }); } else { - const { - sessionUpdate, - deltaEntryInfoResult, - } = sessionInitializationResult; + const { sessionUpdate, deltaEntryInfoResult } = + sessionInitializationResult; const promises = {}; promises.deleteExpiredUpdates = deleteUpdatesBeforeTimeTargetingSession( viewer, oldUpdatesCurrentAsOf, ); promises.fetchUpdateResult = fetchUpdateInfos( viewer, oldUpdatesCurrentAsOf, calendarQuery, ); promises.sessionUpdate = commitSessionUpdate(viewer, sessionUpdate); const { fetchUpdateResult } = await promiseAll(promises); const { updateInfos, userInfos } = fetchUpdateResult; const newUpdatesCurrentAsOf = mostRecentUpdateTimestamp( [...updateInfos], oldUpdatesCurrentAsOf, ); const updatesResult = { newUpdates: updateInfos, currentAsOf: newUpdatesCurrentAsOf, }; responses.push({ type: serverSocketMessageTypes.STATE_SYNC, responseTo: message.id, payload: { type: stateSyncPayloadTypes.INCREMENTAL, messagesResult, updatesResult, deltaEntryInfos: deltaEntryInfoResult.rawEntryInfos, deletedEntryIDs: deltaEntryInfoResult.deletedEntryIDs, userInfos: values(userInfos), }, }); } if (serverRequests.length > 0 || clientResponses.length > 0) { // We send this message first since the STATE_SYNC triggers the client's // connection status to shift to "connected", and we want to make sure the // client responses are cleared from Redux before that happens responses.unshift({ type: serverSocketMessageTypes.REQUESTS, responseTo: message.id, payload: { serverRequests }, }); } if (activityUpdateResult) { // Same reason for unshifting as above responses.unshift({ type: serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, responseTo: message.id, payload: activityUpdateResult, }); } return responses; } async handleResponsesClientSocketMessage( message: ResponsesClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const { clientResponses } = message.payload; const { stateCheckStatus } = await processClientResponses( viewer, clientResponses, ); const serverRequests = []; if (stateCheckStatus && stateCheckStatus.status !== 'state_check') { const { sessionUpdate, checkStateRequest } = await checkState( viewer, stateCheckStatus, viewer.calendarQuery, ); if (sessionUpdate) { await commitSessionUpdate(viewer, sessionUpdate); this.setStateCheckConditions({ stateCheckOngoing: false }); } if (checkStateRequest) { serverRequests.push(checkStateRequest); } } // We send a response message regardless of whether we have any requests, // since we need to ack the client's responses return [ { type: serverSocketMessageTypes.REQUESTS, responseTo: message.id, payload: { serverRequests }, }, ]; } handlePingClientSocketMessage( message: PingClientSocketMessage, ): ServerServerSocketMessage[] { return [ { type: serverSocketMessageTypes.PONG, responseTo: message.id, }, ]; } async handleAckUpdatesClientSocketMessage( message: AckUpdatesClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const { currentAsOf } = message.payload; await Promise.all([ deleteUpdatesBeforeTimeTargetingSession(viewer, currentAsOf), commitSessionUpdate(viewer, { lastUpdate: currentAsOf }), ]); return []; } async handleAPIRequestClientSocketMessage( message: APIRequestClientSocketMessage, ): Promise { if (!endpointIsSocketSafe(message.payload.endpoint)) { throw new ServerError('endpoint_unsafe_for_socket'); } const { viewer } = this; invariant(viewer, 'should be set'); const responder = jsonEndpoints[message.payload.endpoint]; await policiesValidator(viewer, responder.requiredPolicies); const response = await responder.responder(viewer, message.payload.input); return [ { type: serverSocketMessageTypes.API_RESPONSE, responseTo: message.id, payload: response, }, ]; } onRedisMessage = async (message: RedisMessage) => { try { await this.processRedisMessage(message); } catch (e) { console.warn(e); } }; async processRedisMessage(message: RedisMessage) { if (message.type === redisMessageTypes.START_SUBSCRIPTION) { this.ws.terminate(); } else if (message.type === redisMessageTypes.NEW_UPDATES) { const { viewer } = this; invariant(viewer, 'should be set'); if (message.ignoreSession && message.ignoreSession === viewer.session) { return; } const rawUpdateInfos = message.updates; this.redisPromiseResolver.add( (async () => { - const { - updateInfos, - userInfos, - } = await fetchUpdateInfosWithRawUpdateInfos(rawUpdateInfos, { - viewer, - }); + const { updateInfos, userInfos } = + await fetchUpdateInfosWithRawUpdateInfos(rawUpdateInfos, { + viewer, + }); if (updateInfos.length === 0) { console.warn( 'could not get any UpdateInfos from redisMessageTypes.NEW_UPDATES', ); return null; } this.markActivityOccurred(); return { type: serverSocketMessageTypes.UPDATES, payload: { updatesResult: { currentAsOf: mostRecentUpdateTimestamp([...updateInfos], 0), newUpdates: updateInfos, }, userInfos: values(userInfos), }, }; })(), ); } else if (message.type === redisMessageTypes.NEW_MESSAGES) { const { viewer } = this; invariant(viewer, 'should be set'); const rawMessageInfos = message.messages; const messageFetchResult = getMessageFetchResultFromRedisMessages( viewer, rawMessageInfos, ); if (messageFetchResult.rawMessageInfos.length === 0) { console.warn( 'could not get any rawMessageInfos from ' + 'redisMessageTypes.NEW_MESSAGES', ); return; } this.redisPromiseResolver.add( (async () => { this.markActivityOccurred(); return { type: serverSocketMessageTypes.MESSAGES, payload: { messagesResult: { rawMessageInfos: messageFetchResult.rawMessageInfos, truncationStatuses: messageFetchResult.truncationStatuses, currentAsOf: mostRecentMessageTimestamp( messageFetchResult.rawMessageInfos, 0, ), }, }, }; })(), ); } } onSuccessfulConnection() { if (this.ws.readyState !== 1) { return; } this.handleStateCheckConditionsUpdate(); } // The Socket will timeout by calling this.ws.terminate() // serverRequestSocketTimeout milliseconds after the last // time resetTimeout is called resetTimeout = _debounce( () => this.ws.terminate(), serverRequestSocketTimeout, ); debouncedAfterActivity = _debounce( () => this.setStateCheckConditions({ activityRecentlyOccurred: false }), stateCheckInactivityActivationInterval, ); markActivityOccurred = () => { if (this.ws.readyState !== 1) { return; } this.setStateCheckConditions({ activityRecentlyOccurred: true }); this.debouncedAfterActivity(); }; clearStateCheckTimeout() { const { stateCheckTimeoutID } = this; if (stateCheckTimeoutID) { clearTimeout(stateCheckTimeoutID); this.stateCheckTimeoutID = null; } } setStateCheckConditions(newConditions: Shape) { this.stateCheckConditions = { ...this.stateCheckConditions, ...newConditions, }; this.handleStateCheckConditionsUpdate(); } get stateCheckCanStart() { return Object.values(this.stateCheckConditions).every(cond => !cond); } handleStateCheckConditionsUpdate() { if (!this.stateCheckCanStart) { this.clearStateCheckTimeout(); return; } if (this.stateCheckTimeoutID) { return; } const { viewer } = this; if (!viewer) { return; } const timeUntilStateCheck = viewer.sessionLastValidated + sessionCheckFrequency - Date.now(); if (timeUntilStateCheck <= 0) { this.initiateStateCheck(); } else { this.stateCheckTimeoutID = setTimeout( this.initiateStateCheck, timeUntilStateCheck, ); } } initiateStateCheck = async () => { this.setStateCheckConditions({ stateCheckOngoing: true }); const { viewer } = this; invariant(viewer, 'should be set'); const { checkStateRequest } = await checkState( viewer, { status: 'state_check' }, viewer.calendarQuery, ); invariant(checkStateRequest, 'should be set'); this.sendMessage({ type: serverSocketMessageTypes.REQUESTS, payload: { serverRequests: [checkStateRequest] }, }); }; } export { onConnection }; diff --git a/keyserver/src/updaters/activity-updaters.js b/keyserver/src/updaters/activity-updaters.js index 5036086bd..b614b6a0e 100644 --- a/keyserver/src/updaters/activity-updaters.js +++ b/keyserver/src/updaters/activity-updaters.js @@ -1,518 +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 [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; + 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, - ), + 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); } 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/updaters/relationship-updaters.js b/keyserver/src/updaters/relationship-updaters.js index d765b5207..0187b33dc 100644 --- a/keyserver/src/updaters/relationship-updaters.js +++ b/keyserver/src/updaters/relationship-updaters.js @@ -1,370 +1,368 @@ // @flow import invariant from 'invariant'; import { sortIDs } from 'lib/shared/relationship-utils.js'; import { messageTypes } from 'lib/types/message-types.js'; import { type RelationshipRequest, type RelationshipErrors, type UndirectedRelationshipRow, relationshipActions, undirectedStatus, directedStatus, } from 'lib/types/relationship-types.js'; import { threadTypes } from 'lib/types/thread-types.js'; import { updateTypes, type UpdateData } from 'lib/types/update-types.js'; import { cartesianProduct } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import createMessages from '../creators/message-creator.js'; import { createThread } from '../creators/thread-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL, mergeOrConditions } from '../database/database.js'; import { fetchFriendRequestRelationshipOperations } from '../fetchers/relationship-fetchers.js'; import { fetchUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; async function updateRelationships( viewer: Viewer, request: RelationshipRequest, ): Promise { const { action } = request; if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const uniqueUserIDs = [...new Set(request.userIDs)]; const users = await fetchUserInfos(uniqueUserIDs); let errors = {}; const userIDs: string[] = []; for (const userID of uniqueUserIDs) { if (userID === viewer.userID || !users[userID].username) { const acc = errors.invalid_user || []; errors.invalid_user = [...acc, userID]; } else { userIDs.push(userID); } } if (!userIDs.length) { return Object.freeze({ ...errors }); } const updateIDs = []; if (action === relationshipActions.FRIEND) { // We have to create personal threads before setting the relationship // status. By doing that we make sure that failed thread creation is // reported to the caller and can be repeated - there should be only // one PERSONAL thread per a pair of users and we can safely call it // repeatedly. const threadIDPerUser = await createPersonalThreads(viewer, request); - const { - userRelationshipOperations, - errors: friendRequestErrors, - } = await fetchFriendRequestRelationshipOperations(viewer, userIDs); + const { userRelationshipOperations, errors: friendRequestErrors } = + await fetchFriendRequestRelationshipOperations(viewer, userIDs); errors = { ...errors, ...friendRequestErrors }; const undirectedInsertRows = []; const directedInsertRows = []; const directedDeleteIDs = []; const messageDatas = []; const now = Date.now(); for (const userID in userRelationshipOperations) { const operations = userRelationshipOperations[userID]; const ids = sortIDs(viewer.userID, userID); if (operations.length) { updateIDs.push(userID); } for (const operation of operations) { if (operation === 'delete_directed') { directedDeleteIDs.push(userID); } else if (operation === 'friend') { const [user1, user2] = ids; const status = undirectedStatus.FRIEND; undirectedInsertRows.push({ user1, user2, status }); messageDatas.push({ type: messageTypes.UPDATE_RELATIONSHIP, threadID: threadIDPerUser[userID], creatorID: viewer.userID, targetID: userID, time: now, operation: 'request_accepted', }); } else if (operation === 'pending_friend') { const status = directedStatus.PENDING_FRIEND; directedInsertRows.push([viewer.userID, userID, status]); messageDatas.push({ type: messageTypes.UPDATE_RELATIONSHIP, threadID: threadIDPerUser[userID], creatorID: viewer.userID, targetID: userID, time: now, operation: 'request_sent', }); } else if (operation === 'know_of') { const [user1, user2] = ids; const status = undirectedStatus.KNOW_OF; undirectedInsertRows.push({ user1, user2, status }); } else { invariant(false, `unexpected relationship operation ${operation}`); } } } const promises = [updateUndirectedRelationships(undirectedInsertRows)]; if (directedInsertRows.length) { const directedInsertQuery = SQL` INSERT INTO relationships_directed (user1, user2, status) VALUES ${directedInsertRows} ON DUPLICATE KEY UPDATE status = VALUE(status) `; promises.push(dbQuery(directedInsertQuery)); } if (directedDeleteIDs.length) { const directedDeleteQuery = SQL` DELETE FROM relationships_directed WHERE (user1 = ${viewer.userID} AND user2 IN (${directedDeleteIDs})) OR (status = ${directedStatus.PENDING_FRIEND} AND user1 IN (${directedDeleteIDs}) AND user2 = ${viewer.userID}) `; promises.push(dbQuery(directedDeleteQuery)); } if (messageDatas.length > 0) { promises.push(createMessages(viewer, messageDatas, 'broadcast')); } await Promise.all(promises); } else if (action === relationshipActions.UNFRIEND) { updateIDs.push(...userIDs); const updateRows = userIDs.map(userID => { const [user1, user2] = sortIDs(viewer.userID, userID); return { user1, user2, status: undirectedStatus.KNOW_OF }; }); const deleteQuery = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.PENDING_FRIEND} AND (user1 = ${viewer.userID} AND user2 IN (${userIDs}) OR user1 IN (${userIDs}) AND user2 = ${viewer.userID}) `; await Promise.all([ updateUndirectedRelationships(updateRows, false), dbQuery(deleteQuery), ]); } else if (action === relationshipActions.BLOCK) { updateIDs.push(...userIDs); const directedRows = []; const undirectedRows = []; for (const userID of userIDs) { directedRows.push([viewer.userID, userID, directedStatus.BLOCKED]); const [user1, user2] = sortIDs(viewer.userID, userID); undirectedRows.push({ user1, user2, status: undirectedStatus.KNOW_OF }); } const directedInsertQuery = SQL` INSERT INTO relationships_directed (user1, user2, status) VALUES ${directedRows} ON DUPLICATE KEY UPDATE status = VALUE(status) `; const directedDeleteQuery = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.PENDING_FRIEND} AND user1 IN (${userIDs}) AND user2 = ${viewer.userID} `; await Promise.all([ dbQuery(directedInsertQuery), dbQuery(directedDeleteQuery), updateUndirectedRelationships(undirectedRows, false), ]); } else if (action === relationshipActions.UNBLOCK) { updateIDs.push(...userIDs); const query = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.BLOCKED} AND user1 = ${viewer.userID} AND user2 IN (${userIDs}) `; await dbQuery(query); } else { invariant(false, `action ${action} is invalid or not supported currently`); } await createUpdates( updateDatasForUserPairs(cartesianProduct([viewer.userID], updateIDs)), ); return Object.freeze({ ...errors }); } function updateDatasForUserPairs( userPairs: $ReadOnlyArray<[string, string]>, ): UpdateData[] { const time = Date.now(); const updateDatas = []; for (const [user1, user2] of userPairs) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID: user1, time, updatedUserID: user2, }); updateDatas.push({ type: updateTypes.UPDATE_USER, userID: user2, time, updatedUserID: user1, }); } return updateDatas; } async function updateUndirectedRelationships( changeset: UndirectedRelationshipRow[], greatest: boolean = true, ) { if (!changeset.length) { return; } const rows = changeset.map(row => [row.user1, row.user2, row.status]); const query = SQL` INSERT INTO relationships_undirected (user1, user2, status) VALUES ${rows} `; if (greatest) { query.append( SQL`ON DUPLICATE KEY UPDATE status = GREATEST(status, VALUE(status))`, ); } else { query.append(SQL`ON DUPLICATE KEY UPDATE status = VALUE(status)`); } await dbQuery(query); } async function updateChangedUndirectedRelationships( changeset: UndirectedRelationshipRow[], ): Promise { if (changeset.length === 0) { return []; } const user2ByUser1: Map> = new Map(); for (const { user1, user2 } of changeset) { if (!user2ByUser1.has(user1)) { user2ByUser1.set(user1, new Set()); } user2ByUser1.get(user1)?.add(user2); } const selectQuery = SQL` SELECT user1, user2, status FROM relationships_undirected WHERE `; const conditions = []; for (const [user1, users] of user2ByUser1) { conditions.push(SQL`(user1 = ${user1} AND user2 IN (${[...users]}))`); } selectQuery.append(mergeOrConditions(conditions)); const [result] = await dbQuery(selectQuery); const existingStatuses = new Map(); for (const row of result) { existingStatuses.set(`${row.user1}|${row.user2}`, row.status); } const insertRows = []; for (const row of changeset) { const existingStatus = existingStatuses.get(`${row.user1}|${row.user2}`); if (!existingStatus || existingStatus < row.status) { insertRows.push([row.user1, row.user2, row.status]); } } if (insertRows.length === 0) { return []; } const insertQuery = SQL` INSERT INTO relationships_undirected (user1, user2, status) VALUES ${insertRows} ON DUPLICATE KEY UPDATE status = GREATEST(status, VALUE(status)) `; await dbQuery(insertQuery); return updateDatasForUserPairs( insertRows.map(([user1, user2]) => [user1, user2]), ); } async function createPersonalThreads( viewer: Viewer, request: RelationshipRequest, ) { invariant( request.action === relationshipActions.FRIEND, 'We should only create a PERSONAL threads when sending a FRIEND request, ' + `but we tried to do that for ${request.action}`, ); const threadIDPerUser = {}; const personalThreadsQuery = SQL` SELECT t.id AS threadID, m2.user AS user2 FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${viewer.userID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user IN (${request.userIDs}) WHERE t.type = ${threadTypes.PERSONAL} AND m1.role > 0 AND m2.role > 0 `; const [personalThreadsResult] = await dbQuery(personalThreadsQuery); for (const row of personalThreadsResult) { const user2 = row.user2.toString(); threadIDPerUser[user2] = row.threadID.toString(); } const threadCreationPromises = {}; for (const userID of request.userIDs) { if (threadIDPerUser[userID]) { continue; } threadCreationPromises[userID] = createThread( viewer, { type: threadTypes.PERSONAL, initialMemberIDs: [userID], }, { forceAddMembers: true, updatesForCurrentSession: 'broadcast' }, ); } const personalThreadPerUser = await promiseAll(threadCreationPromises); for (const userID in personalThreadPerUser) { const newThread = personalThreadPerUser[userID]; threadIDPerUser[userID] = newThread.newThreadID ?? newThread.newThreadInfo.id; } return threadIDPerUser; } export { updateRelationships, updateDatasForUserPairs, updateUndirectedRelationships, updateChangedUndirectedRelationships, }; diff --git a/keyserver/src/updaters/thread-permission-updaters.js b/keyserver/src/updaters/thread-permission-updaters.js index e181c5962..5ca912593 100644 --- a/keyserver/src/updaters/thread-permission-updaters.js +++ b/keyserver/src/updaters/thread-permission-updaters.js @@ -1,1300 +1,1289 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { makePermissionsBlob, makePermissionsForChildrenBlob, getRoleForPermissions, } from 'lib/permissions/thread-permissions.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import { type ThreadPermissionsBlob, type ThreadRolePermissionsBlob, type ThreadType, assertThreadType, } from 'lib/types/thread-types.js'; import { updateTypes, type ServerUpdateInfo, type CreateUpdatesResult, } from 'lib/types/update-types.js'; import { pushAll } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { updateChangedUndirectedRelationships } from './relationship-updaters.js'; import { createUpdates, type UpdatesForCurrentSession, } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchServerThreadInfos, rawThreadInfosFromServerThreadInfos, type FetchThreadInfosResult, } from '../fetchers/thread-fetchers.js'; import { rescindPushNotifs } from '../push/rescind.js'; import { createScriptViewer } from '../session/scripts.js'; import type { Viewer } from '../session/viewer.js'; import { updateRoles } from '../updaters/role-updaters.js'; import DepthQueue from '../utils/depth-queue.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; export type MembershipRowToSave = { +operation: 'save', +intent: 'join' | 'leave' | 'none', +userID: string, +threadID: string, +userNeedsFullThreadDetails: boolean, +permissions: ?ThreadPermissionsBlob, +permissionsForChildren: ?ThreadPermissionsBlob, // null role represents by "0" +role: string, +oldRole: string, +unread?: boolean, }; type MembershipRowToDelete = { +operation: 'delete', +intent: 'join' | 'leave' | 'none', +userID: string, +threadID: string, +oldRole: string, }; type MembershipRow = MembershipRowToSave | MembershipRowToDelete; type Changeset = { +membershipRows: MembershipRow[], +relationshipChangeset: RelationshipChangeset, }; // 0 role means to remove the user from the thread // null role means to set the user to the default role // string role means to set the user to the role with that ID // -1 role means to set the user as a "ghost" (former member) type ChangeRoleOptions = { +setNewMembersToUnread?: boolean, }; type ChangeRoleMemberInfo = { permissionsFromParent?: ?ThreadPermissionsBlob, memberOfContainingThread?: boolean, }; async function changeRole( threadID: string, userIDs: $ReadOnlyArray, role: string | -1 | 0 | null, options?: ChangeRoleOptions, ): Promise { const intent = role === -1 || role === 0 ? 'leave' : 'join'; const setNewMembersToUnread = options?.setNewMembersToUnread && intent === 'join'; if (userIDs.length === 0) { return { membershipRows: [], relationshipChangeset: new RelationshipChangeset(), }; } const membershipQuery = SQL` SELECT user, role, permissions, permissions_for_children FROM memberships WHERE thread = ${threadID} `; const parentMembershipQuery = SQL` SELECT pm.user, pm.permissions_for_children AS permissions_from_parent FROM threads t INNER JOIN memberships pm ON pm.thread = t.parent_thread_id WHERE t.id = ${threadID} AND (pm.user IN (${userIDs}) OR t.parent_thread_id != ${genesis.id}) `; const containingMembershipQuery = SQL` SELECT cm.user, cm.role AS containing_role FROM threads t INNER JOIN memberships cm ON cm.thread = t.containing_thread_id WHERE t.id = ${threadID} AND cm.user IN (${userIDs}) `; const [ [membershipResults], [parentMembershipResults], containingMembershipResults, roleThreadResult, ] = await Promise.all([ dbQuery(membershipQuery), dbQuery(parentMembershipQuery), (async () => { if (intent === 'leave') { // Membership in the container only needs to be checked for members return []; } const [result] = await dbQuery(containingMembershipQuery); return result; })(), changeRoleThreadQuery(threadID, role), ]); const { roleColumnValue: intendedRole, threadType, parentThreadID, hasContainingThreadID, rolePermissions: intendedRolePermissions, depth, } = roleThreadResult; const existingMembershipInfo = new Map(); for (const row of membershipResults) { const userID = row.user.toString(); existingMembershipInfo.set(userID, { oldRole: row.role.toString(), oldPermissions: JSON.parse(row.permissions), oldPermissionsForChildren: JSON.parse(row.permissions_for_children), }); } const ancestorMembershipInfo: Map = new Map(); for (const row of parentMembershipResults) { const userID = row.user.toString(); if (!userIDs.includes(userID)) { continue; } ancestorMembershipInfo.set(userID, { permissionsFromParent: JSON.parse(row.permissions_from_parent), }); } for (const row of containingMembershipResults) { const userID = row.user.toString(); const ancestorMembership = ancestorMembershipInfo.get(userID); const memberOfContainingThread = row.containing_role > 0; if (ancestorMembership) { ancestorMembership.memberOfContainingThread = memberOfContainingThread; } else { ancestorMembershipInfo.set(userID, { memberOfContainingThread, }); } } const relationshipChangeset = new RelationshipChangeset(); const existingMemberIDs = [...existingMembershipInfo.keys()]; if (threadID !== genesis.id) { relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); } const parentMemberIDs = parentMembershipResults.map(row => row.user.toString(), ); if (parentThreadID && parentThreadID !== genesis.id) { relationshipChangeset.setAllRelationshipsExist(parentMemberIDs); } const membershipRows = []; const toUpdateDescendants = new Map(); for (const userID of userIDs) { const existingMembership = existingMembershipInfo.get(userID); const oldRole = existingMembership?.oldRole ?? '-1'; const oldPermissions = existingMembership?.oldPermissions ?? null; const oldPermissionsForChildren = existingMembership?.oldPermissionsForChildren ?? null; if (existingMembership && oldRole === intendedRole) { // If the old role is the same as the new one, we have nothing to update continue; } else if (Number(oldRole) > 0 && role === null) { // In the case where we're just trying to add somebody to a thread, if // they already have a role with a nonzero role then we don't need to do // anything continue; } let permissionsFromParent = null; let memberOfContainingThread = false; const ancestorMembership = ancestorMembershipInfo.get(userID); if (ancestorMembership) { permissionsFromParent = ancestorMembership.permissionsFromParent; memberOfContainingThread = ancestorMembership.memberOfContainingThread; } if (!hasContainingThreadID) { memberOfContainingThread = true; } const rolePermissions = memberOfContainingThread ? intendedRolePermissions : null; const targetRole = memberOfContainingThread ? intendedRole : '-1'; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); const permissionsForChildren = makePermissionsForChildrenBlob(permissions); const newRole = getRoleForPermissions(targetRole, permissions); const userBecameMember = Number(oldRole) <= 0 && Number(newRole) > 0; const userLostMembership = Number(oldRole) > 0 && Number(newRole) <= 0; if ( (intent === 'join' && Number(newRole) <= 0) || (intent === 'leave' && Number(newRole) > 0) ) { throw new ServerError('invalid_parameters'); } else if (intendedRole !== newRole) { console.warn( `changeRole called for role=${intendedRole}, but ended up setting ` + `role=${newRole} for userID ${userID} and threadID ${threadID}, ` + 'probably because KNOW_OF permission was unexpectedly present or ' + 'missing', ); } if ( existingMembership && _isEqual(permissions)(oldPermissions) && oldRole === newRole ) { // This thread and all of its descendants need no updates for this user, // since the corresponding memberships row is unchanged by this operation continue; } if (permissions) { membershipRows.push({ operation: 'save', intent, userID, threadID, userNeedsFullThreadDetails: userBecameMember, permissions, permissionsForChildren, role: newRole, oldRole, unread: userBecameMember && setNewMembersToUnread, }); } else { membershipRows.push({ operation: 'delete', intent, userID, threadID, oldRole, }); } if (permissions && !existingMembership && threadID !== genesis.id) { relationshipChangeset.setRelationshipsNeeded(userID, existingMemberIDs); } if ( userLostMembership || !_isEqual(permissionsForChildren)(oldPermissionsForChildren) ) { toUpdateDescendants.set(userID, { userIsMember: Number(newRole) > 0, permissionsForChildren, }); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, relationshipChangeset: descendantRelationshipChangeset, } = await updateDescendantPermissions({ threadID, depth, changesByUser: toUpdateDescendants, }); pushAll(membershipRows, descendantMembershipRows); relationshipChangeset.addAll(descendantRelationshipChangeset); } return { membershipRows, relationshipChangeset }; } type RoleThreadResult = { +roleColumnValue: string, +depth: number, +threadType: ThreadType, +parentThreadID: ?string, +hasContainingThreadID: boolean, +rolePermissions: ?ThreadRolePermissionsBlob, }; async function changeRoleThreadQuery( threadID: string, role: string | -1 | 0 | null, ): Promise { if (role === 0 || role === -1) { const query = SQL` SELECT type, depth, parent_thread_id, containing_thread_id FROM threads WHERE id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; return { roleColumnValue: role.toString(), depth: row.depth, threadType: assertThreadType(row.type), parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, hasContainingThreadID: row.containing_thread_id !== null, rolePermissions: null, }; } else if (role !== null) { const query = SQL` SELECT t.type, t.depth, t.parent_thread_id, t.containing_thread_id, r.permissions FROM threads t INNER JOIN roles r ON r.thread = t.id AND r.id = ${role} WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; return { roleColumnValue: role, depth: row.depth, threadType: assertThreadType(row.type), parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, hasContainingThreadID: row.containing_thread_id !== null, rolePermissions: JSON.parse(row.permissions), }; } else { const query = SQL` SELECT t.type, t.depth, t.parent_thread_id, t.containing_thread_id, t.default_role, r.permissions FROM threads t INNER JOIN roles r ON r.thread = t.id AND r.id = t.default_role WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; return { roleColumnValue: row.default_role.toString(), depth: row.depth, threadType: assertThreadType(row.type), parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, hasContainingThreadID: row.containing_thread_id !== null, rolePermissions: JSON.parse(row.permissions), }; } } type ChangedAncestor = { +threadID: string, +depth: number, +changesByUser: Map, }; type AncestorChanges = { +userIsMember: boolean, +permissionsForChildren: ?ThreadPermissionsBlob, }; async function updateDescendantPermissions( initialChangedAncestor: ChangedAncestor, ): Promise { const membershipRows = []; const relationshipChangeset = new RelationshipChangeset(); const initialDescendants = await fetchDescendantsForUpdate([ initialChangedAncestor, ]); const depthQueue = new DepthQueue( getDescendantDepth, getDescendantKey, mergeDescendants, ); depthQueue.addInfos(initialDescendants); let descendants; while ((descendants = depthQueue.getNextDepth())) { const descendantsAsAncestors = []; for (const descendant of descendants) { const { threadID, threadType, depth, users } = descendant; const existingMembers = [...users.entries()]; const existingMemberIDs = existingMembers .filter(([, { curRole }]) => curRole) .map(([userID]) => userID); if (threadID !== genesis.id) { relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); } const usersForNextLayer = new Map(); for (const [userID, user] of users) { const { curRolePermissions, curPermissionsFromParent, curMemberOfContainingThread, nextMemberOfContainingThread, nextPermissionsFromParent, potentiallyNeedsUpdate, } = user; const existingMembership = !!user.curRole; const curRole = user.curRole ?? '-1'; const curPermissions = user.curPermissions ?? null; const curPermissionsForChildren = user.curPermissionsForChildren ?? null; if (!potentiallyNeedsUpdate) { continue; } const permissionsFromParent = nextPermissionsFromParent === undefined ? curPermissionsFromParent : nextPermissionsFromParent; const memberOfContainingThread = nextMemberOfContainingThread === undefined ? curMemberOfContainingThread : nextMemberOfContainingThread; const targetRole = memberOfContainingThread ? curRole : '-1'; const rolePermissions = memberOfContainingThread ? curRolePermissions : null; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); - const permissionsForChildren = makePermissionsForChildrenBlob( - permissions, - ); + const permissionsForChildren = + makePermissionsForChildrenBlob(permissions); const newRole = getRoleForPermissions(targetRole, permissions); const userLostMembership = Number(curRole) > 0 && Number(newRole) <= 0; if (_isEqual(permissions)(curPermissions) && curRole === newRole) { // This thread and all of its descendants need no updates for this // user, since the corresponding memberships row is unchanged by this // operation continue; } if (permissions) { membershipRows.push({ operation: 'save', intent: 'none', userID, threadID, userNeedsFullThreadDetails: false, permissions, permissionsForChildren, role: newRole, oldRole: curRole, }); } else { membershipRows.push({ operation: 'delete', intent: 'none', userID, threadID, oldRole: curRole, }); } if (permissions && !existingMembership && threadID !== genesis.id) { // If there was no membership row before, and we are creating one, // we'll need to make sure the new member has a relationship row with // each existing member. We expect that whoever called us already // generated memberships row for the new members, will will lead // saveMemberships to generate relationships rows between those new // users. relationshipChangeset.setRelationshipsNeeded( userID, existingMemberIDs, ); } if ( userLostMembership || !_isEqual(permissionsForChildren)(curPermissionsForChildren) ) { usersForNextLayer.set(userID, { userIsMember: Number(newRole) > 0, permissionsForChildren, }); } } if (usersForNextLayer.size > 0) { descendantsAsAncestors.push({ threadID, depth, changesByUser: usersForNextLayer, }); } } const nextDescendants = await fetchDescendantsForUpdate( descendantsAsAncestors, ); depthQueue.addInfos(nextDescendants); } return { membershipRows, relationshipChangeset }; } type DescendantUserInfo = $Shape<{ curRole?: string, curRolePermissions?: ?ThreadRolePermissionsBlob, curPermissions?: ?ThreadPermissionsBlob, curPermissionsForChildren?: ?ThreadPermissionsBlob, curPermissionsFromParent?: ?ThreadPermissionsBlob, curMemberOfContainingThread?: boolean, nextPermissionsFromParent?: ?ThreadPermissionsBlob, nextMemberOfContainingThread?: boolean, potentiallyNeedsUpdate?: boolean, }>; type DescendantInfo = { +threadID: string, +parentThreadID: string, +containingThreadID: string, +threadType: ThreadType, +depth: number, +users: Map, }; const fetchDescendantsBatchSize = 10; async function fetchDescendantsForUpdate( ancestors: $ReadOnlyArray, ): Promise { const threadIDs = ancestors.map(ancestor => ancestor.threadID); const rows = []; while (threadIDs.length > 0) { const batch = threadIDs.splice(0, fetchDescendantsBatchSize); const query = SQL` SELECT t.id, m.user, t.type, t.depth, t.parent_thread_id, t.containing_thread_id, r.permissions AS role_permissions, m.permissions, m.permissions_for_children, m.role, pm.permissions_for_children AS permissions_from_parent, cm.role AS containing_role FROM threads t INNER JOIN memberships m ON m.thread = t.id LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id AND pm.user = m.user LEFT JOIN memberships cm ON cm.thread = t.containing_thread_id AND cm.user = m.user LEFT JOIN roles r ON r.id = m.role WHERE t.parent_thread_id IN (${batch}) OR t.containing_thread_id IN (${batch}) `; const [results] = await dbQuery(query); pushAll(rows, results); } const descendantThreadInfos: Map = new Map(); for (const row of rows) { const descendantThreadID = row.id.toString(); if (!descendantThreadInfos.has(descendantThreadID)) { descendantThreadInfos.set(descendantThreadID, { threadID: descendantThreadID, parentThreadID: row.parent_thread_id.toString(), containingThreadID: row.containing_thread_id.toString(), threadType: assertThreadType(row.type), depth: row.depth, users: new Map(), }); } const descendantThreadInfo = descendantThreadInfos.get(descendantThreadID); invariant( descendantThreadInfo, `value should exist for key ${descendantThreadID}`, ); const userID = row.user.toString(); descendantThreadInfo.users.set(userID, { curRole: row.role.toString(), curRolePermissions: JSON.parse(row.role_permissions), curPermissions: JSON.parse(row.permissions), curPermissionsForChildren: JSON.parse(row.permissions_for_children), curPermissionsFromParent: JSON.parse(row.permissions_from_parent), curMemberOfContainingThread: row.containing_role > 0, }); } for (const ancestor of ancestors) { const { threadID, changesByUser } = ancestor; for (const [userID, changes] of changesByUser) { for (const descendantThreadInfo of descendantThreadInfos.values()) { - const { - users, - parentThreadID, - containingThreadID, - } = descendantThreadInfo; + const { users, parentThreadID, containingThreadID } = + descendantThreadInfo; if (threadID !== parentThreadID && threadID !== containingThreadID) { continue; } let user = users.get(userID); if (!user) { user = {}; users.set(userID, user); } if (threadID === parentThreadID) { user.nextPermissionsFromParent = changes.permissionsForChildren; user.potentiallyNeedsUpdate = true; } if (threadID === containingThreadID) { user.nextMemberOfContainingThread = changes.userIsMember; if (!user.nextMemberOfContainingThread) { user.potentiallyNeedsUpdate = true; } } } } } return [...descendantThreadInfos.values()]; } function getDescendantDepth(descendant: DescendantInfo): number { return descendant.depth; } function getDescendantKey(descendant: DescendantInfo): string { return descendant.threadID; } function mergeDescendants( a: DescendantInfo, b: DescendantInfo, ): DescendantInfo { const { users: usersA, ...restA } = a; const { users: usersB, ...restB } = b; if (!_isEqual(restA)(restB)) { console.warn( `inconsistent descendantInfos ${JSON.stringify(restA)}, ` + JSON.stringify(restB), ); throw new ServerError('internal_error'); } const newUsers = new Map(usersA); for (const [userID, userFromB] of usersB) { const userFromA = newUsers.get(userID); if (!userFromA) { newUsers.set(userID, userFromB); } else { newUsers.set(userID, { ...userFromA, ...userFromB }); } } return { ...a, users: newUsers }; } type RecalculatePermissionsMemberInfo = { role?: ?string, permissions?: ?ThreadPermissionsBlob, permissionsForChildren?: ?ThreadPermissionsBlob, rolePermissions?: ?ThreadRolePermissionsBlob, memberOfContainingThread?: boolean, permissionsFromParent?: ?ThreadPermissionsBlob, }; async function recalculateThreadPermissions( threadID: string, ): Promise { const threadQuery = SQL` SELECT type, depth, parent_thread_id, containing_thread_id FROM threads WHERE id = ${threadID} `; const membershipQuery = SQL` SELECT m.user, m.role, m.permissions, m.permissions_for_children, r.permissions AS role_permissions, cm.role AS containing_role FROM threads t INNER JOIN memberships m ON m.thread = t.id LEFT JOIN roles r ON r.id = m.role LEFT JOIN memberships cm ON cm.user = m.user AND cm.thread = t.containing_thread_id WHERE t.id = ${threadID} `; const parentMembershipQuery = SQL` SELECT pm.user, pm.permissions_for_children AS permissions_from_parent FROM threads t INNER JOIN memberships pm ON pm.thread = t.parent_thread_id WHERE t.id = ${threadID} `; - const [ - [threadResults], - [membershipResults], - [parentMembershipResults], - ] = await Promise.all([ - dbQuery(threadQuery), - dbQuery(membershipQuery), - dbQuery(parentMembershipQuery), - ]); + const [[threadResults], [membershipResults], [parentMembershipResults]] = + await Promise.all([ + dbQuery(threadQuery), + dbQuery(membershipQuery), + dbQuery(parentMembershipQuery), + ]); if (threadResults.length !== 1) { throw new ServerError('internal_error'); } const [threadResult] = threadResults; const threadType = assertThreadType(threadResult.type); const depth = threadResult.depth; const hasContainingThreadID = threadResult.containing_thread_id !== null; const parentThreadID = threadResult.parent_thread_id?.toString(); - const membershipInfo: Map< - string, - RecalculatePermissionsMemberInfo, - > = new Map(); + const membershipInfo: Map = + new Map(); for (const row of membershipResults) { const userID = row.user.toString(); membershipInfo.set(userID, { role: row.role.toString(), permissions: JSON.parse(row.permissions), permissionsForChildren: JSON.parse(row.permissions_for_children), rolePermissions: JSON.parse(row.role_permissions), memberOfContainingThread: !!( row.containing_role && row.containing_role > 0 ), }); } for (const row of parentMembershipResults) { const userID = row.user.toString(); const permissionsFromParent = JSON.parse(row.permissions_from_parent); const membership = membershipInfo.get(userID); if (membership) { membership.permissionsFromParent = permissionsFromParent; } else { membershipInfo.set(userID, { permissionsFromParent: permissionsFromParent, }); } } const relationshipChangeset = new RelationshipChangeset(); const existingMemberIDs = membershipResults.map(row => row.user.toString()); if (threadID !== genesis.id) { relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); } const parentMemberIDs = parentMembershipResults.map(row => row.user.toString(), ); if (parentThreadID && parentThreadID !== genesis.id) { relationshipChangeset.setAllRelationshipsExist(parentMemberIDs); } const membershipRows = []; const toUpdateDescendants = new Map(); for (const [userID, membership] of membershipInfo) { - const { - rolePermissions: intendedRolePermissions, - permissionsFromParent, - } = membership; + const { rolePermissions: intendedRolePermissions, permissionsFromParent } = + membership; const oldPermissions = membership?.permissions ?? null; const oldPermissionsForChildren = membership?.permissionsForChildren ?? null; const existingMembership = membership.role !== undefined; const oldRole = membership.role ?? '-1'; const memberOfContainingThread = hasContainingThreadID ? !!membership.memberOfContainingThread : true; const targetRole = memberOfContainingThread ? oldRole : '-1'; const rolePermissions = memberOfContainingThread ? intendedRolePermissions : null; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); const permissionsForChildren = makePermissionsForChildrenBlob(permissions); const newRole = getRoleForPermissions(targetRole, permissions); const userLostMembership = Number(oldRole) > 0 && Number(newRole) <= 0; if (_isEqual(permissions)(oldPermissions) && oldRole === newRole) { // This thread and all of its descendants need no updates for this user, // since the corresponding memberships row is unchanged by this operation continue; } if (permissions) { membershipRows.push({ operation: 'save', intent: 'none', userID, threadID, userNeedsFullThreadDetails: false, permissions, permissionsForChildren, role: newRole, oldRole, }); } else { membershipRows.push({ operation: 'delete', intent: 'none', userID, threadID, oldRole, }); } if (permissions && !existingMembership && threadID !== genesis.id) { // If there was no membership row before, and we are creating one, // we'll need to make sure the new member has a relationship row with // each existing member. We handle guaranteeing that new members have // relationship rows with each other in saveMemberships. relationshipChangeset.setRelationshipsNeeded(userID, existingMemberIDs); } if ( userLostMembership || !_isEqual(permissionsForChildren)(oldPermissionsForChildren) ) { toUpdateDescendants.set(userID, { userIsMember: Number(newRole) > 0, permissionsForChildren, }); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, relationshipChangeset: descendantRelationshipChangeset, } = await updateDescendantPermissions({ threadID, depth, changesByUser: toUpdateDescendants, }); pushAll(membershipRows, descendantMembershipRows); relationshipChangeset.addAll(descendantRelationshipChangeset); } return { membershipRows, relationshipChangeset }; } const defaultSubscriptionString = JSON.stringify({ home: false, pushNotifs: false, }); const joinSubscriptionString = JSON.stringify({ home: true, pushNotifs: true }); const membershipInsertBatchSize = 50; async function saveMemberships(toSave: $ReadOnlyArray) { if (toSave.length === 0) { return; } const time = Date.now(); const insertRows = []; for (const rowToSave of toSave) { insertRows.push([ rowToSave.userID, rowToSave.threadID, rowToSave.role, time, rowToSave.intent === 'join' ? joinSubscriptionString : defaultSubscriptionString, rowToSave.permissions ? JSON.stringify(rowToSave.permissions) : null, rowToSave.permissionsForChildren ? JSON.stringify(rowToSave.permissionsForChildren) : null, rowToSave.unread ? 1 : 0, 0, ]); } // Logic below will only update an existing membership row's `subscription` // column if the user is either joining or leaving the thread. That means // there's no way to use this function to update a user's subscription without // also making them join or leave the thread. The reason we do this is because // we need to specify a value for `subscription` here, as it's a non-null // column and this is an INSERT, but we don't want to require people to have // to know the current `subscription` when they're just using this function to // update the permissions of an existing membership row. while (insertRows.length > 0) { const batch = insertRows.splice(0, membershipInsertBatchSize); const query = SQL` INSERT INTO memberships (user, thread, role, creation_time, subscription, permissions, permissions_for_children, last_message, last_read_message) VALUES ${batch} ON DUPLICATE KEY UPDATE subscription = IF( (role <= 0 AND VALUE(role) > 0) OR (role > 0 AND VALUE(role) <= 0), VALUE(subscription), subscription ), role = VALUE(role), permissions = VALUE(permissions), permissions_for_children = VALUE(permissions_for_children) `; await dbQuery(query); } } async function deleteMemberships( toDelete: $ReadOnlyArray, ) { if (toDelete.length === 0) { return; } const time = Date.now(); const insertRows = toDelete.map(rowToDelete => [ rowToDelete.userID, rowToDelete.threadID, -1, time, defaultSubscriptionString, null, null, 0, 0, ]); while (insertRows.length > 0) { const batch = insertRows.splice(0, membershipInsertBatchSize); const query = SQL` INSERT INTO memberships (user, thread, role, creation_time, subscription, permissions, permissions_for_children, last_message, last_read_message) VALUES ${batch} ON DUPLICATE KEY UPDATE role = -1, permissions = NULL, permissions_for_children = NULL, subscription = ${defaultSubscriptionString}, last_message = 0, last_read_message = 0 `; await dbQuery(query); } } const emptyCommitMembershipChangesetConfig = Object.freeze({}); // Specify non-empty changedThreadIDs to force updates to be generated for those // threads, presumably for reasons not covered in the changeset. calendarQuery // only needs to be specified if a JOIN_THREAD update will be generated for the // viewer, in which case it's necessary for knowing the set of entries to fetch. type ChangesetCommitResult = { ...FetchThreadInfosResult, ...CreateUpdatesResult, }; async function commitMembershipChangeset( viewer: Viewer, changeset: Changeset, { changedThreadIDs = new Set(), calendarQuery, updatesForCurrentSession = 'return', }: { +changedThreadIDs?: Set, +calendarQuery?: ?CalendarQuery, +updatesForCurrentSession?: UpdatesForCurrentSession, } = emptyCommitMembershipChangesetConfig, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { membershipRows, relationshipChangeset } = changeset; const membershipRowMap = new Map(); for (const row of membershipRows) { const { userID, threadID } = row; changedThreadIDs.add(threadID); const pairString = `${userID}|${threadID}`; const existing = membershipRowMap.get(pairString); invariant( !existing || existing.intent === 'none' || row.intent === 'none', `multiple intents provided for ${pairString}`, ); if (!existing || existing.intent === 'none') { membershipRowMap.set(pairString, row); } } const toSave = [], toDelete = [], toRescindPushNotifs = []; for (const row of membershipRowMap.values()) { if ( row.operation === 'delete' || (row.operation === 'save' && Number(row.role) <= 0) ) { const { userID, threadID } = row; toRescindPushNotifs.push({ userID, threadID }); } if (row.operation === 'delete') { toDelete.push(row); } else { toSave.push(row); } } const threadsToSavedUsers = new Map(); for (const row of membershipRowMap.values()) { const { userID, threadID } = row; let savedUsers = threadsToSavedUsers.get(threadID); if (!savedUsers) { savedUsers = []; threadsToSavedUsers.set(threadID, savedUsers); } savedUsers.push(userID); } for (const [threadID, savedUsers] of threadsToSavedUsers) { if (threadID !== genesis.id) { relationshipChangeset.setAllRelationshipsNeeded(savedUsers); } } const relationshipRows = relationshipChangeset.getRows(); const [updateDatas] = await Promise.all([ updateChangedUndirectedRelationships(relationshipRows), saveMemberships(toSave), deleteMemberships(toDelete), rescindPushNotifsForMemberDeletion(toRescindPushNotifs), ]); // We fetch all threads here because old clients still expect the full list of // threads on most thread operations. Once verifyClientSupported gates on // codeVersion 62, we can add a WHERE clause on changedThreadIDs here const serverThreadInfoFetchResult = await fetchServerThreadInfos(); const { threadInfos: serverThreadInfos } = serverThreadInfoFetchResult; const time = Date.now(); for (const changedThreadID of changedThreadIDs) { const serverThreadInfo = serverThreadInfos[changedThreadID]; for (const memberInfo of serverThreadInfo.members) { const pairString = `${memberInfo.id}|${serverThreadInfo.id}`; const membershipRow = membershipRowMap.get(pairString); if (membershipRow) { continue; } updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: memberInfo.id, time, threadID: changedThreadID, }); } } for (const row of membershipRowMap.values()) { const { userID, threadID } = row; if (row.operation === 'delete' || row.role === '-1') { if (row.oldRole !== '-1') { updateDatas.push({ type: updateTypes.DELETE_THREAD, userID, time, threadID, }); } } else if (row.userNeedsFullThreadDetails) { updateDatas.push({ type: updateTypes.JOIN_THREAD, userID, time, threadID, }); } else { updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID, time, threadID, }); } } const threadInfoFetchResult = rawThreadInfosFromServerThreadInfos( viewer, serverThreadInfoFetchResult, ); const { viewerUpdates, userInfos } = await createUpdates(updateDatas, { viewer, calendarQuery, ...threadInfoFetchResult, updatesForCurrentSession, }); return { ...threadInfoFetchResult, userInfos, viewerUpdates, }; } const emptyGetChangesetCommitResultConfig = Object.freeze({}); // When the user tries to create a new thread, it's possible for the client to // fail the creation even if a row gets added to the threads table. This may // occur due to a timeout (on either the client or server side), or due to some // error in the server code following the INSERT operation. Handling the error // scenario is more challenging since it would require detecting which set of // operations failed so we could retry them. As a result, this code is geared at // only handling the timeout scenario. async function getChangesetCommitResultForExistingThread( viewer: Viewer, threadID: string, otherUpdates: $ReadOnlyArray, { calendarQuery, updatesForCurrentSession = 'return', }: { +calendarQuery?: ?CalendarQuery, +updatesForCurrentSession?: UpdatesForCurrentSession, } = emptyGetChangesetCommitResultConfig, ): Promise { for (const update of otherUpdates) { if ( update.type === updateTypes.JOIN_THREAD && update.threadInfo.id === threadID ) { // If the JOIN_THREAD is already there we can expect // the appropriate UPDATE_USERs to be covered as well return { viewerUpdates: otherUpdates, userInfos: {} }; } } const time = Date.now(); const updateDatas = [ { type: updateTypes.JOIN_THREAD, userID: viewer.userID, time, threadID, targetSession: viewer.session, }, ]; // To figure out what UserInfos might be missing, we consider the worst case: // the same client previously attempted to create a thread with a non-friend // they found via search results, but the request timed out. In this scenario // the viewer might never have received the UPDATE_USER that would add that // UserInfo to their UserStore, but the server assumed the client had gotten // it because createUpdates was called with UpdatesForCurrentSession=return. // For completeness here we query for the full list of memberships rows in the // thread. We can't use fetchServerThreadInfos because it skips role=-1 rows const membershipsQuery = SQL` SELECT user FROM memberships WHERE thread = ${threadID} AND user != ${viewer.userID} `; const [results] = await dbQuery(membershipsQuery); for (const row of results) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID: viewer.userID, time, updatedUserID: row.user.toString(), targetSession: viewer.session, }); } const { viewerUpdates, userInfos } = await createUpdates(updateDatas, { viewer, calendarQuery, updatesForCurrentSession, }); return { viewerUpdates: [...otherUpdates, ...viewerUpdates], userInfos }; } const rescindPushNotifsBatchSize = 3; async function rescindPushNotifsForMemberDeletion( toRescindPushNotifs: $ReadOnlyArray<{ +userID: string, +threadID: string }>, ): Promise { const queue = [...toRescindPushNotifs]; while (queue.length > 0) { const batch = queue.splice(0, rescindPushNotifsBatchSize); await Promise.all( batch.map(({ userID, threadID }) => rescindPushNotifs( SQL`n.thread = ${threadID} AND n.user = ${userID}`, SQL`IF(m.thread = ${threadID}, NULL, m.thread)`, ), ), ); } } async function recalculateAllThreadPermissions() { const getAllThreads = SQL`SELECT id FROM threads`; const [result] = await dbQuery(getAllThreads); // We handle each thread one-by-one to avoid a situation where a permission // calculation for a child thread, done during a call to // recalculateThreadPermissions for the parent thread, can be incorrectly // overriden by a call to recalculateThreadPermissions for the child thread. // If the changeset resulting from the parent call isn't committed before the // calculation is done for the child, the calculation done for the child can // be incorrect. const viewer = createScriptViewer(bots.commbot.userID); for (const row of result) { const threadID = row.id.toString(); const changeset = await recalculateThreadPermissions(threadID); await commitMembershipChangeset(viewer, changeset); } } async function updateRolesAndPermissionsForAllThreads() { const batchSize = 10; const fetchThreads = SQL`SELECT id, type, depth FROM threads`; const [result] = await dbQuery(fetchThreads); const allThreads = result.map(row => { return { id: row.id.toString(), type: assertThreadType(row.type), depth: row.depth, }; }); const viewer = createScriptViewer(bots.commbot.userID); const maxDepth = Math.max(...allThreads.map(row => row.depth)); for (let depth = 0; depth <= maxDepth; depth++) { const threads = allThreads.filter(row => row.depth === depth); console.log(`recalculating permissions for threads with depth ${depth}`); while (threads.length > 0) { const batch = threads.splice(0, batchSize); const membershipRows = []; const relationshipChangeset = new RelationshipChangeset(); await Promise.all( batch.map(async thread => { console.log(`updating roles for ${thread.id}`); await updateRoles(viewer, thread.id, thread.type); console.log(`recalculating permissions for ${thread.id}`); const { membershipRows: threadMembershipRows, relationshipChangeset: threadRelationshipChangeset, } = await recalculateThreadPermissions(thread.id); membershipRows.push(...threadMembershipRows); relationshipChangeset.addAll(threadRelationshipChangeset); }), ); console.log(`committing batch ${JSON.stringify(batch)}`); await commitMembershipChangeset(viewer, { membershipRows, relationshipChangeset, }); } } } export { changeRole, recalculateThreadPermissions, getChangesetCommitResultForExistingThread, saveMemberships, commitMembershipChangeset, recalculateAllThreadPermissions, updateRolesAndPermissionsForAllThreads, }; diff --git a/keyserver/src/updaters/thread-updaters.js b/keyserver/src/updaters/thread-updaters.js index c11e34021..db2d214b1 100644 --- a/keyserver/src/updaters/thread-updaters.js +++ b/keyserver/src/updaters/thread-updaters.js @@ -1,853 +1,851 @@ // @flow import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js'; import { threadHasAdminRole, roleIsAdminRole, viewerIsMember, getThreadTypeParentRequirement, validChatNameRegex, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Shape } from 'lib/types/core.js'; import { messageTypes, defaultNumberPerThread, } from 'lib/types/message-types.js'; import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, threadPermissions, threadTypes, } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import { updateRoles } from './role-updaters.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, } from './thread-permission-updaters.js'; import createMessages from '../creators/message-creator.js'; import { getRolePermissionBlobs } from '../creators/role-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfos } from '../fetchers/message-fetchers.js'; import { fetchThreadInfos, fetchServerThreadInfos, determineThreadAncestry, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, viewerIsMember as fetchViewerIsMember, checkThread, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import { verifyUserIDs, verifyUserOrCookieIDs, } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; async function updateRole( viewer: Viewer, request: RoleChangeRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT user, role FROM memberships WHERE user IN (${memberIDs}) AND thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonMemberUser = false; let numResults = 0; for (const row of result) { if (row.role <= 0) { nonMemberUser = true; break; } numResults++; } if (nonMemberUser || numResults < memberIDs.length) { throw new ServerError('invalid_parameters'); } const changeset = await changeRole(request.threadID, memberIDs, request.role); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, }; const newMessageInfos = await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function removeMembers( viewer: Viewer, request: RemoveMembersRequest, ): Promise { const viewerID = viewer.userID; if (request.memberIDs.includes(viewerID)) { throw new ServerError('invalid_parameters'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserOrCookieIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.REMOVE_MEMBERS, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT m.user, m.role, t.default_role FROM memberships m LEFT JOIN threads t ON t.id = m.thread WHERE m.user IN (${memberIDs}) AND m.thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonDefaultRoleUser = false; const actualMemberIDs = []; for (const row of result) { if (row.role <= 0) { continue; } actualMemberIDs.push(row.user.toString()); if (row.role !== row.default_role) { nonDefaultRoleUser = true; } } if (nonDefaultRoleUser) { const hasChangeRolePermission = await checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ); if (!hasChangeRolePermission) { throw new ServerError('invalid_credentials'); } } const changeset = await changeRole(request.threadID, actualMemberIDs, 0); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const newMessageInfos = await (async () => { if (actualMemberIDs.length === 0) { return []; } const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; return await createMessages(viewer, [messageData]); })(); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [fetchThreadResult, hasPermission] = await Promise.all([ fetchThreadInfos(viewer, SQL`t.id = ${request.threadID}`), checkThreadPermission( viewer, request.threadID, threadPermissions.LEAVE_THREAD, ), ]); const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!viewerIsMember(threadInfo)) { if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: [] }, }; } const { threadInfos } = await fetchThreadInfos(viewer); return { threadInfos, updatesResult: { newUpdates: [], }, }; } if (!hasPermission) { throw new ServerError('invalid_parameters'); } const viewerID = viewer.userID; if (threadHasAdminRole(threadInfo)) { let otherUsersExist = false; let otherAdminsExist = false; for (const member of threadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo.roles[role])) { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates } }; } return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } type UpdateThreadOptions = Shape<{ +forceAddMembers: boolean, +forceUpdateRoot: boolean, +silenceMessages: boolean, +ignorePermissions: boolean, }>; async function updateThread( viewer: Viewer, request: UpdateThreadRequest, options?: UpdateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const forceUpdateRoot = options?.forceUpdateRoot ?? false; const silenceMessages = options?.silenceMessages ?? false; const ignorePermissions = (options?.ignorePermissions && viewer.isScriptViewer) ?? false; const validationPromises = {}; const changedFields = {}; const sqlUpdate = {}; const untrimmedName = request.changes.name; if (untrimmedName !== undefined && untrimmedName !== null) { const name = firstLine(untrimmedName); if (name.search(validChatNameRegex) === -1) { throw new ServerError('invalid_chat_name'); } changedFields.name = name; sqlUpdate.name = name ?? null; } const { description } = request.changes; if (description !== undefined && description !== null) { changedFields.description = description; sqlUpdate.description = description ?? null; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const { parentThreadID } = request.changes; if (parentThreadID !== undefined) { // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } if ( !ignorePermissions && threadType !== null && threadType !== undefined && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && threadType !== threadTypes.COMMUNITY_SECRET_SUBTHREAD ) { throw new ServerError('invalid_parameters'); } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...new Set(request.changes.newMemberIDs)] : null; if ( Object.keys(sqlUpdate).length === 0 && !newMemberIDs && !forceUpdateRoot ) { throw new ServerError('invalid_parameters'); } validationPromises.serverThreadInfos = fetchServerThreadInfos( SQL`t.id = ${request.threadID}`, ); validationPromises.hasNecessaryPermissions = (async () => { if (ignorePermissions) { return; } const checks = []; if (sqlUpdate.name !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_NAME, }); } if (sqlUpdate.description !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_DESCRIPTION, }); } if (sqlUpdate.color !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_COLOR, }); } if (parentThreadID !== undefined || sqlUpdate.type !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_PERMISSIONS, }); } if (newMemberIDs) { checks.push({ check: 'permission', permission: threadPermissions.ADD_MEMBERS, }); } const hasNecessaryPermissions = await checkThread( viewer, request.threadID, checks, ); if (!hasNecessaryPermissions) { throw new ServerError('invalid_credentials'); } })(); const { serverThreadInfos } = await promiseAll(validationPromises); const serverThreadInfo = serverThreadInfos.threadInfos[request.threadID]; if (!serverThreadInfo) { throw new ServerError('internal_error'); } // Threads with source message should be visible to everyone, but we can't // guarantee it for COMMUNITY_SECRET_SUBTHREAD threads so we forbid it for // now. In the future, if we want to support this, we would need to unlink the // source message. if ( threadType !== null && threadType !== undefined && threadType !== threadTypes.SIDEBAR && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && serverThreadInfo.sourceMessageID ) { throw new ServerError('invalid_parameters'); } // You can't change the parent thread of a current or former SIDEBAR if (parentThreadID !== undefined && serverThreadInfo.sourceMessageID) { throw new ServerError('invalid_parameters'); } const oldThreadType = serverThreadInfo.type; const oldParentThreadID = serverThreadInfo.parentThreadID; const oldContainingThreadID = serverThreadInfo.containingThreadID; const oldCommunity = serverThreadInfo.community; const oldDepth = serverThreadInfo.depth; const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; let nextParentThreadID = parentThreadID !== undefined ? parentThreadID : oldParentThreadID; // Does the new thread type preclude a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'disabled' && nextParentThreadID !== null ) { nextParentThreadID = null; sqlUpdate.parent_thread_id = null; } // Does the new thread type require a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'required' && nextParentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const determineThreadAncestryPromise = determineThreadAncestry( nextParentThreadID, nextThreadType, ); const confirmParentPermissionPromise = (async () => { if (ignorePermissions || !nextParentThreadID) { return; } if ( nextParentThreadID === oldParentThreadID && (nextThreadType === threadTypes.SIDEBAR) === (oldThreadType === threadTypes.SIDEBAR) ) { return; } const hasParentPermission = await checkThreadPermission( viewer, nextParentThreadID, nextThreadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_parameters'); } })(); const rolesNeedUpdate = forceUpdateRoot || nextThreadType !== oldThreadType; const validateNewMembersPromise = (async () => { if (!newMemberIDs || ignorePermissions) { return; } const defaultRolePermissionsPromise = (async () => { let rolePermissions; if (!rolesNeedUpdate) { const rolePermissionsQuery = SQL` SELECT r.permissions FROM threads t LEFT JOIN roles r ON r.id = t.default_role WHERE t.id = ${request.threadID} `; const [result] = await dbQuery(rolePermissionsQuery); if (result.length > 0) { rolePermissions = JSON.parse(result[0].permissions); } } if (!rolePermissions) { rolePermissions = getRolePermissionBlobs(nextThreadType).Members; } return rolePermissions; })(); const [defaultRolePermissions, nextThreadAncestry] = await Promise.all([ defaultRolePermissionsPromise, determineThreadAncestryPromise, ]); const { newMemberIDs: validatedIDs } = await validateCandidateMembers( viewer, { newMemberIDs }, { threadType: nextThreadType, parentThreadID: nextParentThreadID, containingThreadID: nextThreadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !forceAddMembers }, ); if ( validatedIDs && Number(validatedIDs?.length) < Number(newMemberIDs?.length) ) { throw new ServerError('invalid_credentials'); } })(); const { nextThreadAncestry } = await promiseAll({ nextThreadAncestry: determineThreadAncestryPromise, confirmParentPermissionPromise, validateNewMembersPromise, }); if (nextThreadAncestry.containingThreadID !== oldContainingThreadID) { sqlUpdate.containing_thread_id = nextThreadAncestry.containingThreadID; } if (nextThreadAncestry.community !== oldCommunity) { if (!ignorePermissions) { throw new ServerError('invalid_parameters'); } sqlUpdate.community = nextThreadAncestry.community; } if (nextThreadAncestry.depth !== oldDepth) { sqlUpdate.depth = nextThreadAncestry.depth; } const updateQueryPromise = (async () => { if (Object.keys(sqlUpdate).length === 0) { return; } const updateQuery = SQL` UPDATE threads SET ${sqlUpdate} WHERE id = ${request.threadID} `; await dbQuery(updateQuery); })(); const updateRolesPromise = (async () => { if (rolesNeedUpdate) { await updateRoles(viewer, request.threadID, nextThreadType); } })(); const intermediatePromises = {}; intermediatePromises.updateQuery = updateQueryPromise; intermediatePromises.updateRoles = updateRolesPromise; if (newMemberIDs) { intermediatePromises.addMembersChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await changeRole(request.threadID, newMemberIDs, null, { setNewMembersToUnread: true, }); })(); } const threadRootChanged = rolesNeedUpdate || nextParentThreadID !== oldParentThreadID; if (threadRootChanged) { intermediatePromises.recalculatePermissionsChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await recalculateThreadPermissions(request.threadID); })(); } - const { - addMembersChangeset, - recalculatePermissionsChangeset, - } = await promiseAll(intermediatePromises); + const { addMembersChangeset, recalculatePermissionsChangeset } = + await promiseAll(intermediatePromises); const membershipRows = []; const relationshipChangeset = new RelationshipChangeset(); if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipChangeset.addAll(recalculateRelationshipChangeset); } let addedMemberIDs; if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipChangeset: addMembersRelationshipChangeset, } = addMembersChangeset; addedMemberIDs = addMembersMembershipRows .filter( row => row.operation === 'save' && row.threadID === request.threadID && Number(row.role) > 0, ) .map(row => row.userID); membershipRows.push(...addMembersMembershipRows); relationshipChangeset.addAll(addMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, { // This forces an update for this thread, // regardless of whether any membership rows are changed changedThreadIDs: Object.keys(sqlUpdate).length > 0 ? new Set([request.threadID]) : new Set(), }, ); let newMessageInfos = []; if (!silenceMessages) { const time = Date.now(); const messageDatas = []; for (const fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (addedMemberIDs && addedMemberIDs.length > 0) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: addedMemberIDs, }); } newMessageInfos = await createMessages(viewer, messageDatas); } if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [isMember, hasPermission] = await Promise.all([ fetchViewerIsMember(viewer, request.threadID), checkThreadPermission( viewer, request.threadID, threadPermissions.JOIN_THREAD, ), ]); if (!hasPermission) { throw new ServerError('invalid_parameters'); } // TODO: determine code version const hasCodeVersionBelow87 = !hasMinCodeVersion(viewer.platformDetails, 87); const hasCodeVersionBelow62 = !hasMinCodeVersion(viewer.platformDetails, 62); const { calendarQuery } = request; if (isMember) { const response: ThreadJoinResult = { rawMessageInfos: [], truncationStatuses: {}, userInfos: {}, updatesResult: { newUpdates: [], }, }; if (calendarQuery && hasCodeVersionBelow87) { response.rawEntryInfos = []; } if (hasCodeVersionBelow62) { response.threadInfos = {}; } return response; } if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewer.userID], null); const membershipResult = await commitMembershipChangeset(viewer, changeset, { calendarQuery, }); const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; const newMessages = await createMessages(viewer, [messageData]); const messageSelectionCriteria = { threadCursors: { [request.threadID]: false }, }; if (!hasCodeVersionBelow87) { return { rawMessageInfos: newMessages, truncationStatuses: {}, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; } const [fetchMessagesResult, fetchEntriesResult] = await Promise.all([ fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, ]); const rawEntryInfos = fetchEntriesResult && fetchEntriesResult.rawEntryInfos; const response: ThreadJoinResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; if (hasCodeVersionBelow62) { response.threadInfos = membershipResult.threadInfos; } if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } async function updateThreadMembers(viewer: Viewer) { const { threadInfos } = await fetchThreadInfos( viewer, SQL`t.parent_thread_id IS NOT NULL `, ); const updateDatas = []; const time = Date.now(); for (const threadID in threadInfos) { updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: viewer.id, time, threadID: threadID, targetSession: viewer.session, }); } await createUpdates(updateDatas); } export { updateRole, removeMembers, leaveThread, updateThread, joinThread, updateThreadMembers, }; diff --git a/keyserver/src/updaters/upload-updaters.js b/keyserver/src/updaters/upload-updaters.js index 47425e6c0..50ea9b487 100644 --- a/keyserver/src/updaters/upload-updaters.js +++ b/keyserver/src/updaters/upload-updaters.js @@ -1,38 +1,37 @@ // @flow import type { MediaMessageServerDBContent } from 'lib/types/messages/media.js'; import { getUploadIDsFromMediaMessageServerDBContents } from 'lib/types/messages/media.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; async function assignMedia( viewer: Viewer, mediaIDs: $ReadOnlyArray, containerID: string, ): Promise { const query = SQL` UPDATE uploads SET container = ${containerID} WHERE id IN (${mediaIDs}) AND uploader = ${viewer.id} AND container IS NULL `; await dbQuery(query); } async function assignMessageContainerToMedia( viewer: Viewer, mediaMessageContents: $ReadOnlyArray, containerID: string, ): Promise { - const uploadIDs = getUploadIDsFromMediaMessageServerDBContents( - mediaMessageContents, - ); + const uploadIDs = + getUploadIDsFromMediaMessageServerDBContents(mediaMessageContents); const query = SQL` UPDATE uploads SET container = ${containerID} WHERE id IN (${uploadIDs}) AND uploader = ${viewer.id} AND container IS NULL `; await dbQuery(query); } export { assignMedia, assignMessageContainerToMedia }; diff --git a/keyserver/src/uploads/media-utils.js b/keyserver/src/uploads/media-utils.js index 83e9209fe..fa1bcc840 100644 --- a/keyserver/src/uploads/media-utils.js +++ b/keyserver/src/uploads/media-utils.js @@ -1,174 +1,172 @@ // @flow import bmp from '@vingle/bmp-js'; import invariant from 'invariant'; import sharp from 'sharp'; import { serverTranscodableTypes, serverCanHandleTypes, readableFilename, } from 'lib/media/file-utils.js'; import { getImageProcessingPlan } from 'lib/media/image-utils.js'; import type { Dimensions } from 'lib/types/media-types.js'; import { deepFileInfoFromData } from 'web/media/file-utils.js'; import type { UploadInput } from '../creators/upload-creator.js'; function initializeSharp(buffer: Buffer, mime: string) { if (mime !== 'image/bmp') { return sharp(buffer); } const bitmap = bmp.decode(buffer, true); return sharp(bitmap.data, { raw: { width: bitmap.width, height: bitmap.height, channels: 4, }, }); } async function validateAndConvert( initialBuffer: Buffer, initialName: string, inputDimensions: ?Dimensions, inputLoop: boolean, size: number, // in bytes ): Promise { const { mime, mediaType } = deepFileInfoFromData(initialBuffer); if (!mime || !mediaType) { return null; } if (!serverCanHandleTypes.has(mime)) { return null; } if (mediaType === 'video') { invariant( inputDimensions, 'inputDimensions should be set in validateAndConvert', ); return { mime: mime, mediaType: mediaType, name: initialName, buffer: initialBuffer, dimensions: inputDimensions, loop: inputLoop, }; } if (!serverTranscodableTypes.has(mime)) { // This should've gotten converted on the client return null; } return convertImage( initialBuffer, mime, initialName, inputDimensions, inputLoop, size, ); } async function convertImage( initialBuffer: Buffer, mime: string, initialName: string, inputDimensions: ?Dimensions, inputLoop: boolean, size: number, ): Promise { let sharpImage, metadata; try { sharpImage = initializeSharp(initialBuffer, mime); metadata = await sharpImage.metadata(); } catch (e) { return null; } let initialDimensions = inputDimensions; if (!initialDimensions) { if (metadata.orientation && metadata.orientation > 4) { initialDimensions = { width: metadata.height, height: metadata.width }; } else { initialDimensions = { width: metadata.width, height: metadata.height }; } } const plan = getImageProcessingPlan({ inputMIME: mime, inputDimensions: initialDimensions, inputFileSize: size, inputOrientation: metadata.orientation, }); if (plan.action === 'none') { const name = readableFilename(initialName, mime); invariant(name, `should be able to construct filename for ${mime}`); return { mime, mediaType: 'photo', name, buffer: initialBuffer, dimensions: initialDimensions, loop: inputLoop, }; } console.log(`processing image with ${JSON.stringify(plan)}`); const { targetMIME, compressionRatio, fitInside, shouldRotate } = plan; if (shouldRotate) { sharpImage = sharpImage.rotate(); } if (fitInside) { sharpImage = sharpImage.resize(fitInside.width, fitInside.height, { fit: 'inside', withoutEnlargement: true, }); } if (targetMIME === 'image/png') { sharpImage = sharpImage.png(); } else { sharpImage = sharpImage.jpeg({ quality: compressionRatio * 100 }); } const { data: convertedBuffer, info } = await sharpImage.toBuffer({ resolveWithObject: true, }); const convertedDimensions = { width: info.width, height: info.height }; - const { - mime: convertedMIME, - mediaType: convertedMediaType, - } = deepFileInfoFromData(convertedBuffer); + const { mime: convertedMIME, mediaType: convertedMediaType } = + deepFileInfoFromData(convertedBuffer); if ( !convertedMIME || !convertedMediaType || convertedMIME !== targetMIME || convertedMediaType !== 'photo' ) { return null; } const convertedName = readableFilename(initialName, targetMIME); if (!convertedName) { return null; } return { mime: targetMIME, mediaType: 'photo', name: convertedName, buffer: convertedBuffer, dimensions: convertedDimensions, loop: inputLoop, }; } export { validateAndConvert }; diff --git a/landing/investor-data.js b/landing/investor-data.js index 0bd15d477..a62d1c234 100644 --- a/landing/investor-data.js +++ b/landing/investor-data.js @@ -1,790 +1,788 @@ // @flow import _keyBy from 'lodash/fp/keyBy.js'; import _shuffle from 'lodash/fp/shuffle.js'; import { assetsCacheURLPrefix } from './asset-meta-data.js'; type Investors = { +id: string, +name: string, +description: string, +involvement?: string, +imageURL: string, +website?: string, +twitter?: string, +linkedin?: string, }; const investorsData: $ReadOnlyArray = [ { id: 'ashoat_tevosyan', name: 'Ashoat Tevosyan', description: 'Founder of Comm. Learned to code modding PHP forums in the mid-2000s. Joined Facebook full-time at age 20 with last role as EM.', involvement: 'Initially Invested in May 2020', imageURL: `${assetsCacheURLPrefix}/ashoat.png`, website: 'https://site.ashoat.com', twitter: 'ashoat', linkedin: 'in/ashoatt', }, { id: 'slow_ventures', name: 'Slow Ventures', description: 'Slow Ventures is a generalist fund that backs founders from the earliest days. Slow has been heavily investing in and around the crypto space for the last 5+ years.', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/slow_ventures.jpeg`, website: 'https://slow.co', twitter: 'slow', linkedin: 'company/slow-ventures', }, { id: 'electric_capital', name: 'Electric Capital', description: 'An early stage venture firm focused on cryptocurrencies, blockchain, fintech, and marketplaces.', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/electric_capital.jpeg`, twitter: 'ElectricCapital', linkedin: 'company/electric-capital', }, { id: 'graph_ventures', name: 'Graph Ventures', description: 'Early-stage VC firm established in 2011 by leading technology entrepreneurs and executives with 300+ global investments.', involvement: 'Initially Invested in Nov 2021', imageURL: `${assetsCacheURLPrefix}/investors/graph_ventures.jpeg`, website: 'https://www.graphventures.com', twitter: 'graphventures', linkedin: 'company/graph-ventures', }, { id: 'draft_vc', name: 'Draft VC', description: 'Seed stage venture fund focused on Web3, climate, proptech, and fintech.', involvement: 'Initially Invested in Dec 2021', imageURL: `${assetsCacheURLPrefix}/investors/draft_vc.jpeg`, website: 'https://draftvc.com', twitter: 'draftvc', linkedin: 'company/draft-ventures', }, { id: 'd1_ventures', name: 'D1 Ventures', description: 'D1 Ventures is a private investment firm backing early stage crypto native infrastructures and applications.', involvement: 'Initially Invested in Feb 2022', imageURL: `${assetsCacheURLPrefix}/investors/d1_ventures.jpeg`, website: 'https://www.d1.ventures', twitter: 'd1ventures', linkedin: 'company/d1-ventures', }, { id: 'eniac_ventures', name: 'Eniac Ventures', description: 'Leads pre-seed & seed rounds in bold founders who use code to create transformational companies.', involvement: 'Initially Invested in Feb 2022', imageURL: `${assetsCacheURLPrefix}/investors/eniac_ventures.jpeg`, website: 'https://eniac.vc', twitter: 'EniacVC', linkedin: 'company/eniacvc', }, { id: 'seed_club_ventures', name: 'Seed Club Ventures', description: 'Venture DAO that invests in early-stage projects building and enabling a community-owned internet. SCV believes community ownership is the superpower of Web3 and is guided by the principles of inclusivity and opportunity for all participants.', involvement: 'Initially Invested in Dec 2021', imageURL: `${assetsCacheURLPrefix}/investors/seed_club_ventures.jpeg`, twitter: 'seedclubvc', }, { id: 'metaweb_ventures', name: 'MetaWeb Ventures', description: 'Global crypto firm investing in the future of Web3.', involvement: 'Initially Invested in Feb 2022', imageURL: `${assetsCacheURLPrefix}/investors/metaweb_ventures.jpeg`, website: 'https://www.metaweb.vc', twitter: 'MetaWebVC', }, { id: 'coinfund', name: 'CoinFund', description: 'CoinFund is a web3 and crypto focused investment firm and registered investment adviser founded in 2015 with the goal of shaping the global transition to web3. The firm invests in seed, venture and liquid opportunities within the blockchain sector with a focus on digital assets, decentralization technologies, and key enabling infrastructure.', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/coinfund.jpeg`, website: 'https://www.coinfund.io', twitter: 'coinfund_io', linkedin: 'company/coinfund', }, { id: 'shima_capital', name: 'Shima Capital', description: 'An early-stage global venture firm focused on supporting cutting edge blockchain startups.', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/shima_capital.jpeg`, website: 'https://shima.capital', twitter: 'shimacapital', linkedin: 'company/shima-capital', }, { id: 'republic_capital', name: 'Republic Capital', description: 'Republic Capital is a multi-stage venture capital firm focused on accelerating disruptive innovations.', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/republic_capital.jpeg`, website: 'https://www.republiccapital.co', twitter: '_rcapital_', linkedin: 'company/republic-capital', }, { id: 'global_coin_research', name: 'Global Coin Research', description: 'Investment & Research DAO focused on Web3', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/global_coin_research.jpeg`, website: 'https://globalcoinresearch.com', twitter: 'Globalcoinrsrch', }, { id: '3se_holdings', name: '3SE Holdings', description: 'Crypto-Native Operators Fund', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/3se_holdings.jpeg`, website: 'https://3seholdings.com', twitter: '3SEHoldings', }, { id: 'vibe_capital', name: 'Vibe Capital', description: 'Vibecap is a $10m Pre-Seed and Seed stage fund that invests in Deep Sci, AI, and Web3.', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/vibe_capital.jpeg`, website: 'https://vibecap.co', twitter: 'vibe_cap', }, { id: 'longhash_ventures', name: 'LongHash Ventures', description: "Asia's leading Web3 investment fund and accelerator collaborating with founders to build their Web3 model and tap into the vast potential of Asia.", involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/longhash_ventures.jpeg`, website: 'https://longhash.vc', twitter: 'LongHashVC', }, { id: 'micheal_stoppelman', name: 'Michael Stoppelman', description: 'Investor in Flexport, Vanta, and Benchling, Former SVP of Engineering at Yelp & ex-Google Software Engineer', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/micheal_stoppelman.jpeg`, twitter: 'stopman', linkedin: 'in/michaelstoppelman', }, { id: 'hursh_agrawal', name: 'Hursh Agrawal', description: 'Cofounder of The Browser Company', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/hursh_agrawal.jpeg`, twitter: 'hursh', linkedin: 'in/hurshagrawal', }, { id: 'adam_midvidy', name: 'Adam Midvidy', description: 'Developer at Jane Street', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/adam_midvidy.jpeg`, twitter: 'amidvidy', }, { id: 'dan_shipper', name: 'Dan Shipper', description: 'CEO of Every', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/dan_shipper.jpeg`, twitter: 'danshipper', linkedin: 'in/danshipper', }, { id: 'mary_pimenova', name: 'Mary Pimenova', description: 'Senior Engineering Manager at Robinhood', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/mary_pimenova.jpeg`, linkedin: 'in/mpimenova', }, { id: 'ranjan_pradeep', name: 'Ranjan Pradeep', description: 'Software Engineer at Microsoft', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/ranjan_pradeep.jpeg`, }, { id: 'tyler_menezes', name: 'Tyler Menezes', description: 'Helping kids find a place in tech at CodeDay. Forbes 30 Under 30, 425 Mag 30 Under 30. Tech & Learning Influential in EdTech.', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/tyler_menezes.jpeg`, twitter: 'tylermenezes', linkedin: 'in/tylermenezes', }, { id: 'alex_esibov', name: 'Alex Esibov', description: 'Principal Lead Product Manager at Microsoft', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/alex_esibov.jpeg`, twitter: 'AlexEsibov', linkedin: 'in/alexesibov', }, { id: 'inna_turshudzhyan', name: 'Inna Turshudzhyan', description: 'Senior Software Engineer at Microsoft', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/inna_turshudzhyan.jpeg`, }, { id: 'nick_mauro', name: 'Nick Mauro', description: 'Comm is building a messaging service that puts encryption and decentralization first. Thrilled to be among the group of early investors.', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/nick_mauro.jpeg`, twitter: '0x7BA086', }, { id: 'josh_kornreich', name: 'Josh Kornreich', description: 'Managing Partner at Unit Engineering Group and CTO at Ignite Tournaments', involvement: 'Initially Invested in May 2020', imageURL: `${assetsCacheURLPrefix}/investors/josh_kornreich.jpeg`, linkedin: 'in/joshuakornreich', }, { id: 'lucas_lowman', name: 'Lucas Lowman', description: 'Creative Director at Weirdbreak', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/lucas_lowman.jpeg`, linkedin: 'in/lucaslowman', }, { id: 'jonathan_shi', name: 'Jonathan Shi', description: 'Postdoctoral Researcher', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/jonathan_shi.jpeg`, website: 'https://www.jshi.science', twitter: 'jtnshi', }, { id: 'larry_fenn', name: 'Larry Fenn', description: 'Data journalist at Associated Press', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/larry_fenn.jpeg`, website: 'https://larryfenn.com', }, { id: 'dave_lowman', name: 'Dave Lowman', description: 'Senior Financial Services Executive, Board Member and Entrepreneur', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/dave_lowman.jpeg`, linkedin: 'in/dave-lowman-a90bb81', }, { id: 'jason_yeh', name: 'Jason Yeh', description: 'Founder at Adamant. Previously at Greycroft VC.', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/jason_yeh.jpeg`, twitter: 'jayyeh', }, { id: 'blake_embrey', name: 'Blake Embrey', description: 'Senior Software Engineer at Opendoor.com', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/blake_embrey.jpeg`, website: 'http://blakeembrey.me', twitter: 'blakeembrey', linkedin: 'in/blakeembrey', }, { id: 'ted_kalaw', name: 'Ted Kalaw', description: 'Software Engineer at Yoz Labs. Previously Senior Software Engineer at UnitedMasters and Facebook.', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/ted_kalaw.jpeg`, linkedin: 'in/ted-kalaw-791a0541', }, { id: 'jack_arenas', name: 'Jack Arenas', description: 'Co-founder CTO at Modern Life and Co-founder at Petal. Previously Goldman Sachs and Amazon.', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/jack_arenas.jpeg`, twitter: 'jackarenas', linkedin: 'in/jackarenas', }, { id: 'dave_schatz', name: 'Dave Schatz', description: 'Entrepreneur, engineer, blockchain dev, angel investor. Previously at Circles For Zoom and Facebook.', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/dave_schatz.jpeg`, twitter: 'daveschatz', linkedin: 'in/daveschatz', }, { id: 'michelle_nacouzi', name: 'Michelle Nacouzi', description: 'Early-stage VC at Northzone', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/michelle_nacouzi.jpeg`, twitter: 'MichelleNacouzi', }, { id: 'rousseau_kazi', name: 'Rousseau Kazi', description: 'CEO at Threads', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/rousseau_kazi.jpeg`, twitter: 'rousseaukazi', linkedin: 'in/rousseaukazi', }, { id: 'liu_jiang', name: 'Liu Jiang', description: 'Investor and Advisor', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/liu_jiang.jpeg`, linkedin: 'in/liujiang1', }, { id: 'jan_karl_driscoll', name: 'Jan-Karl Driscoll', description: 'Software Engineer and Architect. Previously at BounceX.', involvement: 'Initially Invested in Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/jan_karl_driscoll.jpeg`, linkedin: 'in/jan-karl-driscoll-91254a3', }, { id: 'tess_rinearson', name: 'Tess Rinearson', description: 'Leads Blockchain at Twitter. Previously the VP of Engineering at the Interchain Foundation.', involvement: 'Initially Invested in Mar 2021', imageURL: `${assetsCacheURLPrefix}/investors/tess_rinearson.jpeg`, twitter: '_tessr', linkedin: 'in/temiri', }, { id: 'ashwin_bhat', name: 'Ashwin Bhat', description: 'Engineering Manager at Loom', involvement: 'Initially Invested in Mar 2021', imageURL: `${assetsCacheURLPrefix}/investors/ashwin_bhat.jpeg`, twitter: 'swac', linkedin: 'in/ashwin-bhat-23573222', }, { id: 'lev_dubinets', name: 'Lev Dubinets', description: 'Enerineering Manager at Mercury', imageURL: `${assetsCacheURLPrefix}/investors/lev_dubinets.jpeg`, twitter: 'LevDubinets', }, { id: 'charlie_songhurst', name: 'Charlie Songhurst', description: 'Private investor in tech companies. Previously at Microsoft and McKinsey.', involvement: 'Initially Invested in Mar 2021', imageURL: `${assetsCacheURLPrefix}/investors/charlie_songhurst.jpeg`, linkedin: 'in/charlessonghurst', }, { id: 'edward_lando', name: 'Edward Lando', description: 'Managing Partner at Pareto Holdings', involvement: 'Initially Invested in Mar 2021', imageURL: `${assetsCacheURLPrefix}/investors/edward_lando.jpeg`, twitter: 'edwardlando', linkedin: 'in/edwardlando', }, { id: 'alina_libova_cohen', name: 'Alina Libova Cohen', description: 'Angel Investor', involvement: 'Initially Invested in Mar 2021', imageURL: `${assetsCacheURLPrefix}/investors/alina_libova_cohen.jpeg`, twitter: 'alina_libova', }, { id: 'kahren_tevosyan', name: 'Kahren Tevosyan', description: 'VP of Engineering at Microsoft', involvement: 'Initially Invested in Mar 2021', imageURL: `${assetsCacheURLPrefix}/investors/kahren_tevosyan.jpeg`, }, { id: 'anna_barhudarian', name: 'Anna Barhudarian', description: "Ashoat's mother and Principal PM Manager at Microsoft", involvement: 'Initially Invested in Mar 2021', imageURL: `${assetsCacheURLPrefix}/investors/anna_barhudarian.jpeg`, }, { id: 'jim_posen', name: 'Jim Posen', description: 'Cryptography engineer. Previously technical lead at Coinbase.', involvement: 'Initially Invested in Nov 2021', imageURL: `${assetsCacheURLPrefix}/investors/jim_posen.jpeg`, twitter: 'jimpo_potamus', linkedin: 'in/jimpo', }, { id: 'chet_corcos', name: 'Chet Corcos', description: 'Previously engineer at Notion, Affirm, and SpaceX', involvement: 'Initially Invested in Nov 2021', imageURL: `${assetsCacheURLPrefix}/investors/chet_corcos.jpeg`, website: 'http://chetcorcos.com', twitter: 'ccorcos', }, { id: 'eric_siu', name: 'Eric Siu', description: 'Founder at Single Grain. Investor and creator at Leveling up Heroes.', involvement: 'Initially Invested in Nov 2021', imageURL: `${assetsCacheURLPrefix}/investors/eric_siu.jpeg`, twitter: 'ericosiu', linkedin: 'in/ericosiu', }, { id: 'gmoney', name: 'gmoney', description: 'Founder at Admit One and 9dcc', involvement: 'Initially Invested in Nov 2021', imageURL: `${assetsCacheURLPrefix}/investors/gmoney.jpeg`, twitter: 'gmoneyNFT', }, { id: 'dylan_portelance', name: 'Dylan Portelance', description: 'Product Growth at Photomath', involvement: 'Initially Invested in Nov 2021', imageURL: `${assetsCacheURLPrefix}/investors/dylan_portelance.jpeg`, twitter: 'dylanjpo', linkedin: 'in/dylanportelance', }, { id: 'lisa_xu', name: 'Lisa Xu', description: 'VC at FirstMark investing in early stage consumer and web3 startups. Co-host of Crypto Driven.', involvement: 'Initially Invested in Nov 2021', imageURL: `${assetsCacheURLPrefix}/investors/lisa_xu.jpeg`, twitter: 'lisamxu', }, { id: 'mark_mullen', name: 'Mark Mullen', description: 'Managing Partner at Double M and Co-Founder at Bonfire Ventures', involvement: 'Initially Invested in Nov 2021', imageURL: `${assetsCacheURLPrefix}/investors/mark_mullen.jpeg`, website: 'https://www.bonfirevc.com/team/mark-mullen', }, { id: 'reuben_bramanathan', name: 'Reuben Bramanathan', description: 'General Partner at IDEO CoLab Ventures. Previously Head of Asset Management and Product Counsel at Coinbase.', involvement: 'Initially Invested in Nov 2021', imageURL: `${assetsCacheURLPrefix}/investors/reuben_bramanathan.jpeg`, twitter: 'bramanathan', linkedin: 'in/rbramanathan', }, { id: 'balaji_srinivasan', name: 'Balaji Srinivasan', description: 'Author of The Network State. Formerly the CTO of Coinbase and General Partner at Andreessen Horowitz.', involvement: 'Initially Invested in Nov 2021', imageURL: `${assetsCacheURLPrefix}/investors/balaji_srinivasan.jpeg`, twitter: 'balajis', }, { id: 'david_rodriguez', name: 'David Rodriguez', description: 'Co-Founder and Managing Partner at Draft Ventures', involvement: 'Initially Invested in Dec 2021', imageURL: `${assetsCacheURLPrefix}/investors/david_rodriguez.jpeg`, twitter: 'davidjrodriguez', linkedin: 'in/davidjrodriguez', }, { id: 'artia_moghbel', name: 'Artia Moghbel', description: 'Co-founder at Draft Ventures. Previously the COO at DFINITY.', involvement: 'Initially Invested in Dec 2021', imageURL: `${assetsCacheURLPrefix}/investors/artia_moghbel.jpeg`, twitter: 'artia', linkedin: 'in/artiam', }, { id: 'grant_gittlin', name: 'Grant Gittlin', description: 'Investor & Former CGO at MediaLink', involvement: 'Initially Invested in Jan 2022', imageURL: `${assetsCacheURLPrefix}/investors/grant_gittlin.jpeg`, linkedin: 'in/grantgittlin', }, { id: 'julian_weisser', name: 'Julian Weisser', description: 'Co-Founder of On Deck. Ex-core at Constitution DAO and GP at Other Ventures.', involvement: 'Initially Invested in Jan 2022', imageURL: `${assetsCacheURLPrefix}/investors/julian_weisser.jpeg`, twitter: 'julianweisser', linkedin: 'in/julianweisser', }, { id: 'ethan_beard', name: 'Ethan Beard', description: 'Co-Founder at Yoz Labs. Previously Senior VP at Ripple.', involvement: 'Initially Invested in Jan 2022', imageURL: `${assetsCacheURLPrefix}/investors/ethan_beard.jpeg`, twitter: 'ethanbeard', linkedin: 'in/ethanbeard', }, { id: 'tim_chen', name: 'Tim Chen', description: 'General Partner at Essence VC. Co-host of the Open Source Startup Podcast.', involvement: 'Initially Invested in Jan 2022', imageURL: `${assetsCacheURLPrefix}/investors/tim_chen.jpeg`, twitter: 'tnachen', linkedin: 'in/timchen', }, { id: 'jennifer_liu', name: 'Jennifer Liu', description: 'Founding Partner at D1 Ventures.', involvement: 'Initially Invested in Jan 2022', imageURL: `${assetsCacheURLPrefix}/investors/jennifer_liu.jpeg`, }, { id: 'tamara_frankel', name: 'Tamara Frankel', description: 'Founding Partner at D1 Ventures. Previously Founding Partner at Azoth Group.', involvement: 'Initially Invested in Feb 2022', imageURL: `${assetsCacheURLPrefix}/investors/tamara_frankel.jpeg`, linkedin: 'in/tamara-based-jpegs', }, { id: 'ahmed_jafri', name: 'Ahmed Jafri', description: 'Engineering Manager at Meta', involvement: 'Initially Invested in Jan 2022', imageURL: `${assetsCacheURLPrefix}/investors/ahmed_jafri.jpeg`, twitter: 'ahmedjafrii', linkedin: 'in/ahmedjafrii', }, { id: 'aksel_piran', name: 'Aksel Piran', description: 'Founder at CP3 Ventures and The Syndicate by BANA', involvement: 'Initially Invested in Jan 2022', imageURL: `${assetsCacheURLPrefix}/investors/aksel_piran.jpeg`, linkedin: 'in/apiran', }, { id: 'paul_veradittakit', name: 'Paul Veradittakit', description: 'Investor at Pantera Capital', involvement: 'Initially Invested in Feb 2022', imageURL: `${assetsCacheURLPrefix}/investors/paul_veradittakit.jpeg`, twitter: 'veradittakit', linkedin: 'in/veradittakit', }, { id: 'ammar_karmali', name: 'Ammar Karmali', description: 'Investment Banking Associate at Gordon Dyal & Co. Advisory Group LP', involvement: 'Initially Invested in Feb 2022', imageURL: `${assetsCacheURLPrefix}/investors/ammar_karmali.jpeg`, linkedin: 'in/ammar-karmali-658b10b4', }, { id: 'avi_zurlo', name: 'Avi Zurlo', description: 'Ventures Associate at Delphi Digital', involvement: 'Initially Invested in Feb 2022', imageURL: `${assetsCacheURLPrefix}/investors/avi_zurlo.jpeg`, twitter: 'thejewforu', linkedin: 'in/avi-zurlo-2b0760104', }, { id: 'tom_shaughnessy', name: 'Tom Shaughnessy', description: 'Co-Founder at Delphi Digital. Host of The Delphi Podcast.', involvement: 'Initially Invested in Feb 2022', imageURL: `${assetsCacheURLPrefix}/investors/tom_shaughnessy.jpeg`, twitter: 'Shaughnessy119', linkedin: 'in/tom-shaughnessy-jr-2572a220', }, { id: 'yan_liberman', name: 'Yan Liberman', description: 'Co-Founder at Delphi Digital', involvement: 'Initially Invested in Feb 2022', imageURL: `${assetsCacheURLPrefix}/investors/yan_liberman.jpeg`, twitter: 'YanLiberman', linkedin: 'in/yanliberman', }, { id: 'faizan_khan', name: 'Faizan Khan', description: 'Founder, Managing Director at Visary Capital', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/faizan_khan.jpeg`, linkedin: 'in/faizanjkhan', }, { id: 'lane_rettig', name: 'Lane Rettig', description: 'Core team at teamspacemesh. Previously Ethereum Core Dev.', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/lane_rettig.jpeg`, twitter: 'lrettig', linkedin: 'in/lane-rettig-32904b227', }, { id: 'lon_lundgren', name: 'Lon Lundgren', description: 'Founder at Galactical and Ocelot. Previously at AWS and Microsoft.', imageURL: `${assetsCacheURLPrefix}/investors/lon_lundgren.jpeg`, linkedin: 'in/lonlundgren', }, { id: 'will_papper', name: 'Will Papper', description: 'Co-Founder at Syndicate DAO. Core at Constitution DAO.', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/will_papper.jpeg`, twitter: 'WillPapper', }, { id: 'sida_li', name: 'Sida Li', description: 'Strategy at SyndicateDAO. Previously built ventures at IDEO and Atomic.', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/sida_li.jpeg`, twitter: 'Sidaelle', linkedin: 'in/sida-li-35729698', }, { id: 'reverie', name: 'Reverie', description: 'Reverie helps DAOs grow', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/reverie.jpeg`, website: 'https://www.reverie.ooo', twitter: 'hi_reverie', linkedin: 'company/reveriereserves', }, { id: 'patricio_worthalter', name: 'Patricio Worthalter', description: 'Founder at POAP', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/patricio_worthalter.jpeg`, twitter: 'in/worthalter', }, { id: 'andrew_green', name: 'Andrew Green', description: 'Co-Founder & CEO of Strider. Previously, Partner at Andreessen Horowitz.', involvement: 'Initially Invested in July 2022', imageURL: `${assetsCacheURLPrefix}/investors/andrew_green.jpeg`, linkedin: 'in/andrewngreen10', }, { id: 'taylor_rogalski', name: 'Taylor Rogalski', description: 'Former Product Designer at Facebook, Pioneerp.app, and ClassDojo', involvement: 'Advisor since Feb 2021', imageURL: `${assetsCacheURLPrefix}/investors/taylor_rogalski.jpeg`, twitter: 'tayroga', linkedin: 'in/taylor-rogalski-4b169767', }, { id: 'julia_lipton', name: 'Julia Lipton', description: 'Investing in Web3 at Awesome People Ventures', imageURL: `${assetsCacheURLPrefix}/investors/julia_lipton.jpeg`, involvement: 'Advisor since Oct 2021', twitter: 'JuliaLipton', linkedin: 'in/julialipton', }, { id: 'varun_dhananjaya', name: 'Varun Dhananjaya', description: 'Software Engineer at Comm', involvement: 'Joined July 2022', imageURL: `${assetsCacheURLPrefix}/investors/varun_dhananjaya.jpeg`, }, { id: 'mark_rafferty', name: 'Mark Rafferty', description: 'Recruiter at Comm', involvement: 'Joined Feb 2022', imageURL: `${assetsCacheURLPrefix}/investors/mark_rafferty.jpeg`, twitter: 'markraff', }, ]; -const shuffledInvestorsData: $ReadOnlyArray = _shuffle( - investorsData, -); +const shuffledInvestorsData: $ReadOnlyArray = + _shuffle(investorsData); -const keyedInvestorData: { [key: string]: Investors } = _keyBy('id')( - investorsData, -); +const keyedInvestorData: { [key: string]: Investors } = + _keyBy('id')(investorsData); export { shuffledInvestorsData, keyedInvestorData }; diff --git a/landing/siwe.react.js b/landing/siwe.react.js index da16590ae..c96774835 100644 --- a/landing/siwe.react.js +++ b/landing/siwe.react.js @@ -1,199 +1,198 @@ // @flow import { useConnectModal, RainbowKitProvider, darkTheme, useModalState, ConnectButton, connectorsForWallets, } from '@rainbow-me/rainbowkit'; import '@rainbow-me/rainbowkit/styles.css'; import { injectedWallet, rainbowWallet, metaMaskWallet, walletConnectWallet, // eslint-disable-next-line import/extensions } from '@rainbow-me/rainbowkit/wallets'; import invariant from 'invariant'; import _merge from 'lodash/fp/merge.js'; import * as React from 'react'; import { useAccount, useSigner, WagmiConfig } from 'wagmi'; import type { SIWEWebViewMessage } from 'lib/types/siwe-types.js'; import { getSIWEStatementForPublicKey, siweStatementWithoutPublicKey, siweMessageSigningExplanationStatements, createSIWEMessage, } from 'lib/utils/siwe-utils.js'; import { configureWagmiChains, createWagmiClient, } from 'lib/utils/wagmi-utils.js'; import { SIWEContext } from './siwe-context.js'; import css from './siwe.css'; const { chains, provider } = configureWagmiChains(process.env.COMM_ALCHEMY_KEY); const connectors = connectorsForWallets([ { groupName: 'Recommended', wallets: [ injectedWallet({ chains }), rainbowWallet({ chains }), metaMaskWallet({ chains }), walletConnectWallet({ chains }), ], }, ]); const wagmiClient = createWagmiClient({ connectors, provider }); function postMessageToNativeWebView(message: SIWEWebViewMessage) { window.ReactNativeWebView?.postMessage?.(JSON.stringify(message)); } async function signInWithEthereum( address: string, signer, nonce: string, statement: string, ) { invariant(nonce, 'nonce must be present in signInWithEthereum'); const message = createSIWEMessage(address, statement, nonce); const signature = await signer.signMessage(message); postMessageToNativeWebView({ type: 'siwe_success', address, message, signature, }); } function SIWE(): React.Node { const { address } = useAccount(); const { data: signer } = useSigner(); - const { siweNonce, siwePrimaryIdentityPublicKey } = React.useContext( - SIWEContext, - ); + const { siweNonce, siwePrimaryIdentityPublicKey } = + React.useContext(SIWEContext); const onClick = React.useCallback(() => { invariant(siweNonce, 'nonce must be present during SIWE attempt'); const statement = siwePrimaryIdentityPublicKey ? getSIWEStatementForPublicKey(siwePrimaryIdentityPublicKey) : siweStatementWithoutPublicKey; signInWithEthereum(address, signer, siweNonce, statement); }, [address, signer, siweNonce, siwePrimaryIdentityPublicKey]); const { openConnectModal } = useConnectModal(); const hasNonce = siweNonce !== null && siweNonce !== undefined; React.useEffect(() => { if (hasNonce && openConnectModal) { openConnectModal(); } }, [hasNonce, openConnectModal]); const prevConnectModalOpen = React.useRef(false); const modalState = useModalState(); const closeTimeoutRef = React.useRef(); const { connectModalOpen } = modalState; React.useEffect(() => { if (!connectModalOpen && prevConnectModalOpen.current && !signer) { closeTimeoutRef.current = setTimeout( () => postMessageToNativeWebView({ type: 'siwe_closed' }), 50, ); } else if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = undefined; } prevConnectModalOpen.current = connectModalOpen; }, [connectModalOpen, signer]); const newModalAppeared = React.useCallback(mutationList => { for (const mutation of mutationList) { for (const addedNode of mutation.addedNodes) { if ( addedNode instanceof HTMLElement && addedNode.id === 'walletconnect-wrapper' ) { postMessageToNativeWebView({ type: 'walletconnect_modal_update', state: 'open', }); } } for (const addedNode of mutation.removedNodes) { if ( addedNode instanceof HTMLElement && addedNode.id === 'walletconnect-wrapper' ) { postMessageToNativeWebView({ type: 'walletconnect_modal_update', state: 'closed', }); } } } }, []); React.useEffect(() => { const observer = new MutationObserver(newModalAppeared); invariant(document.body, 'document.body should be set'); observer.observe(document.body, { childList: true }); return () => { observer.disconnect(); }; }, [newModalAppeared]); if (!hasNonce) { return (

Unable to proceed: nonce not found.

); } else if (!signer) { return null; } else { return (
Wallet Connected:

{siweMessageSigningExplanationStatements}

By signing up, you agree to our{' '} Terms of Use &{' '} Privacy Policy.

Sign in using this wallet
); } } function SIWEWrapper(): React.Node { const theme = React.useMemo(() => { return _merge(darkTheme())({ radii: { modal: 0, modalMobile: 0, }, colors: { modalBackdrop: '#242529', }, }); }, []); return ( ); } export default SIWEWrapper; diff --git a/landing/subscription-form.react.js b/landing/subscription-form.react.js index dd9494dcf..887693a30 100644 --- a/landing/subscription-form.react.js +++ b/landing/subscription-form.react.js @@ -1,103 +1,101 @@ // @flow import * as React from 'react'; import { validEmailRegex } from 'lib/shared/account-utils.js'; import css from './subscription-form.css'; type SubscriptionFormStatus = | { +status: 'pending' } | { +status: 'in_progress' } | { +status: 'success' } | { +status: 'error', +error: string }; function SubscriptionForm(): React.Node { const [email, setEmail] = React.useState(''); - const [ - subscriptionFormStatus, - setSubscriptionFormStatus, - ] = React.useState({ status: 'pending' }); + const [subscriptionFormStatus, setSubscriptionFormStatus] = + React.useState({ status: 'pending' }); const onEmailSubmitted = React.useCallback( async (e: Event) => { e.preventDefault(); if ( subscriptionFormStatus.status === 'in_progress' || subscriptionFormStatus.status === 'success' ) { return; } if (email.search(validEmailRegex) === -1) { setSubscriptionFormStatus({ status: 'error', error: 'Invalid email' }); return; } setSubscriptionFormStatus({ status: 'in_progress' }); try { const response = await fetch('subscribe_email', { method: 'POST', credentials: 'same-origin', body: JSON.stringify({ email }), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }); const respJson = await response.json(); if (!respJson.success) { setSubscriptionFormStatus({ status: 'error', error: 'Request failed', }); return; } setSubscriptionFormStatus({ status: 'success' }); document.activeElement?.blur(); } catch { setSubscriptionFormStatus({ status: 'error', error: 'Network failed' }); } }, [email, subscriptionFormStatus], ); React.useEffect(() => { setSubscriptionFormStatus({ status: 'pending' }); }, [email]); let btnText = 'Subscribe for updates'; let btnStyle = css.button; let inputStyle = css.email_input; if (subscriptionFormStatus.status === 'error') { btnText = subscriptionFormStatus.error; btnStyle = `${css.button} ${css.button_failure}`; inputStyle = `${css.email_input} ${css.email_input_failure}`; } else if (subscriptionFormStatus.status === 'success') { btnText = 'Subscribed!'; btnStyle = `${css.button} ${css.button_success}`; inputStyle = `${css.email_input} ${css.email_input_success}`; } const onEmailChange = React.useCallback(e => { setEmail(e.target.value); }, []); return (
); } export default SubscriptionForm; diff --git a/lib/actions/activity-actions.js b/lib/actions/activity-actions.js index a98383f81..606eb4215 100644 --- a/lib/actions/activity-actions.js +++ b/lib/actions/activity-actions.js @@ -1,58 +1,62 @@ // @flow import type { ActivityUpdate, ActivityUpdateSuccessPayload, SetThreadUnreadStatusPayload, SetThreadUnreadStatusRequest, SetThreadUnreadStatusResult, } from '../types/activity-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; const updateActivityActionTypes = Object.freeze({ started: 'UPDATE_ACTIVITY_STARTED', success: 'UPDATE_ACTIVITY_SUCCESS', failed: 'UPDATE_ACTIVITY_FAILED', }); -const updateActivity = ( - callServerEndpoint: CallServerEndpoint, -): (( - activityUpdates: $ReadOnlyArray, -) => Promise) => async activityUpdates => { - const response = await callServerEndpoint('update_activity', { - updates: activityUpdates, - }); - return { - activityUpdates, - result: { - unfocusedToUnread: response.unfocusedToUnread, - }, +const updateActivity = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + activityUpdates: $ReadOnlyArray, + ) => Promise) => + async activityUpdates => { + const response = await callServerEndpoint('update_activity', { + updates: activityUpdates, + }); + return { + activityUpdates, + result: { + unfocusedToUnread: response.unfocusedToUnread, + }, + }; }; -}; const setThreadUnreadStatusActionTypes = Object.freeze({ started: 'SET_THREAD_UNREAD_STATUS_STARTED', success: 'SET_THREAD_UNREAD_STATUS_SUCCESS', failed: 'SET_THREAD_UNREAD_STATUS_FAILED', }); -const setThreadUnreadStatus = ( - callServerEndpoint: CallServerEndpoint, -): (( - request: SetThreadUnreadStatusRequest, -) => Promise) => async request => { - const response: SetThreadUnreadStatusResult = await callServerEndpoint( - 'set_thread_unread_status', - request, - ); - return { - resetToUnread: response.resetToUnread, - threadID: request.threadID, +const setThreadUnreadStatus = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + request: SetThreadUnreadStatusRequest, + ) => Promise) => + async request => { + const response: SetThreadUnreadStatusResult = await callServerEndpoint( + 'set_thread_unread_status', + request, + ); + return { + resetToUnread: response.resetToUnread, + threadID: request.threadID, + }; }; -}; export { updateActivityActionTypes, updateActivity, setThreadUnreadStatusActionTypes, setThreadUnreadStatus, }; diff --git a/lib/actions/device-actions.js b/lib/actions/device-actions.js index 3ab254b07..51aec0b3a 100644 --- a/lib/actions/device-actions.js +++ b/lib/actions/device-actions.js @@ -1,21 +1,23 @@ // @flow import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; import { getConfig } from '../utils/config.js'; const setDeviceTokenActionTypes = Object.freeze({ started: 'SET_DEVICE_TOKEN_STARTED', success: 'SET_DEVICE_TOKEN_SUCCESS', failed: 'SET_DEVICE_TOKEN_FAILED', }); -const setDeviceToken = ( - callServerEndpoint: CallServerEndpoint, -): ((deviceToken: ?string) => Promise) => async deviceToken => { - await callServerEndpoint('update_device_token', { - deviceToken, - platformDetails: getConfig().platformDetails, - }); - return deviceToken; -}; +const setDeviceToken = + ( + callServerEndpoint: CallServerEndpoint, + ): ((deviceToken: ?string) => Promise) => + async deviceToken => { + await callServerEndpoint('update_device_token', { + deviceToken, + platformDetails: getConfig().platformDetails, + }); + return deviceToken; + }; export { setDeviceTokenActionTypes, setDeviceToken }; diff --git a/lib/actions/entry-actions.js b/lib/actions/entry-actions.js index 3d391913f..0613f86b6 100644 --- a/lib/actions/entry-actions.js +++ b/lib/actions/entry-actions.js @@ -1,195 +1,200 @@ // @flow import { localIDPrefix } from '../shared/message-utils.js'; import type { RawEntryInfo, CalendarQuery, SaveEntryInfo, SaveEntryResult, CreateEntryInfo, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResult, RestoreEntryInfo, RestoreEntryResult, FetchEntryInfosResult, CalendarQueryUpdateResult, } from '../types/entry-types.js'; import type { HistoryRevisionInfo } from '../types/history-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; import { dateFromString } from '../utils/date-utils.js'; const fetchEntriesActionTypes = Object.freeze({ started: 'FETCH_ENTRIES_STARTED', success: 'FETCH_ENTRIES_SUCCESS', failed: 'FETCH_ENTRIES_FAILED', }); -const fetchEntries = ( - callServerEndpoint: CallServerEndpoint, -): (( - calendarQuery: CalendarQuery, -) => Promise) => async calendarQuery => { - const response = await callServerEndpoint('fetch_entries', calendarQuery); - return { - rawEntryInfos: response.rawEntryInfos, +const fetchEntries = + ( + callServerEndpoint: CallServerEndpoint, + ): ((calendarQuery: CalendarQuery) => Promise) => + async calendarQuery => { + const response = await callServerEndpoint('fetch_entries', calendarQuery); + return { + rawEntryInfos: response.rawEntryInfos, + }; }; -}; const updateCalendarQueryActionTypes = Object.freeze({ started: 'UPDATE_CALENDAR_QUERY_STARTED', success: 'UPDATE_CALENDAR_QUERY_SUCCESS', failed: 'UPDATE_CALENDAR_QUERY_FAILED', }); -const updateCalendarQuery = ( - callServerEndpoint: CallServerEndpoint, -): (( - calendarQuery: CalendarQuery, - reduxAlreadyUpdated?: boolean, -) => Promise) => async ( - calendarQuery, - reduxAlreadyUpdated = false, -) => { - const response = await callServerEndpoint( - 'update_calendar_query', - calendarQuery, - ); - const { rawEntryInfos, deletedEntryIDs } = response; - return { - rawEntryInfos, - deletedEntryIDs, - calendarQuery, - calendarQueryAlreadyUpdated: reduxAlreadyUpdated, +const updateCalendarQuery = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + calendarQuery: CalendarQuery, + reduxAlreadyUpdated?: boolean, + ) => Promise) => + async (calendarQuery, reduxAlreadyUpdated = false) => { + const response = await callServerEndpoint( + 'update_calendar_query', + calendarQuery, + ); + const { rawEntryInfos, deletedEntryIDs } = response; + return { + rawEntryInfos, + deletedEntryIDs, + calendarQuery, + calendarQueryAlreadyUpdated: reduxAlreadyUpdated, + }; }; -}; const createLocalEntryActionType = 'CREATE_LOCAL_ENTRY'; function createLocalEntry( threadID: string, localID: number, dateString: string, creatorID: string, ): RawEntryInfo { const date = dateFromString(dateString); const newEntryInfo: RawEntryInfo = { localID: `${localIDPrefix}${localID}`, threadID, text: '', year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), creationTime: Date.now(), creatorID, deleted: false, }; return newEntryInfo; } const createEntryActionTypes = Object.freeze({ started: 'CREATE_ENTRY_STARTED', success: 'CREATE_ENTRY_SUCCESS', failed: 'CREATE_ENTRY_FAILED', }); -const createEntry = ( - callServerEndpoint: CallServerEndpoint, -): (( - request: CreateEntryInfo, -) => Promise) => async request => { - const result = await callServerEndpoint('create_entry', request); - return { - entryID: result.entryID, - newMessageInfos: result.newMessageInfos, - threadID: request.threadID, - localID: request.localID, - updatesResult: result.updatesResult, +const createEntry = + ( + callServerEndpoint: CallServerEndpoint, + ): ((request: CreateEntryInfo) => Promise) => + async request => { + const result = await callServerEndpoint('create_entry', request); + return { + entryID: result.entryID, + newMessageInfos: result.newMessageInfos, + threadID: request.threadID, + localID: request.localID, + updatesResult: result.updatesResult, + }; }; -}; const saveEntryActionTypes = Object.freeze({ started: 'SAVE_ENTRY_STARTED', success: 'SAVE_ENTRY_SUCCESS', failed: 'SAVE_ENTRY_FAILED', }); const concurrentModificationResetActionType = 'CONCURRENT_MODIFICATION_RESET'; -const saveEntry = ( - callServerEndpoint: CallServerEndpoint, -): ((request: SaveEntryInfo) => Promise) => async request => { - const result = await callServerEndpoint('update_entry', request); - return { - entryID: result.entryID, - newMessageInfos: result.newMessageInfos, - updatesResult: result.updatesResult, +const saveEntry = + ( + callServerEndpoint: CallServerEndpoint, + ): ((request: SaveEntryInfo) => Promise) => + async request => { + const result = await callServerEndpoint('update_entry', request); + return { + entryID: result.entryID, + newMessageInfos: result.newMessageInfos, + updatesResult: result.updatesResult, + }; }; -}; const deleteEntryActionTypes = Object.freeze({ started: 'DELETE_ENTRY_STARTED', success: 'DELETE_ENTRY_SUCCESS', failed: 'DELETE_ENTRY_FAILED', }); -const deleteEntry = ( - callServerEndpoint: CallServerEndpoint, -): ((info: DeleteEntryInfo) => Promise) => async info => { - const response = await callServerEndpoint('delete_entry', { - ...info, - timestamp: Date.now(), - }); - return { - newMessageInfos: response.newMessageInfos, - threadID: response.threadID, - updatesResult: response.updatesResult, +const deleteEntry = + ( + callServerEndpoint: CallServerEndpoint, + ): ((info: DeleteEntryInfo) => Promise) => + async info => { + const response = await callServerEndpoint('delete_entry', { + ...info, + timestamp: Date.now(), + }); + return { + newMessageInfos: response.newMessageInfos, + threadID: response.threadID, + updatesResult: response.updatesResult, + }; }; -}; const fetchRevisionsForEntryActionTypes = Object.freeze({ started: 'FETCH_REVISIONS_FOR_ENTRY_STARTED', success: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS', failed: 'FETCH_REVISIONS_FOR_ENTRY_FAILED', }); -const fetchRevisionsForEntry = ( - callServerEndpoint: CallServerEndpoint, -): (( - entryID: string, -) => Promise<$ReadOnlyArray>) => async entryID => { - const response = await callServerEndpoint('fetch_entry_revisions', { - id: entryID, - }); - return response.result; -}; +const fetchRevisionsForEntry = + ( + callServerEndpoint: CallServerEndpoint, + ): ((entryID: string) => Promise<$ReadOnlyArray>) => + async entryID => { + const response = await callServerEndpoint('fetch_entry_revisions', { + id: entryID, + }); + return response.result; + }; const restoreEntryActionTypes = Object.freeze({ started: 'RESTORE_ENTRY_STARTED', success: 'RESTORE_ENTRY_SUCCESS', failed: 'RESTORE_ENTRY_FAILED', }); -const restoreEntry = ( - callServerEndpoint: CallServerEndpoint, -): ((info: RestoreEntryInfo) => Promise) => async info => { - const response = await callServerEndpoint('restore_entry', { - ...info, - timestamp: Date.now(), - }); - return { - newMessageInfos: response.newMessageInfos, - updatesResult: response.updatesResult, +const restoreEntry = + ( + callServerEndpoint: CallServerEndpoint, + ): ((info: RestoreEntryInfo) => Promise) => + async info => { + const response = await callServerEndpoint('restore_entry', { + ...info, + timestamp: Date.now(), + }); + return { + newMessageInfos: response.newMessageInfos, + updatesResult: response.updatesResult, + }; }; -}; export { fetchEntriesActionTypes, fetchEntries, updateCalendarQueryActionTypes, updateCalendarQuery, createLocalEntryActionType, createLocalEntry, createEntryActionTypes, createEntry, saveEntryActionTypes, concurrentModificationResetActionType, saveEntry, deleteEntryActionTypes, deleteEntry, fetchRevisionsForEntryActionTypes, fetchRevisionsForEntry, restoreEntryActionTypes, restoreEntry, }; diff --git a/lib/actions/message-actions.js b/lib/actions/message-actions.js index d1fa8cabf..f589a6417 100644 --- a/lib/actions/message-actions.js +++ b/lib/actions/message-actions.js @@ -1,262 +1,263 @@ // @flow import invariant from 'invariant'; import type { FetchMessageInfosPayload, SendMessageResult, SendReactionMessageRequest, SimpleMessagesPayload, } from '../types/message-types.js'; import type { MediaMessageServerDBContent } from '../types/messages/media.js'; import type { CallServerEndpoint, CallServerEndpointResultInfo, } from '../utils/call-server-endpoint.js'; const fetchMessagesBeforeCursorActionTypes = Object.freeze({ started: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', success: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', failed: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', }); -const fetchMessagesBeforeCursor = ( - callServerEndpoint: CallServerEndpoint, -): (( - threadID: string, - beforeMessageID: string, -) => Promise) => async ( - threadID, - beforeMessageID, -) => { - const response = await callServerEndpoint('fetch_messages', { - cursors: { - [threadID]: beforeMessageID, - }, - }); - return { - threadID, - rawMessageInfos: response.rawMessageInfos, - truncationStatus: response.truncationStatuses[threadID], +const fetchMessagesBeforeCursor = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + threadID: string, + beforeMessageID: string, + ) => Promise) => + async (threadID, beforeMessageID) => { + const response = await callServerEndpoint('fetch_messages', { + cursors: { + [threadID]: beforeMessageID, + }, + }); + return { + threadID, + rawMessageInfos: response.rawMessageInfos, + truncationStatus: response.truncationStatuses[threadID], + }; }; -}; const fetchMostRecentMessagesActionTypes = Object.freeze({ started: 'FETCH_MOST_RECENT_MESSAGES_STARTED', success: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', failed: 'FETCH_MOST_RECENT_MESSAGES_FAILED', }); -const fetchMostRecentMessages = ( - callServerEndpoint: CallServerEndpoint, -): (( - threadID: string, -) => Promise) => async threadID => { - const response = await callServerEndpoint('fetch_messages', { - cursors: { - [threadID]: null, - }, - }); - return { - threadID, - rawMessageInfos: response.rawMessageInfos, - truncationStatus: response.truncationStatuses[threadID], +const fetchMostRecentMessages = + ( + callServerEndpoint: CallServerEndpoint, + ): ((threadID: string) => Promise) => + async threadID => { + const response = await callServerEndpoint('fetch_messages', { + cursors: { + [threadID]: null, + }, + }); + return { + threadID, + rawMessageInfos: response.rawMessageInfos, + truncationStatus: response.truncationStatuses[threadID], + }; }; -}; const fetchSingleMostRecentMessagesFromThreadsActionTypes = Object.freeze({ started: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_STARTED', success: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_SUCCESS', failed: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_FAILED', }); -const fetchSingleMostRecentMessagesFromThreads = ( - callServerEndpoint: CallServerEndpoint, -): (( - threadIDs: $ReadOnlyArray, -) => Promise) => async threadIDs => { - const cursors = Object.fromEntries( - threadIDs.map(threadID => [threadID, null]), - ); - const response = await callServerEndpoint('fetch_messages', { - cursors, - numberPerThread: 1, - }); - return { - rawMessageInfos: response.rawMessageInfos, - truncationStatuses: response.truncationStatuses, +const fetchSingleMostRecentMessagesFromThreads = + ( + callServerEndpoint: CallServerEndpoint, + ): ((threadIDs: $ReadOnlyArray) => Promise) => + async threadIDs => { + const cursors = Object.fromEntries( + threadIDs.map(threadID => [threadID, null]), + ); + const response = await callServerEndpoint('fetch_messages', { + cursors, + numberPerThread: 1, + }); + return { + rawMessageInfos: response.rawMessageInfos, + truncationStatuses: response.truncationStatuses, + }; }; -}; const sendTextMessageActionTypes = Object.freeze({ started: 'SEND_TEXT_MESSAGE_STARTED', success: 'SEND_TEXT_MESSAGE_SUCCESS', failed: 'SEND_TEXT_MESSAGE_FAILED', }); -const sendTextMessage = ( - callServerEndpoint: CallServerEndpoint, -): (( - threadID: string, - localID: string, - text: string, -) => Promise) => async (threadID, localID, text) => { - let resultInfo; - const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { - resultInfo = passedResultInfo; - }; - const response = await callServerEndpoint( - 'create_text_message', - { - threadID, - localID, - text, - }, - { getResultInfo }, - ); - const resultInterface = resultInfo?.interface; - invariant( - resultInterface, - 'getResultInfo not called before callServerEndpoint resolves', - ); - return { - id: response.newMessageInfo.id, - time: response.newMessageInfo.time, - interface: resultInterface, +const sendTextMessage = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + threadID: string, + localID: string, + text: string, + ) => Promise) => + async (threadID, localID, text) => { + let resultInfo; + const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { + resultInfo = passedResultInfo; + }; + const response = await callServerEndpoint( + 'create_text_message', + { + threadID, + localID, + text, + }, + { getResultInfo }, + ); + const resultInterface = resultInfo?.interface; + invariant( + resultInterface, + 'getResultInfo not called before callServerEndpoint resolves', + ); + return { + id: response.newMessageInfo.id, + time: response.newMessageInfo.time, + interface: resultInterface, + }; }; -}; const createLocalMessageActionType = 'CREATE_LOCAL_MESSAGE'; const sendMultimediaMessageActionTypes = Object.freeze({ started: 'SEND_MULTIMEDIA_MESSAGE_STARTED', success: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', failed: 'SEND_MULTIMEDIA_MESSAGE_FAILED', }); -const sendMultimediaMessage = ( - callServerEndpoint: CallServerEndpoint, -): (( - threadID: string, - localID: string, - mediaMessageContents: $ReadOnlyArray, -) => Promise) => async ( - threadID, - localID, - mediaMessageContents, -) => { - let resultInfo; - const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { - resultInfo = passedResultInfo; +const sendMultimediaMessage = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + threadID: string, + localID: string, + mediaMessageContents: $ReadOnlyArray, + ) => Promise) => + async (threadID, localID, mediaMessageContents) => { + let resultInfo; + const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { + resultInfo = passedResultInfo; + }; + const response = await callServerEndpoint( + 'create_multimedia_message', + { + threadID, + localID, + mediaMessageContents, + }, + { getResultInfo }, + ); + const resultInterface = resultInfo?.interface; + invariant( + resultInterface, + 'getResultInfo not called before callServerEndpoint resolves', + ); + return { + id: response.newMessageInfo.id, + time: response.newMessageInfo.time, + interface: resultInterface, + }; }; - const response = await callServerEndpoint( - 'create_multimedia_message', - { - threadID, - localID, - mediaMessageContents, - }, - { getResultInfo }, - ); - const resultInterface = resultInfo?.interface; - invariant( - resultInterface, - 'getResultInfo not called before callServerEndpoint resolves', - ); - return { - id: response.newMessageInfo.id, - time: response.newMessageInfo.time, - interface: resultInterface, - }; -}; -const legacySendMultimediaMessage = ( - callServerEndpoint: CallServerEndpoint, -): (( - threadID: string, - localID: string, - mediaIDs: $ReadOnlyArray, -) => Promise) => async (threadID, localID, mediaIDs) => { - let resultInfo; - const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { - resultInfo = passedResultInfo; - }; - const response = await callServerEndpoint( - 'create_multimedia_message', - { - threadID, - localID, - mediaIDs, - }, - { getResultInfo }, - ); - const resultInterface = resultInfo?.interface; - invariant( - resultInterface, - 'getResultInfo not called before callServerEndpoint resolves', - ); - return { - id: response.newMessageInfo.id, - time: response.newMessageInfo.time, - interface: resultInterface, +const legacySendMultimediaMessage = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + threadID: string, + localID: string, + mediaIDs: $ReadOnlyArray, + ) => Promise) => + async (threadID, localID, mediaIDs) => { + let resultInfo; + const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { + resultInfo = passedResultInfo; + }; + const response = await callServerEndpoint( + 'create_multimedia_message', + { + threadID, + localID, + mediaIDs, + }, + { getResultInfo }, + ); + const resultInterface = resultInfo?.interface; + invariant( + resultInterface, + 'getResultInfo not called before callServerEndpoint resolves', + ); + return { + id: response.newMessageInfo.id, + time: response.newMessageInfo.time, + interface: resultInterface, + }; }; -}; const sendReactionMessageActionTypes = Object.freeze({ started: 'SEND_REACTION_MESSAGE_STARTED', success: 'SEND_REACTION_MESSAGE_SUCCESS', failed: 'SEND_REACTION_MESSAGE_FAILED', }); -const sendReactionMessage = ( - callServerEndpoint: CallServerEndpoint, -): (( - request: SendReactionMessageRequest, -) => Promise) => async request => { - let resultInfo; - const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { - resultInfo = passedResultInfo; - }; +const sendReactionMessage = + ( + callServerEndpoint: CallServerEndpoint, + ): ((request: SendReactionMessageRequest) => Promise) => + async request => { + let resultInfo; + const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { + resultInfo = passedResultInfo; + }; - const response = await callServerEndpoint( - 'create_reaction_message', - { - threadID: request.threadID, - localID: request.localID, - targetMessageID: request.targetMessageID, - reaction: request.reaction, - action: request.action, - }, - { getResultInfo }, - ); + const response = await callServerEndpoint( + 'create_reaction_message', + { + threadID: request.threadID, + localID: request.localID, + targetMessageID: request.targetMessageID, + reaction: request.reaction, + action: request.action, + }, + { getResultInfo }, + ); - const resultInterface = resultInfo?.interface; - invariant( - resultInterface, - 'getResultInfo not called before callServerEndpoint resolves', - ); + const resultInterface = resultInfo?.interface; + invariant( + resultInterface, + 'getResultInfo not called before callServerEndpoint resolves', + ); - return { - id: response.newMessageInfo.id, - time: response.newMessageInfo.time, - interface: resultInterface, + return { + id: response.newMessageInfo.id, + time: response.newMessageInfo.time, + interface: resultInterface, + }; }; -}; const saveMessagesActionType = 'SAVE_MESSAGES'; const processMessagesActionType = 'PROCESS_MESSAGES'; const messageStorePruneActionType = 'MESSAGE_STORE_PRUNE'; export { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, fetchSingleMostRecentMessagesFromThreadsActionTypes, fetchSingleMostRecentMessagesFromThreads, sendTextMessageActionTypes, sendTextMessage, createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, legacySendMultimediaMessage, sendReactionMessageActionTypes, sendReactionMessage, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, }; diff --git a/lib/actions/message-report-actions.js b/lib/actions/message-report-actions.js index ca00557b3..d8e7ff87e 100644 --- a/lib/actions/message-report-actions.js +++ b/lib/actions/message-report-actions.js @@ -1,22 +1,24 @@ // @flow import type { MessageReportCreationRequest, MessageReportCreationResult, } from '../types/message-report-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; const sendMessageReportActionTypes = Object.freeze({ started: 'SEND_MESSAGE_REPORT_STARTED', success: 'SEND_MESSAGE_REPORT_SUCCESS', failed: 'SEND_MESSAGE_REPORT_FAILED', }); -const sendMessageReport = ( - callServerEndpoint: CallServerEndpoint, -): (( - request: MessageReportCreationRequest, -) => Promise) => async request => { - return await callServerEndpoint('create_message_report', request); -}; +const sendMessageReport = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + request: MessageReportCreationRequest, + ) => Promise) => + async request => { + return await callServerEndpoint('create_message_report', request); + }; export { sendMessageReportActionTypes, sendMessageReport }; diff --git a/lib/actions/relationship-actions.js b/lib/actions/relationship-actions.js index b5621d9eb..8d5b4c2a6 100644 --- a/lib/actions/relationship-actions.js +++ b/lib/actions/relationship-actions.js @@ -1,34 +1,34 @@ // @flow import type { RelationshipRequest, RelationshipErrors, } from '../types/relationship-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; import { ServerError } from '../utils/errors.js'; const updateRelationshipsActionTypes = Object.freeze({ started: 'UPDATE_RELATIONSHIPS_STARTED', success: 'UPDATE_RELATIONSHIPS_SUCCESS', failed: 'UPDATE_RELATIONSHIPS_FAILED', }); -const updateRelationships = ( - callServerEndpoint: CallServerEndpoint, -): (( - request: RelationshipRequest, -) => Promise) => async request => { - const errors = await callServerEndpoint('update_relationships', request); +const updateRelationships = + ( + callServerEndpoint: CallServerEndpoint, + ): ((request: RelationshipRequest) => Promise) => + async request => { + const errors = await callServerEndpoint('update_relationships', request); - const { invalid_user, already_friends, user_blocked } = errors; - if (invalid_user) { - throw new ServerError('invalid_user', errors); - } else if (already_friends) { - throw new ServerError('already_friends', errors); - } else if (user_blocked) { - throw new ServerError('user_blocked', errors); - } + const { invalid_user, already_friends, user_blocked } = errors; + if (invalid_user) { + throw new ServerError('invalid_user', errors); + } else if (already_friends) { + throw new ServerError('already_friends', errors); + } else if (user_blocked) { + throw new ServerError('user_blocked', errors); + } - return errors; -}; + return errors; + }; export { updateRelationshipsActionTypes, updateRelationships }; diff --git a/lib/actions/report-actions.js b/lib/actions/report-actions.js index db82b9122..02a78c2eb 100644 --- a/lib/actions/report-actions.js +++ b/lib/actions/report-actions.js @@ -1,53 +1,57 @@ // @flow import type { ClientReportCreationRequest, ReportCreationResponse, } from '../types/report-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; const sendReportActionTypes = Object.freeze({ started: 'SEND_REPORT_STARTED', success: 'SEND_REPORT_SUCCESS', failed: 'SEND_REPORT_FAILED', }); const callServerEndpointOptions = { timeout: 60000 }; -const sendReport = ( - callServerEndpoint: CallServerEndpoint, -): (( - request: ClientReportCreationRequest, -) => Promise) => async request => { - const response = await callServerEndpoint( - 'create_report', - request, - callServerEndpointOptions, - ); - return { id: response.id }; -}; +const sendReport = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + request: ClientReportCreationRequest, + ) => Promise) => + async request => { + const response = await callServerEndpoint( + 'create_report', + request, + callServerEndpointOptions, + ); + return { id: response.id }; + }; const sendReportsActionTypes = Object.freeze({ started: 'SEND_REPORTS_STARTED', success: 'SEND_REPORTS_SUCCESS', failed: 'SEND_REPORTS_FAILED', }); -const sendReports = ( - callServerEndpoint: CallServerEndpoint, -): (( - reports: $ReadOnlyArray, -) => Promise) => async reports => { - await callServerEndpoint( - 'create_reports', - { reports }, - callServerEndpointOptions, - ); -}; +const sendReports = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + reports: $ReadOnlyArray, + ) => Promise) => + async reports => { + await callServerEndpoint( + 'create_reports', + { reports }, + callServerEndpointOptions, + ); + }; const queueReportsActionType = 'QUEUE_REPORTS'; export { sendReportActionTypes, sendReport, sendReportsActionTypes, sendReports, queueReportsActionType, }; diff --git a/lib/actions/siwe-actions.js b/lib/actions/siwe-actions.js index 2752bbfbd..eefe5716b 100644 --- a/lib/actions/siwe-actions.js +++ b/lib/actions/siwe-actions.js @@ -1,70 +1,70 @@ // @flow import { mergeUserInfos } from './user-actions.js'; import threadWatcher from '../shared/thread-watcher.js'; import { type LogInResult, logInActionSources, } from '../types/account-types.js'; import type { SIWEAuthServerCall } from '../types/siwe-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; import { getConfig } from '../utils/config.js'; const getSIWENonceActionTypes = Object.freeze({ started: 'GET_SIWE_NONCE_STARTED', success: 'GET_SIWE_NONCE_SUCCESS', failed: 'GET_SIWE_NONCE_FAILED', }); -const getSIWENonce = ( - callServerEndpoint: CallServerEndpoint, -): (() => Promise) => async () => { - const response = await callServerEndpoint('siwe_nonce'); - return response.nonce; -}; +const getSIWENonce = + (callServerEndpoint: CallServerEndpoint): (() => Promise) => + async () => { + const response = await callServerEndpoint('siwe_nonce'); + return response.nonce; + }; const siweAuthActionTypes = Object.freeze({ started: 'SIWE_AUTH_STARTED', success: 'SIWE_AUTH_SUCCESS', failed: 'SIWE_AUTH_FAILED', }); const siweAuthCallServerEndpointOptions = { timeout: 60000 }; -const siweAuth = ( - callServerEndpoint: CallServerEndpoint, -): (( - siweAuthPayload: SIWEAuthServerCall, -) => Promise) => async siweAuthPayload => { - const watchedIDs = threadWatcher.getWatchedIDs(); - const response = await callServerEndpoint( - 'siwe_auth', - { - ...siweAuthPayload, - watchedIDs, - platformDetails: getConfig().platformDetails, - }, - siweAuthCallServerEndpointOptions, - ); - const userInfos = mergeUserInfos( - response.userInfos, - response.cookieChange.userInfos, - ); - return { - threadInfos: response.cookieChange.threadInfos, - currentUserInfo: response.currentUserInfo, - calendarResult: { - calendarQuery: siweAuthPayload.calendarQuery, - rawEntryInfos: response.rawEntryInfos, - }, - messagesResult: { - messageInfos: response.rawMessageInfos, - truncationStatus: response.truncationStatuses, - watchedIDsAtRequestTime: watchedIDs, - currentAsOf: response.serverTime, - }, - userInfos, - updatesCurrentAsOf: response.serverTime, - logInActionSource: logInActionSources.logInFromNativeSIWE, - notAcknowledgedPolicies: response.notAcknowledgedPolicies, +const siweAuth = + ( + callServerEndpoint: CallServerEndpoint, + ): ((siweAuthPayload: SIWEAuthServerCall) => Promise) => + async siweAuthPayload => { + const watchedIDs = threadWatcher.getWatchedIDs(); + const response = await callServerEndpoint( + 'siwe_auth', + { + ...siweAuthPayload, + watchedIDs, + platformDetails: getConfig().platformDetails, + }, + siweAuthCallServerEndpointOptions, + ); + const userInfos = mergeUserInfos( + response.userInfos, + response.cookieChange.userInfos, + ); + return { + threadInfos: response.cookieChange.threadInfos, + currentUserInfo: response.currentUserInfo, + calendarResult: { + calendarQuery: siweAuthPayload.calendarQuery, + rawEntryInfos: response.rawEntryInfos, + }, + messagesResult: { + messageInfos: response.rawMessageInfos, + truncationStatus: response.truncationStatuses, + watchedIDsAtRequestTime: watchedIDs, + currentAsOf: response.serverTime, + }, + userInfos, + updatesCurrentAsOf: response.serverTime, + logInActionSource: logInActionSources.logInFromNativeSIWE, + notAcknowledgedPolicies: response.notAcknowledgedPolicies, + }; }; -}; export { getSIWENonceActionTypes, getSIWENonce, siweAuthActionTypes, siweAuth }; diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js index 29514293f..8791d5ddf 100644 --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -1,180 +1,181 @@ // @flow import invariant from 'invariant'; import type { ChangeThreadSettingsPayload, LeaveThreadPayload, UpdateThreadRequest, ClientNewThreadRequest, NewThreadResult, ClientThreadJoinRequest, ThreadJoinPayload, } from '../types/thread-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; import { values } from '../utils/objects.js'; const deleteThreadActionTypes = Object.freeze({ started: 'DELETE_THREAD_STARTED', success: 'DELETE_THREAD_SUCCESS', failed: 'DELETE_THREAD_FAILED', }); -const deleteThread = ( - callServerEndpoint: CallServerEndpoint, -): (( - threadID: string, - currentAccountPassword: ?string, -) => Promise) => async ( - threadID, - currentAccountPassword, -) => { - const response = await callServerEndpoint('delete_thread', { - threadID, - accountPassword: currentAccountPassword, - }); - return { - updatesResult: response.updatesResult, +const deleteThread = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + threadID: string, + currentAccountPassword: ?string, + ) => Promise) => + async (threadID, currentAccountPassword) => { + const response = await callServerEndpoint('delete_thread', { + threadID, + accountPassword: currentAccountPassword, + }); + return { + updatesResult: response.updatesResult, + }; }; -}; const changeThreadSettingsActionTypes = Object.freeze({ started: 'CHANGE_THREAD_SETTINGS_STARTED', success: 'CHANGE_THREAD_SETTINGS_SUCCESS', failed: 'CHANGE_THREAD_SETTINGS_FAILED', }); -const changeThreadSettings = ( - callServerEndpoint: CallServerEndpoint, -): (( - request: UpdateThreadRequest, -) => Promise) => async request => { - const response = await callServerEndpoint('update_thread', request); - invariant( - Object.keys(request.changes).length > 0, - 'No changes provided to changeThreadSettings!', - ); - return { - threadID: request.threadID, - updatesResult: response.updatesResult, - newMessageInfos: response.newMessageInfos, +const changeThreadSettings = + ( + callServerEndpoint: CallServerEndpoint, + ): ((request: UpdateThreadRequest) => Promise) => + async request => { + const response = await callServerEndpoint('update_thread', request); + invariant( + Object.keys(request.changes).length > 0, + 'No changes provided to changeThreadSettings!', + ); + return { + threadID: request.threadID, + updatesResult: response.updatesResult, + newMessageInfos: response.newMessageInfos, + }; }; -}; const removeUsersFromThreadActionTypes = Object.freeze({ started: 'REMOVE_USERS_FROM_THREAD_STARTED', success: 'REMOVE_USERS_FROM_THREAD_SUCCESS', failed: 'REMOVE_USERS_FROM_THREAD_FAILED', }); -const removeUsersFromThread = ( - callServerEndpoint: CallServerEndpoint, -): (( - threadID: string, - memberIDs: $ReadOnlyArray, -) => Promise) => async (threadID, memberIDs) => { - const response = await callServerEndpoint('remove_members', { - threadID, - memberIDs, - }); - return { - threadID, - updatesResult: response.updatesResult, - newMessageInfos: response.newMessageInfos, +const removeUsersFromThread = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + threadID: string, + memberIDs: $ReadOnlyArray, + ) => Promise) => + async (threadID, memberIDs) => { + const response = await callServerEndpoint('remove_members', { + threadID, + memberIDs, + }); + return { + threadID, + updatesResult: response.updatesResult, + newMessageInfos: response.newMessageInfos, + }; }; -}; const changeThreadMemberRolesActionTypes = Object.freeze({ started: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', success: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', failed: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', }); -const changeThreadMemberRoles = ( - callServerEndpoint: CallServerEndpoint, -): (( - threadID: string, - memberIDs: $ReadOnlyArray, - newRole: string, -) => Promise) => async ( - threadID, - memberIDs, - newRole, -) => { - const response = await callServerEndpoint('update_role', { - threadID, - memberIDs, - role: newRole, - }); - return { - threadID, - updatesResult: response.updatesResult, - newMessageInfos: response.newMessageInfos, +const changeThreadMemberRoles = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + threadID: string, + memberIDs: $ReadOnlyArray, + newRole: string, + ) => Promise) => + async (threadID, memberIDs, newRole) => { + const response = await callServerEndpoint('update_role', { + threadID, + memberIDs, + role: newRole, + }); + return { + threadID, + updatesResult: response.updatesResult, + newMessageInfos: response.newMessageInfos, + }; }; -}; const newThreadActionTypes = Object.freeze({ started: 'NEW_THREAD_STARTED', success: 'NEW_THREAD_SUCCESS', failed: 'NEW_THREAD_FAILED', }); -const newThread = ( - callServerEndpoint: CallServerEndpoint, -): (( - request: ClientNewThreadRequest, -) => Promise) => async request => { - const response = await callServerEndpoint('create_thread', request); - return { - newThreadID: response.newThreadID, - updatesResult: response.updatesResult, - newMessageInfos: response.newMessageInfos, - userInfos: response.userInfos, +const newThread = + ( + callServerEndpoint: CallServerEndpoint, + ): ((request: ClientNewThreadRequest) => Promise) => + async request => { + const response = await callServerEndpoint('create_thread', request); + return { + newThreadID: response.newThreadID, + updatesResult: response.updatesResult, + newMessageInfos: response.newMessageInfos, + userInfos: response.userInfos, + }; }; -}; const joinThreadActionTypes = Object.freeze({ started: 'JOIN_THREAD_STARTED', success: 'JOIN_THREAD_SUCCESS', failed: 'JOIN_THREAD_FAILED', }); -const joinThread = ( - callServerEndpoint: CallServerEndpoint, -): (( - request: ClientThreadJoinRequest, -) => Promise) => async request => { - const response = await callServerEndpoint('join_thread', request); - const userInfos = values(response.userInfos); - return { - updatesResult: response.updatesResult, - rawMessageInfos: response.rawMessageInfos, - truncationStatuses: response.truncationStatuses, - userInfos, +const joinThread = + ( + callServerEndpoint: CallServerEndpoint, + ): ((request: ClientThreadJoinRequest) => Promise) => + async request => { + const response = await callServerEndpoint('join_thread', request); + const userInfos = values(response.userInfos); + return { + updatesResult: response.updatesResult, + rawMessageInfos: response.rawMessageInfos, + truncationStatuses: response.truncationStatuses, + userInfos, + }; }; -}; const leaveThreadActionTypes = Object.freeze({ started: 'LEAVE_THREAD_STARTED', success: 'LEAVE_THREAD_SUCCESS', failed: 'LEAVE_THREAD_FAILED', }); -const leaveThread = ( - callServerEndpoint: CallServerEndpoint, -): ((threadID: string) => Promise) => async threadID => { - const response = await callServerEndpoint('leave_thread', { threadID }); - return { - updatesResult: response.updatesResult, +const leaveThread = + ( + callServerEndpoint: CallServerEndpoint, + ): ((threadID: string) => Promise) => + async threadID => { + const response = await callServerEndpoint('leave_thread', { threadID }); + return { + updatesResult: response.updatesResult, + }; }; -}; export { deleteThreadActionTypes, deleteThread, changeThreadSettingsActionTypes, changeThreadSettings, removeUsersFromThreadActionTypes, removeUsersFromThread, changeThreadMemberRolesActionTypes, changeThreadMemberRoles, newThreadActionTypes, newThread, joinThreadActionTypes, joinThread, leaveThreadActionTypes, leaveThread, }; diff --git a/lib/actions/upload-actions.js b/lib/actions/upload-actions.js index 591828cb5..206064ae3 100644 --- a/lib/actions/upload-actions.js +++ b/lib/actions/upload-actions.js @@ -1,79 +1,77 @@ // @flow import type { Shape } from '../types/core.js'; import type { UploadMultimediaResult, Dimensions, } from '../types/media-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; import type { UploadBlob } from '../utils/upload-blob.js'; export type MultimediaUploadCallbacks = Shape<{ onProgress: (percent: number) => void, abortHandler: (abort: () => void) => void, uploadBlob: UploadBlob, }>; export type MultimediaUploadExtras = Shape<{ ...Dimensions, loop: boolean }>; -const uploadMultimedia = ( - callServerEndpoint: CallServerEndpoint, -): (( - multimedia: Object, - extras: MultimediaUploadExtras, - callbacks?: MultimediaUploadCallbacks, -) => Promise) => async ( - multimedia, - extras, - callbacks, -) => { - const onProgress = callbacks && callbacks.onProgress; - const abortHandler = callbacks && callbacks.abortHandler; - const uploadBlob = callbacks && callbacks.uploadBlob; +const uploadMultimedia = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + multimedia: Object, + extras: MultimediaUploadExtras, + callbacks?: MultimediaUploadCallbacks, + ) => Promise) => + async (multimedia, extras, callbacks) => { + const onProgress = callbacks && callbacks.onProgress; + const abortHandler = callbacks && callbacks.abortHandler; + const uploadBlob = callbacks && callbacks.uploadBlob; - const stringExtras = {}; - if (extras.height !== null && extras.height !== undefined) { - stringExtras.height = extras.height.toString(); - } - if (extras.width !== null && extras.width !== undefined) { - stringExtras.width = extras.width.toString(); - } - if (extras.loop) { - stringExtras.loop = '1'; - } + const stringExtras = {}; + if (extras.height !== null && extras.height !== undefined) { + stringExtras.height = extras.height.toString(); + } + if (extras.width !== null && extras.width !== undefined) { + stringExtras.width = extras.width.toString(); + } + if (extras.loop) { + stringExtras.loop = '1'; + } - const response = await callServerEndpoint( - 'upload_multimedia', - { - multimedia: [multimedia], - ...stringExtras, - }, - { - onProgress, - abortHandler, - blobUpload: uploadBlob ? uploadBlob : true, - }, - ); - const [uploadResult] = response.results; - return { - id: uploadResult.id, - uri: uploadResult.uri, - dimensions: uploadResult.dimensions, - mediaType: uploadResult.mediaType, - loop: uploadResult.loop, + const response = await callServerEndpoint( + 'upload_multimedia', + { + multimedia: [multimedia], + ...stringExtras, + }, + { + onProgress, + abortHandler, + blobUpload: uploadBlob ? uploadBlob : true, + }, + ); + const [uploadResult] = response.results; + return { + id: uploadResult.id, + uri: uploadResult.uri, + dimensions: uploadResult.dimensions, + mediaType: uploadResult.mediaType, + loop: uploadResult.loop, + }; }; -}; const updateMultimediaMessageMediaActionType = 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA'; -const deleteUpload = ( - callServerEndpoint: CallServerEndpoint, -): ((id: string) => Promise) => async id => { - await callServerEndpoint('delete_upload', { id }); -}; +const deleteUpload = + (callServerEndpoint: CallServerEndpoint): ((id: string) => Promise) => + async id => { + await callServerEndpoint('delete_upload', { id }); + }; export { uploadMultimedia, updateMultimediaMessageMediaActionType, deleteUpload, }; diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js index c91d071fa..b39d43121 100644 --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -1,261 +1,267 @@ // @flow import threadWatcher from '../shared/thread-watcher.js'; import type { LogOutResult, LogInInfo, LogInResult, RegisterResult, RegisterInfo, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, } from '../types/account-types.js'; import type { GetSessionPublicKeysArgs } from '../types/request-types.js'; import type { UserSearchResult } from '../types/search-types.js'; import type { SessionPublicKeys, PreRequestUserState, } from '../types/session-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from '../types/subscription-types.js'; import type { UserInfo, PasswordUpdate } from '../types/user-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; import { getConfig } from '../utils/config.js'; import sleep from '../utils/sleep.js'; const logOutActionTypes = Object.freeze({ started: 'LOG_OUT_STARTED', success: 'LOG_OUT_SUCCESS', failed: 'LOG_OUT_FAILED', }); -const logOut = ( - callServerEndpoint: CallServerEndpoint, -): (( - preRequestUserState: PreRequestUserState, -) => Promise) => async preRequestUserState => { - let response = null; - try { - response = await Promise.race([ - callServerEndpoint('log_out', {}), - (async () => { - await sleep(500); - throw new Error('log_out took more than 500ms'); - })(), - ]); - } catch {} - const currentUserInfo = response ? response.currentUserInfo : null; - return { currentUserInfo, preRequestUserState }; -}; +const logOut = + ( + callServerEndpoint: CallServerEndpoint, + ): ((preRequestUserState: PreRequestUserState) => Promise) => + async preRequestUserState => { + let response = null; + try { + response = await Promise.race([ + callServerEndpoint('log_out', {}), + (async () => { + await sleep(500); + throw new Error('log_out took more than 500ms'); + })(), + ]); + } catch {} + const currentUserInfo = response ? response.currentUserInfo : null; + return { currentUserInfo, preRequestUserState }; + }; const deleteAccountActionTypes = Object.freeze({ started: 'DELETE_ACCOUNT_STARTED', success: 'DELETE_ACCOUNT_SUCCESS', failed: 'DELETE_ACCOUNT_FAILED', }); -const deleteAccount = ( - callServerEndpoint: CallServerEndpoint, -): (( - password: ?string, - preRequestUserState: PreRequestUserState, -) => Promise) => async (password, preRequestUserState) => { - const response = await callServerEndpoint('delete_account', { password }); - return { currentUserInfo: response.currentUserInfo, preRequestUserState }; -}; +const deleteAccount = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + password: ?string, + preRequestUserState: PreRequestUserState, + ) => Promise) => + async (password, preRequestUserState) => { + const response = await callServerEndpoint('delete_account', { password }); + return { currentUserInfo: response.currentUserInfo, preRequestUserState }; + }; const registerActionTypes = Object.freeze({ started: 'REGISTER_STARTED', success: 'REGISTER_SUCCESS', failed: 'REGISTER_FAILED', }); const registerCallServerEndpointOptions = { timeout: 60000 }; -const register = ( - callServerEndpoint: CallServerEndpoint, -): (( - registerInfo: RegisterInfo, -) => Promise) => async registerInfo => { - const response = await callServerEndpoint( - 'create_account', - { - ...registerInfo, - platformDetails: getConfig().platformDetails, - }, - registerCallServerEndpointOptions, - ); - return { - currentUserInfo: response.currentUserInfo, - rawMessageInfos: response.rawMessageInfos, - threadInfos: response.cookieChange.threadInfos, - userInfos: response.cookieChange.userInfos, - calendarQuery: registerInfo.calendarQuery, +const register = + ( + callServerEndpoint: CallServerEndpoint, + ): ((registerInfo: RegisterInfo) => Promise) => + async registerInfo => { + const response = await callServerEndpoint( + 'create_account', + { + ...registerInfo, + platformDetails: getConfig().platformDetails, + }, + registerCallServerEndpointOptions, + ); + return { + currentUserInfo: response.currentUserInfo, + rawMessageInfos: response.rawMessageInfos, + threadInfos: response.cookieChange.threadInfos, + userInfos: response.cookieChange.userInfos, + calendarQuery: registerInfo.calendarQuery, + }; }; -}; function mergeUserInfos(...userInfoArrays: UserInfo[][]): UserInfo[] { const merged = {}; for (const userInfoArray of userInfoArrays) { for (const userInfo of userInfoArray) { merged[userInfo.id] = userInfo; } } const flattened = []; for (const id in merged) { flattened.push(merged[id]); } return flattened; } const logInActionTypes = Object.freeze({ started: 'LOG_IN_STARTED', success: 'LOG_IN_SUCCESS', failed: 'LOG_IN_FAILED', }); const logInCallServerEndpointOptions = { timeout: 60000 }; -const logIn = ( - callServerEndpoint: CallServerEndpoint, -): ((logInInfo: LogInInfo) => Promise) => async logInInfo => { - const watchedIDs = threadWatcher.getWatchedIDs(); - const { logInActionSource, ...restLogInInfo } = logInInfo; - const response = await callServerEndpoint( - 'log_in', - { - ...restLogInInfo, - source: logInActionSource, - watchedIDs, - platformDetails: getConfig().platformDetails, - }, - logInCallServerEndpointOptions, - ); - const userInfos = mergeUserInfos( - response.userInfos, - response.cookieChange.userInfos, - ); - return { - threadInfos: response.cookieChange.threadInfos, - currentUserInfo: response.currentUserInfo, - calendarResult: { - calendarQuery: logInInfo.calendarQuery, - rawEntryInfos: response.rawEntryInfos, - }, - messagesResult: { - messageInfos: response.rawMessageInfos, - truncationStatus: response.truncationStatuses, - watchedIDsAtRequestTime: watchedIDs, - currentAsOf: response.serverTime, - }, - userInfos, - updatesCurrentAsOf: response.serverTime, - logInActionSource: logInInfo.logInActionSource, - notAcknowledgedPolicies: response.notAcknowledgedPolicies, +const logIn = + ( + callServerEndpoint: CallServerEndpoint, + ): ((logInInfo: LogInInfo) => Promise) => + async logInInfo => { + const watchedIDs = threadWatcher.getWatchedIDs(); + const { logInActionSource, ...restLogInInfo } = logInInfo; + const response = await callServerEndpoint( + 'log_in', + { + ...restLogInInfo, + source: logInActionSource, + watchedIDs, + platformDetails: getConfig().platformDetails, + }, + logInCallServerEndpointOptions, + ); + const userInfos = mergeUserInfos( + response.userInfos, + response.cookieChange.userInfos, + ); + return { + threadInfos: response.cookieChange.threadInfos, + currentUserInfo: response.currentUserInfo, + calendarResult: { + calendarQuery: logInInfo.calendarQuery, + rawEntryInfos: response.rawEntryInfos, + }, + messagesResult: { + messageInfos: response.rawMessageInfos, + truncationStatus: response.truncationStatuses, + watchedIDsAtRequestTime: watchedIDs, + currentAsOf: response.serverTime, + }, + userInfos, + updatesCurrentAsOf: response.serverTime, + logInActionSource: logInInfo.logInActionSource, + notAcknowledgedPolicies: response.notAcknowledgedPolicies, + }; }; -}; const changeUserPasswordActionTypes = Object.freeze({ started: 'CHANGE_USER_PASSWORD_STARTED', success: 'CHANGE_USER_PASSWORD_SUCCESS', failed: 'CHANGE_USER_PASSWORD_FAILED', }); -const changeUserPassword = ( - callServerEndpoint: CallServerEndpoint, -): (( - passwordUpdate: PasswordUpdate, -) => Promise) => async passwordUpdate => { - await callServerEndpoint('update_account', passwordUpdate); -}; +const changeUserPassword = + ( + callServerEndpoint: CallServerEndpoint, + ): ((passwordUpdate: PasswordUpdate) => Promise) => + async passwordUpdate => { + await callServerEndpoint('update_account', passwordUpdate); + }; const searchUsersActionTypes = Object.freeze({ started: 'SEARCH_USERS_STARTED', success: 'SEARCH_USERS_SUCCESS', failed: 'SEARCH_USERS_FAILED', }); -const searchUsers = ( - callServerEndpoint: CallServerEndpoint, -): (( - usernamePrefix: string, -) => Promise) => async usernamePrefix => { - const response = await callServerEndpoint('search_users', { - prefix: usernamePrefix, - }); - return { - userInfos: response.userInfos, +const searchUsers = + ( + callServerEndpoint: CallServerEndpoint, + ): ((usernamePrefix: string) => Promise) => + async usernamePrefix => { + const response = await callServerEndpoint('search_users', { + prefix: usernamePrefix, + }); + return { + userInfos: response.userInfos, + }; }; -}; const updateSubscriptionActionTypes = Object.freeze({ started: 'UPDATE_SUBSCRIPTION_STARTED', success: 'UPDATE_SUBSCRIPTION_SUCCESS', failed: 'UPDATE_SUBSCRIPTION_FAILED', }); -const updateSubscription = ( - callServerEndpoint: CallServerEndpoint, -): (( - subscriptionUpdate: SubscriptionUpdateRequest, -) => Promise) => async subscriptionUpdate => { - const response = await callServerEndpoint( - 'update_user_subscription', - subscriptionUpdate, - ); - return { - threadID: subscriptionUpdate.threadID, - subscription: response.threadSubscription, +const updateSubscription = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + subscriptionUpdate: SubscriptionUpdateRequest, + ) => Promise) => + async subscriptionUpdate => { + const response = await callServerEndpoint( + 'update_user_subscription', + subscriptionUpdate, + ); + return { + threadID: subscriptionUpdate.threadID, + subscription: response.threadSubscription, + }; }; -}; const setUserSettingsActionTypes = Object.freeze({ started: 'SET_USER_SETTINGS_STARTED', success: 'SET_USER_SETTINGS_SUCCESS', failed: 'SET_USER_SETTINGS_FAILED', }); -const setUserSettings = ( - callServerEndpoint: CallServerEndpoint, -): (( - userSettingsRequest: UpdateUserSettingsRequest, -) => Promise) => async userSettingsRequest => { - await callServerEndpoint('update_user_settings', userSettingsRequest); -}; +const setUserSettings = + ( + callServerEndpoint: CallServerEndpoint, + ): ((userSettingsRequest: UpdateUserSettingsRequest) => Promise) => + async userSettingsRequest => { + await callServerEndpoint('update_user_settings', userSettingsRequest); + }; -const getSessionPublicKeys = ( - callServerEndpoint: CallServerEndpoint, -): (( - data: GetSessionPublicKeysArgs, -) => Promise) => async data => { - return await callServerEndpoint('get_session_public_keys', data); -}; +const getSessionPublicKeys = + ( + callServerEndpoint: CallServerEndpoint, + ): ((data: GetSessionPublicKeysArgs) => Promise) => + async data => { + return await callServerEndpoint('get_session_public_keys', data); + }; const policyAcknowledgmentActionTypes = Object.freeze({ started: 'POLICY_ACKNOWLEDGMENT_STARTED', success: 'POLICY_ACKNOWLEDGMENT_SUCCESS', failed: 'POLICY_ACKNOWLEDGMENT_FAILED', }); -const policyAcknowledgment = ( - callServerEndpoint: CallServerEndpoint, -): (( - policyRequest: PolicyAcknowledgmentRequest, -) => Promise) => async policyRequest => { - await callServerEndpoint('policy_acknowledgment', policyRequest); -}; +const policyAcknowledgment = + ( + callServerEndpoint: CallServerEndpoint, + ): ((policyRequest: PolicyAcknowledgmentRequest) => Promise) => + async policyRequest => { + await callServerEndpoint('policy_acknowledgment', policyRequest); + }; export { changeUserPasswordActionTypes, changeUserPassword, deleteAccount, deleteAccountActionTypes, getSessionPublicKeys, mergeUserInfos, logIn, logInActionTypes, logOut, logOutActionTypes, register, registerActionTypes, searchUsers, searchUsersActionTypes, setUserSettings, setUserSettingsActionTypes, updateSubscription, updateSubscriptionActionTypes, policyAcknowledgment, policyAcknowledgmentActionTypes, }; diff --git a/lib/components/connected-wallet-info.react.js b/lib/components/connected-wallet-info.react.js index d42285cc7..6b02c0af6 100644 --- a/lib/components/connected-wallet-info.react.js +++ b/lib/components/connected-wallet-info.react.js @@ -1,69 +1,70 @@ // @flow import { emojiAvatarForAddress, useAccountModal } from '@rainbow-me/rainbowkit'; import * as React from 'react'; import { useAccount, useEnsAvatar } from 'wagmi'; import css from './connected-wallet-info.css'; import SWMansionIcon from './SWMansionIcon.react.js'; import { useENSName } from '../hooks/ens-cache.js'; function shortenAddressToFitWidth(address: string): string { if (address.length < 22) { return address; } return `${address.slice(0, 10)}…${address.slice(-10)}`; } type RainbowKitEmojiAvatar = { +color: string, +emoji: string, }; function ConnectedWalletInfo(): React.Node { const { address } = useAccount(); const { openAccountModal } = useAccountModal(); const potentiallyENSName = useENSName(address); const emojiAvatar: RainbowKitEmojiAvatar = React.useMemo( () => emojiAvatarForAddress(address), [address], ); const emojiAvatarStyle = React.useMemo( () => ({ backgroundColor: emojiAvatar.color }), [emojiAvatar.color], ); - const emojiAvatarView = React.useMemo(() =>

{emojiAvatar.emoji}

, [ - emojiAvatar.emoji, - ]); + const emojiAvatarView = React.useMemo( + () =>

{emojiAvatar.emoji}

, + [emojiAvatar.emoji], + ); const { data: ensAvatarURI } = useEnsAvatar({ addressOrName: potentiallyENSName, }); const potentiallyENSAvatar = React.useMemo( () => , [ensAvatarURI], ); const onClick = React.useCallback(() => { openAccountModal && openAccountModal(); }, [openAccountModal]); return (
{ensAvatarURI ? potentiallyENSAvatar : emojiAvatarView}

{shortenAddressToFitWidth(potentiallyENSName)}

); } export default ConnectedWalletInfo; diff --git a/lib/components/ens-cache-provider.react.js b/lib/components/ens-cache-provider.react.js index a87455ea1..fee412128 100644 --- a/lib/components/ens-cache-provider.react.js +++ b/lib/components/ens-cache-provider.react.js @@ -1,44 +1,43 @@ // @flow import * as React from 'react'; import { ENSCache, type EthersProvider } from '../utils/ens-cache.js'; import { getENSNames as baseGetENSNames, type GetENSNames, } from '../utils/ens-helpers.js'; type ENSCacheContextType = { +ensCache: ?ENSCache, +getENSNames: ?GetENSNames, }; const defaultContext = { ensCache: undefined, getENSNames: undefined, }; -const ENSCacheContext: React.Context = React.createContext( - defaultContext, -); +const ENSCacheContext: React.Context = + React.createContext(defaultContext); type Props = { +provider: ?EthersProvider, +children: React.Node, }; function ENSCacheProvider(props: Props): React.Node { const { provider, children } = props; const context = React.useMemo(() => { if (!provider) { return defaultContext; } const ensCache = new ENSCache(provider); const getENSNames: GetENSNames = baseGetENSNames.bind(null, ensCache); return { ensCache, getENSNames }; }, [provider]); return ( {children} ); } export { ENSCacheContext, ENSCacheProvider }; diff --git a/lib/components/modal-provider.react.js b/lib/components/modal-provider.react.js index de3024df0..4c2e230c0 100644 --- a/lib/components/modal-provider.react.js +++ b/lib/components/modal-provider.react.js @@ -1,68 +1,67 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { getUUID } from '../utils/uuid.js'; export type PushModal = React.Node => string; type Props = { +children: React.Node, }; type ModalContextType = { +modals: $ReadOnlyArray<[React.Node, string]>, +pushModal: PushModal, +popModal: () => void, +clearModals: () => void, }; -const ModalContext: React.Context = React.createContext( - { +const ModalContext: React.Context = + React.createContext({ modals: [], pushModal: () => '', popModal: () => {}, clearModals: () => {}, - }, -); + }); function ModalProvider(props: Props): React.Node { const { children } = props; const [modals, setModals] = React.useState< $ReadOnlyArray<[React.Node, string]>, >([]); const popModal = React.useCallback( () => setModals(oldModals => oldModals.slice(0, -1)), [], ); const pushModal = React.useCallback(newModal => { const key = getUUID(); setModals(oldModals => [...oldModals, [newModal, key]]); return key; }, []); const clearModals = React.useCallback(() => setModals([]), []); const value = React.useMemo( () => ({ modals, pushModal, popModal, clearModals, }), [modals, pushModal, popModal, clearModals], ); return ( {children} ); } function useModalContext(): ModalContextType { const context = React.useContext(ModalContext); invariant(context, 'ModalContext not found'); return context; } export { ModalProvider, useModalContext }; diff --git a/lib/hooks/disconnected-bar.js b/lib/hooks/disconnected-bar.js index c4d5606d7..946a693e5 100644 --- a/lib/hooks/disconnected-bar.js +++ b/lib/hooks/disconnected-bar.js @@ -1,118 +1,115 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { updateDisconnectedBarActionType } from '../types/socket-types.js'; import { useSelector } from '../utils/redux-utils.js'; function useDisconnectedBarVisibilityHandler(networkConnected: boolean): void { const dispatch = useDispatch(); const disconnected = useSelector( state => state.connection.showDisconnectedBar, ); const setDisconnected = React.useCallback( (newDisconnected: boolean) => { if (newDisconnected === disconnected) { return; } dispatch({ type: updateDisconnectedBarActionType, payload: { visible: newDisconnected }, }); }, [disconnected, dispatch], ); const networkActiveRef = React.useRef(true); React.useEffect(() => { networkActiveRef.current = networkConnected; if (!networkConnected) { setDisconnected(true); } }, [setDisconnected, networkConnected]); const prevConnectionStatusRef = React.useRef(); const connectionStatus = useSelector(state => state.connection.status); const someRequestIsLate = useSelector( state => state.connection.lateResponses.length !== 0, ); React.useEffect(() => { const prevConnectionStatus = prevConnectionStatusRef.current; prevConnectionStatusRef.current = connectionStatus; if ( connectionStatus === 'connected' && prevConnectionStatus !== 'connected' ) { // Sometimes NetInfo misses the network coming back online for some // reason. But if the socket reconnects, the network must be up networkActiveRef.current = true; setDisconnected(false); } else if (!networkActiveRef.current || someRequestIsLate) { setDisconnected(true); } else if ( connectionStatus === 'reconnecting' || connectionStatus === 'forcedDisconnecting' ) { setDisconnected(true); } else if (connectionStatus === 'connected') { setDisconnected(false); } }, [connectionStatus, someRequestIsLate, setDisconnected]); } function useShouldShowDisconnectedBar(): { +disconnected: boolean, +shouldShowDisconnectedBar: boolean, } { const disconnected = useSelector( state => state.connection.showDisconnectedBar, ); const socketConnected = useSelector( state => state.connection.status === 'connected', ); const shouldShowDisconnectedBar = disconnected || !socketConnected; return { disconnected, shouldShowDisconnectedBar }; } type DisconnectedBarCause = 'connecting' | 'disconnected'; function useDisconnectedBar( changeShowing: boolean => void, ): DisconnectedBarCause { - const { - disconnected, - shouldShowDisconnectedBar, - } = useShouldShowDisconnectedBar(); + const { disconnected, shouldShowDisconnectedBar } = + useShouldShowDisconnectedBar(); const prevShowDisconnectedBar = React.useRef(); React.useEffect(() => { const wasShowing = prevShowDisconnectedBar.current; if (shouldShowDisconnectedBar && wasShowing === false) { changeShowing(true); } else if (!shouldShowDisconnectedBar && wasShowing) { changeShowing(false); } prevShowDisconnectedBar.current = shouldShowDisconnectedBar; }, [shouldShowDisconnectedBar, changeShowing]); - const [barCause, setBarCause] = React.useState( - 'connecting', - ); + const [barCause, setBarCause] = + React.useState('connecting'); React.useEffect(() => { if (shouldShowDisconnectedBar && disconnected) { setBarCause('disconnected'); } else if (shouldShowDisconnectedBar) { setBarCause('connecting'); } }, [shouldShowDisconnectedBar, disconnected]); return barCause; } export { useDisconnectedBarVisibilityHandler, useShouldShowDisconnectedBar, useDisconnectedBar, }; diff --git a/lib/reducers/master-reducer.js b/lib/reducers/master-reducer.js index b98034ee8..c53a05ffe 100644 --- a/lib/reducers/master-reducer.js +++ b/lib/reducers/master-reducer.js @@ -1,122 +1,117 @@ // @flow import reduceCalendarFilters from './calendar-filters-reducer.js'; import reduceConnectionInfo from './connection-reducer.js'; import reduceDataLoaded from './data-loaded-reducer.js'; import { reduceDraftStore } from './draft-reducer.js'; import reduceEnabledApps from './enabled-apps-reducer.js'; import { reduceEntryInfos } from './entry-reducer.js'; import reduceLifecycleState from './lifecycle-state-reducer.js'; import { reduceLoadingStatuses } from './loading-reducer.js'; import reduceNextLocalID from './local-id-reducer.js'; import { reduceMessageStore } from './message-reducer.js'; import reduceBaseNavInfo from './nav-reducer.js'; import policiesReducer from './policies-reducer.js'; import reduceReportStore from './report-store-reducer.js'; import { reduceThreadInfos } from './thread-reducer.js'; import reduceUpdatesCurrentAsOf from './updates-reducer.js'; import reduceURLPrefix from './url-prefix-reducer.js'; import { reduceCurrentUserInfo, reduceUserInfos } from './user-reducer.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { registerActionTypes, logInActionTypes, } from '../actions/user-actions.js'; import type { BaseNavInfo } from '../types/nav-types.js'; import type { BaseAppState, BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import type { StoreOperations } from '../types/store-ops-types.js'; export default function baseReducer>( state: T, action: BaseAction, ): { state: T, storeOperations: StoreOperations } { - const { - threadStore, - newThreadInconsistencies, - threadStoreOperations, - } = reduceThreadInfos(state.threadStore, action); + const { threadStore, newThreadInconsistencies, threadStoreOperations } = + reduceThreadInfos(state.threadStore, action); const { threadInfos } = threadStore; const [entryStore, newEntryInconsistencies] = reduceEntryInfos( state.entryStore, action, threadInfos, ); const newInconsistencies = [ ...newEntryInconsistencies, ...newThreadInconsistencies, ]; // Only allow checkpoints to increase if we are connected // or if the action is a STATE_SYNC - const { - messageStoreOperations, - messageStore: reducedMessageStore, - } = reduceMessageStore(state.messageStore, action, threadInfos); + const { messageStoreOperations, messageStore: reducedMessageStore } = + reduceMessageStore(state.messageStore, action, threadInfos); let messageStore = reducedMessageStore; let updatesCurrentAsOf = reduceUpdatesCurrentAsOf( state.updatesCurrentAsOf, action, ); const connection = reduceConnectionInfo(state.connection, action); if ( connection.status !== 'connected' && action.type !== incrementalStateSyncActionType && action.type !== fullStateSyncActionType && action.type !== registerActionTypes.success && action.type !== logInActionTypes.success && action.type !== siweAuthActionTypes.success ) { if (messageStore.currentAsOf !== state.messageStore.currentAsOf) { messageStore = { ...messageStore, currentAsOf: state.messageStore.currentAsOf, }; } if (updatesCurrentAsOf !== state.updatesCurrentAsOf) { updatesCurrentAsOf = state.updatesCurrentAsOf; } } const { draftStore, draftStoreOperations } = reduceDraftStore( state.draftStore, action, ); return { state: { ...state, navInfo: reduceBaseNavInfo(state.navInfo, action), draftStore, entryStore, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), currentUserInfo: reduceCurrentUserInfo(state.currentUserInfo, action), threadStore, userStore: reduceUserInfos(state.userStore, action), messageStore, updatesCurrentAsOf, urlPrefix: reduceURLPrefix(state.urlPrefix, action), calendarFilters: reduceCalendarFilters(state.calendarFilters, action), connection, lifecycleState: reduceLifecycleState(state.lifecycleState, action), enabledApps: reduceEnabledApps(state.enabledApps, action), reportStore: reduceReportStore( state.reportStore, action, newInconsistencies, ), nextLocalID: reduceNextLocalID(state.nextLocalID, action), dataLoaded: reduceDataLoaded(state.dataLoaded, action), userPolicies: policiesReducer(state.userPolicies, action), }, storeOperations: { draftStoreOperations, threadStoreOperations, messageStoreOperations, }, }; } diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js index 48a0f37b8..a96aabeb4 100644 --- a/lib/reducers/message-reducer.js +++ b/lib/reducers/message-reducer.js @@ -1,1463 +1,1448 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import _flow from 'lodash/fp/flow.js'; import _isEqual from 'lodash/fp/isEqual.js'; import _keyBy from 'lodash/fp/keyBy.js'; import _map from 'lodash/fp/map.js'; import _mapKeys from 'lodash/fp/mapKeys.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omit from 'lodash/fp/omit.js'; import _omitBy from 'lodash/fp/omitBy.js'; import _pick from 'lodash/fp/pick.js'; import _pickBy from 'lodash/fp/pickBy.js'; import _uniq from 'lodash/fp/uniq.js'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { createEntryActionTypes, saveEntryActionTypes, deleteEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions.js'; import { fetchMessagesBeforeCursorActionTypes, fetchMostRecentMessagesActionTypes, sendTextMessageActionTypes, sendMultimediaMessageActionTypes, sendReactionMessageActionTypes, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, createLocalMessageActionType, fetchSingleMostRecentMessagesFromThreadsActionTypes, } from '../actions/message-actions.js'; import { sendMessageReportActionTypes } from '../actions/message-report-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, leaveThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, } from '../actions/thread-actions.js'; import { updateMultimediaMessageMediaActionType } from '../actions/upload-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, } from '../actions/user-actions.js'; import { pendingToRealizedThreadIDsSelector } from '../selectors/thread-selectors.js'; import { messageID, combineTruncationStatuses, sortMessageInfoList, sortMessageIDs, mergeThreadMessageInfos, } from '../shared/message-utils.js'; import { threadHasPermission, threadInChatList, threadIsPending, } from '../shared/thread-utils.js'; import threadWatcher from '../shared/thread-watcher.js'; import { unshimMessageInfos } from '../shared/unshim-utils.js'; import type { Media, Image } from '../types/media-types.js'; import { type RawMessageInfo, type LocalMessageInfo, type MessageStore, type ReplaceMessageOperation, type MessageStoreOperation, type MessageTruncationStatus, type ThreadMessageInfo, type MessageTruncationStatuses, messageTruncationStatus, messageTypes, defaultNumberPerThread, } from '../types/message-types.js'; import type { RawImagesMessageInfo } from '../types/messages/images.js'; import type { RawMediaMessageInfo } from '../types/messages/media.js'; import { type BaseAction } from '../types/redux-types.js'; import { processServerRequestsActionType } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import { type RawThreadInfo, threadPermissions, } from '../types/thread-types.js'; import { updateTypes, type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; import { setNewSessionActionType } from '../utils/action-utils.js'; import { translateClientDBMessageInfosToRawMessageInfos } from '../utils/message-ops-utils.js'; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); // Input must already be ordered! function mapThreadsToMessageIDsFromOrderedMessageInfos( orderedMessageInfos: $ReadOnlyArray, ): { [threadID: string]: string[] } { const threadsToMessageIDs: { [threadID: string]: string[] } = {}; for (const messageInfo of orderedMessageInfos) { const key = messageID(messageInfo); if (!threadsToMessageIDs[messageInfo.threadID]) { threadsToMessageIDs[messageInfo.threadID] = [key]; } else { threadsToMessageIDs[messageInfo.threadID].push(key); } } return threadsToMessageIDs; } function isThreadWatched( threadID: string, threadInfo: ?RawThreadInfo, watchedIDs: $ReadOnlyArray, ) { return ( threadIsPending(threadID) || (threadInfo && threadHasPermission(threadInfo, threadPermissions.VISIBLE) && (threadInChatList(threadInfo) || watchedIDs.includes(threadID))) ); } const newThread = () => ({ messageIDs: [], startReached: false, lastNavigatedTo: 0, lastPruned: Date.now(), }); type FreshMessageStoreResult = { +messageStoreOperations: $ReadOnlyArray, +messageStore: MessageStore, }; function freshMessageStore( messageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, currentAsOf: number, threadInfos: { +[threadID: string]: RawThreadInfo }, ): FreshMessageStoreResult { const unshimmed = unshimMessageInfos(messageInfos); const orderedMessageInfos = sortMessageInfoList(unshimmed); const messages = _keyBy(messageID)(orderedMessageInfos); const messageStoreReplaceOperations = orderedMessageInfos.map( messageInfo => ({ type: 'replace', payload: { id: messageID(messageInfo), messageInfo }, }), ); const messageStoreOperations = [ { type: 'remove_all' }, ...messageStoreReplaceOperations, ]; - const threadsToMessageIDs = mapThreadsToMessageIDsFromOrderedMessageInfos( - orderedMessageInfos, - ); + const threadsToMessageIDs = + mapThreadsToMessageIDsFromOrderedMessageInfos(orderedMessageInfos); const threads = _mapValuesWithKeys( (messageIDs: string[], threadID: string) => ({ ...newThread(), messageIDs, startReached: truncationStatus[threadID] === messageTruncationStatus.EXHAUSTIVE, }), )(threadsToMessageIDs); const watchedIDs = threadWatcher.getWatchedIDs(); for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if ( threads[threadID] || !isThreadWatched(threadID, threadInfo, watchedIDs) ) { continue; } threads[threadID] = newThread(); } return { messageStoreOperations, messageStore: { messages, threads, local: {}, currentAsOf }, }; } type ReassignmentResult = { +messageStoreOperations: MessageStoreOperation[], +messageStore: MessageStore, +reassignedThreadIDs: string[], }; function reassignMessagesToRealizedThreads( messageStore: MessageStore, threadInfos: { +[threadID: string]: RawThreadInfo }, ): ReassignmentResult { - const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector( - threadInfos, - ); + const pendingToRealizedThreadIDs = + pendingToRealizedThreadIDsSelector(threadInfos); const messageStoreOperations: MessageStoreOperation[] = []; const messages = {}; for (const storeMessageID in messageStore.messages) { const message = messageStore.messages[storeMessageID]; const newThreadID = pendingToRealizedThreadIDs.get(message.threadID); messages[storeMessageID] = newThreadID ? { ...message, threadID: newThreadID, time: threadInfos[newThreadID]?.creationTime ?? message.time, } : message; if (!newThreadID) { continue; } const updateMsgOperation: ReplaceMessageOperation = { type: 'replace', payload: { id: storeMessageID, messageInfo: messages[storeMessageID] }, }; messageStoreOperations.push(updateMsgOperation); } const threads = {}; const reassignedThreadIDs = []; for (const threadID in messageStore.threads) { const threadMessageInfo = messageStore.threads[threadID]; const newThreadID = pendingToRealizedThreadIDs.get(threadID); if (!newThreadID) { threads[threadID] = threadMessageInfo; continue; } const realizedThread = messageStore.threads[newThreadID]; if (!realizedThread) { reassignedThreadIDs.push(newThreadID); threads[newThreadID] = threadMessageInfo; continue; } threads[newThreadID] = mergeThreadMessageInfos( threadMessageInfo, realizedThread, messages, ); } return { messageStoreOperations, messageStore: { ...messageStore, threads, messages, }, reassignedThreadIDs, }; } type MergeNewMessagesResult = { +messageStoreOperations: $ReadOnlyArray, +messageStore: MessageStore, }; // oldMessageStore is from the old state // newMessageInfos, truncationStatus come from server function mergeNewMessages( oldMessageStore: MessageStore, newMessageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, threadInfos: { +[threadID: string]: RawThreadInfo }, ): MergeNewMessagesResult { const { messageStoreOperations: updateWithLatestThreadInfosOps, messageStore: messageStoreUpdatedWithLatestThreadInfos, reassignedThreadIDs, } = updateMessageStoreWithLatestThreadInfos(oldMessageStore, threadInfos); const messageStoreAfterUpdateOps = processMessageStoreOperations( oldMessageStore, updateWithLatestThreadInfosOps, ); const updatedMessageStore = { ...messageStoreUpdatedWithLatestThreadInfos, messages: messageStoreAfterUpdateOps.messages, }; const localIDsToServerIDs: Map = new Map(); const watchedThreadIDs = [ ...threadWatcher.getWatchedIDs(), ...reassignedThreadIDs, ]; const unshimmedNewMessages = unshimMessageInfos(newMessageInfos); const unshimmedNewMessagesOfWatchedThreads = unshimmedNewMessages.filter( msg => isThreadWatched( msg.threadID, threadInfos[msg.threadID], watchedThreadIDs, ), ); const orderedNewMessageInfos = _flow( _map((messageInfo: RawMessageInfo) => { const { id: inputID } = messageInfo; invariant(inputID, 'new messageInfos should have serverID'); invariant( !threadIsPending(messageInfo.threadID), 'new messageInfos should have realized thread id', ); const currentMessageInfo = updatedMessageStore.messages[inputID]; if ( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { localID: inputLocalID } = messageInfo; const currentLocalMessageInfo = inputLocalID ? updatedMessageStore.messages[inputLocalID] : null; if (currentMessageInfo && currentMessageInfo.localID) { // If the client already has a RawMessageInfo with this serverID, keep // any localID associated with the existing one. This is because we // use localIDs as React keys and changing React keys leads to loss of // component state. (The conditional below is for Flow) if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawImagesMessageInfo); } } else if (currentLocalMessageInfo && currentLocalMessageInfo.localID) { // If the client has a RawMessageInfo with this localID, but not with // the serverID, that means the message creation succeeded but the // success action never got processed. We set a key in // localIDsToServerIDs here to fix the messageIDs for the rest of the // MessageStore too. (The conditional below is for Flow) invariant(inputLocalID, 'inputLocalID should be set'); localIDsToServerIDs.set(inputLocalID, inputID); if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentLocalMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawImagesMessageInfo); } } else { // If neither the serverID nor the localID from the delivered // RawMessageInfo exists in the client store, then this message is // brand new to us. Ignore any localID provided by the server. // (The conditional below is for Flow) const { localID, ...rest } = messageInfo; if (rest.type === messageTypes.TEXT) { messageInfo = { ...rest }; } else if (rest.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...rest }: RawMediaMessageInfo); } else { messageInfo = ({ ...rest }: RawImagesMessageInfo); } } } else if ( currentMessageInfo && messageInfo.time > currentMessageInfo.time ) { // When thick threads will be introduced it will be possible for two // clients to create the same message (e.g. when they create the same // sidebar at the same time). We're going to use deterministic ids for // messages which should be unique within a thread and we have to find // a way for clients to agree which message to keep. We can't rely on // always choosing incoming messages nor messages from the store, // because a message that is in one user's store, will be send to // another user. One way to deal with it is to always choose a message // which is older, according to its timestamp. We can use this strategy // only for messages that can start a thread, because for other types // it might break the "contiguous" property of message ids (we can // consider selecting younger messages in that case, but for now we use // an invariant). invariant( messageInfo.type === messageTypes.CREATE_SIDEBAR || messageInfo.type === messageTypes.CREATE_THREAD || messageInfo.type === messageTypes.SIDEBAR_SOURCE, `Two different messages of type ${messageInfo.type} with the same ` + 'id found', ); return currentMessageInfo; } return _isEqual(messageInfo)(currentMessageInfo) ? currentMessageInfo : messageInfo; }), sortMessageInfoList, )(unshimmedNewMessagesOfWatchedThreads); const newMessageOps: MessageStoreOperation[] = []; const threadsToMessageIDs = mapThreadsToMessageIDsFromOrderedMessageInfos( orderedNewMessageInfos, ); const oldMessageInfosToCombine = []; const threadsThatNeedMessageIDsResorted = []; const local = {}; const threads = _flow( _mapValuesWithKeys((messageIDs: string[], threadID: string) => { const oldThread = updatedMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (!oldThread) { return { ...newThread(), messageIDs, startReached: truncate === messageTruncationStatus.EXHAUSTIVE, }; } let oldMessageIDsUnchanged = true; const oldMessageIDs = oldThread.messageIDs.map(oldID => { const newID = localIDsToServerIDs.get(oldID); if (newID !== null && newID !== undefined) { oldMessageIDsUnchanged = false; return newID; } return oldID; }); if (truncate === messageTruncationStatus.TRUNCATED) { // If the result set in the payload isn't contiguous with what we have // now, that means we need to dump what we have in the state and replace // it with the result set. We do this to achieve our two goals for the // messageStore: currentness and contiguousness. newMessageOps.push({ type: 'remove_messages_for_threads', payload: { threadIDs: [threadID] }, }); return { messageIDs, startReached: false, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; } const oldNotInNew = _difference(oldMessageIDs)(messageIDs); for (const id of oldNotInNew) { const oldMessageInfo = updatedMessageStore.messages[id]; invariant(oldMessageInfo, `could not find ${id} in messageStore`); oldMessageInfosToCombine.push(oldMessageInfo); const localInfo = updatedMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } const startReached = oldThread.startReached || truncate === messageTruncationStatus.EXHAUSTIVE; if (_difference(messageIDs)(oldMessageIDs).length === 0) { if (startReached === oldThread.startReached && oldMessageIDsUnchanged) { return oldThread; } return { messageIDs: oldMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; } const mergedMessageIDs = [...messageIDs, ...oldNotInNew]; threadsThatNeedMessageIDsResorted.push(threadID); return { messageIDs: mergedMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; }), _pickBy(thread => !!thread), )(threadsToMessageIDs); for (const threadID in updatedMessageStore.threads) { if (threads[threadID]) { continue; } let thread = updatedMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (truncate === messageTruncationStatus.EXHAUSTIVE) { thread = { ...thread, startReached: true, }; } threads[threadID] = thread; for (const id of thread.messageIDs) { const messageInfo = updatedMessageStore.messages[id]; if (messageInfo) { oldMessageInfosToCombine.push(messageInfo); } const localInfo = updatedMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } } const messages = _flow( sortMessageInfoList, _keyBy(messageID), )([...orderedNewMessageInfos, ...oldMessageInfosToCombine]); const newMessages = _keyBy(messageID)(orderedNewMessageInfos); for (const id in newMessages) { newMessageOps.push({ type: 'replace', payload: { id, messageInfo: newMessages[id] }, }); } if (localIDsToServerIDs.size > 0) { newMessageOps.push({ type: 'remove', payload: { ids: [...localIDsToServerIDs.keys()] }, }); } for (const threadID of threadsThatNeedMessageIDsResorted) { threads[threadID].messageIDs = sortMessageIDs(messages)( threads[threadID].messageIDs, ); } const currentAsOf = Math.max( orderedNewMessageInfos.length > 0 ? orderedNewMessageInfos[0].time : 0, updatedMessageStore.currentAsOf, ); const processedMessageStore = processMessageStoreOperations( updatedMessageStore, newMessageOps, ); return { messageStoreOperations: [ ...updateWithLatestThreadInfosOps, ...newMessageOps, ], messageStore: { messages: processedMessageStore.messages, threads, local, currentAsOf, }, }; } type UpdateMessageStoreWithLatestThreadInfosResult = { +messageStoreOperations: MessageStoreOperation[], +messageStore: MessageStore, +reassignedThreadIDs: string[], }; function updateMessageStoreWithLatestThreadInfos( messageStore: MessageStore, threadInfos: { +[id: string]: RawThreadInfo }, ): UpdateMessageStoreWithLatestThreadInfosResult { const messageStoreOperations: MessageStoreOperation[] = []; const { messageStore: reassignedMessageStore, messageStoreOperations: reassignMessagesOps, reassignedThreadIDs, } = reassignMessagesToRealizedThreads(messageStore, threadInfos); messageStoreOperations.push(...reassignMessagesOps); const watchedIDs = [...threadWatcher.getWatchedIDs(), ...reassignedThreadIDs]; const watchedThreadInfos = _pickBy((threadInfo: RawThreadInfo) => isThreadWatched(threadInfo.id, threadInfo, watchedIDs), )(threadInfos); const filteredThreads = _pick(Object.keys(watchedThreadInfos))( reassignedMessageStore.threads, ); const messageIDsToRemove = []; const threadsToRemoveMessagesFrom = []; for (const threadID in reassignedMessageStore.threads) { if (watchedThreadInfos[threadID]) { continue; } threadsToRemoveMessagesFrom.push(threadID); for (const id of reassignedMessageStore.threads[threadID].messageIDs) { messageIDsToRemove.push(id); } } for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if ( isThreadWatched(threadID, threadInfo, watchedIDs) && !filteredThreads[threadID] ) { filteredThreads[threadID] = newThread(); } } messageStoreOperations.push({ type: 'remove_messages_for_threads', payload: { threadIDs: threadsToRemoveMessagesFrom }, }); return { messageStoreOperations, messageStore: { messages: _omit(messageIDsToRemove)(reassignedMessageStore.messages), threads: filteredThreads, local: _omit(messageIDsToRemove)(reassignedMessageStore.local), currentAsOf: reassignedMessageStore.currentAsOf, }, reassignedThreadIDs, }; } function ensureRealizedThreadIDIsUsedWhenPossible( payload: T, threadInfos: { +[id: string]: RawThreadInfo }, ): T { - const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector( - threadInfos, - ); + const pendingToRealizedThreadIDs = + pendingToRealizedThreadIDsSelector(threadInfos); const realizedThreadID = pendingToRealizedThreadIDs.get(payload.threadID); return realizedThreadID ? { ...payload, threadID: realizedThreadID } : payload; } function processMessageStoreOperations( messageStore: MessageStore, messageStoreOperations: $ReadOnlyArray, ): MessageStore { if (messageStoreOperations.length === 0) { return messageStore; } let processedMessages = { ...messageStore.messages }; for (const operation of messageStoreOperations) { if (operation.type === 'replace') { processedMessages[operation.payload.id] = operation.payload.messageInfo; } else if (operation.type === 'remove') { for (const id of operation.payload.ids) { delete processedMessages[id]; } } else if (operation.type === 'remove_messages_for_threads') { for (const msgID in processedMessages) { if ( operation.payload.threadIDs.includes( processedMessages[msgID].threadID, ) ) { delete processedMessages[msgID]; } } } else if (operation.type === 'rekey') { processedMessages[operation.payload.to] = processedMessages[operation.payload.from]; delete processedMessages[operation.payload.from]; } else if (operation.type === 'remove_all') { processedMessages = {}; } } return { ...messageStore, messages: processedMessages }; } type ReduceMessageStoreResult = { +messageStoreOperations: $ReadOnlyArray, +messageStore: MessageStore, }; function reduceMessageStore( messageStore: MessageStore, action: BaseAction, newThreadInfos: { +[id: string]: RawThreadInfo }, ): ReduceMessageStoreResult { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success ) { const messagesResult = action.payload.messagesResult; - const { - messageStoreOperations, - messageStore: freshStore, - } = freshMessageStore( - messagesResult.messageInfos, - messagesResult.truncationStatus, - messagesResult.currentAsOf, - newThreadInfos, - ); + const { messageStoreOperations, messageStore: freshStore } = + freshMessageStore( + messagesResult.messageInfos, + messagesResult.truncationStatus, + messagesResult.currentAsOf, + newThreadInfos, + ); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...freshStore, messages: processedMessageStore.messages }, }; } else if (action.type === incrementalStateSyncActionType) { if ( action.payload.messagesResult.rawMessageInfos.length === 0 && action.payload.updatesResult.newUpdates.length === 0 ) { return { messageStoreOperations: [], messageStore }; } const messagesResult = mergeUpdatesWithMessageInfos( action.payload.messagesResult.rawMessageInfos, action.payload.updatesResult.newUpdates, action.payload.messagesResult.truncationStatuses, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, ); } else if (action.type === processUpdatesActionType) { if (action.payload.updatesResult.newUpdates.length === 0) { return { messageStoreOperations: [], messageStore }; } const messagesResult = mergeUpdatesWithMessageInfos( [], action.payload.updatesResult.newUpdates, ); - const { - messageStoreOperations, - messageStore: newMessageStore, - } = mergeNewMessages( - messageStore, - messagesResult.rawMessageInfos, - messagesResult.truncationStatuses, - newThreadInfos, - ); + const { messageStoreOperations, messageStore: newMessageStore } = + mergeNewMessages( + messageStore, + messagesResult.rawMessageInfos, + messagesResult.truncationStatuses, + newThreadInfos, + ); return { messageStoreOperations, messageStore: { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, currentAsOf: messageStore.currentAsOf, }, }; } else if ( action.type === fullStateSyncActionType || action.type === processMessagesActionType ) { const { messagesResult } = action.payload; return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, ); } else if ( action.type === fetchSingleMostRecentMessagesFromThreadsActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, action.payload.truncationStatuses, newThreadInfos, ); } else if ( action.type === fetchMessagesBeforeCursorActionTypes.success || action.type === fetchMostRecentMessagesActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, { [action.payload.threadID]: action.payload.truncationStatus }, newThreadInfos, ); } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === setNewSessionActionType ) { - const { - messageStoreOperations, - messageStore: filteredMessageStore, - } = updateMessageStoreWithLatestThreadInfos(messageStore, newThreadInfos); + const { messageStoreOperations, messageStore: filteredMessageStore } = + updateMessageStoreWithLatestThreadInfos(messageStore, newThreadInfos); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...filteredMessageStore, messages: processedMessageStore.messages, }, }; } else if (action.type === newThreadActionTypes.success) { const messagesResult = mergeUpdatesWithMessageInfos( action.payload.newMessageInfos, action.payload.updatesResult.newUpdates, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, ); } else if (action.type === sendMessageReportActionTypes.success) { return mergeNewMessages( messageStore, [action.payload.messageInfo], { [action.payload.messageInfo.threadID]: messageTruncationStatus.UNCHANGED, }, newThreadInfos, ); } else if (action.type === registerActionTypes.success) { const truncationStatuses = {}; for (const messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } return mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, ); } else if ( action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success || action.type === createEntryActionTypes.success || action.type === saveEntryActionTypes.success || action.type === restoreEntryActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [action.payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, ); } else if (action.type === deleteEntryActionTypes.success) { const payload = action.payload; if (payload) { return mergeNewMessages( messageStore, payload.newMessageInfos, { [payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, ); } } else if (action.type === joinThreadActionTypes.success) { const messagesResult = mergeUpdatesWithMessageInfos( action.payload.rawMessageInfos, action.payload.updatesResult.newUpdates, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, ); } else if ( action.type === sendTextMessageActionTypes.started || action.type === sendMultimediaMessageActionTypes.started || action.type === sendReactionMessageActionTypes.started ) { const payload = ensureRealizedThreadIDIsUsedWhenPossible( action.payload, newThreadInfos, ); const { localID, threadID } = payload; invariant(localID, `localID should be set on ${action.type}`); const messageStoreOperations = [ { type: 'replace', payload: { id: localID, messageInfo: payload }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const now = Date.now(); const messageIDs = messageStore.threads[threadID]?.messageIDs ?? []; if (messageStore.messages[localID]) { const messages = { ...messageStore.messages, [localID]: payload }; const local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== localID, )(messageStore.local); const thread = messageStore.threads[threadID]; const threads = { ...messageStore.threads, [threadID]: { messageIDs: sortMessageIDs(messages)(messageIDs), startReached: thread?.startReached ?? true, lastNavigatedTo: thread?.lastNavigatedTo ?? now, lastPruned: thread?.lastPruned ?? now, }, }; return { messageStoreOperations, messageStore: { ...messageStore, messages: processedMessageStore.messages, threads, local, }, }; } for (const existingMessageID of messageIDs) { const existingMessageInfo = messageStore.messages[existingMessageID]; if (existingMessageInfo && existingMessageInfo.localID === localID) { return { messageStoreOperations: [], messageStore }; } } const threadState: ThreadMessageInfo = messageStore.threads[threadID] ? { ...messageStore.threads[threadID], messageIDs: [localID, ...messageIDs], } : { messageIDs: [localID], startReached: true, lastNavigatedTo: now, lastPruned: now, }; return { messageStoreOperations, messageStore: { messages: processedMessageStore.messages, threads: { ...messageStore.threads, [threadID]: threadState, }, local: messageStore.local, currentAsOf: messageStore.currentAsOf, }, }; } else if ( action.type === sendTextMessageActionTypes.failed || action.type === sendMultimediaMessageActionTypes.failed ) { const { localID } = action.payload; return { messageStoreOperations: [], messageStore: { messages: messageStore.messages, threads: messageStore.threads, local: { ...messageStore.local, [localID]: { sendFailed: true }, }, currentAsOf: messageStore.currentAsOf, }, }; } else if (action.type === sendReactionMessageActionTypes.failed) { const { localID, threadID } = action.payload; const messageStoreOperations = []; messageStoreOperations.push({ type: 'remove', payload: { ids: [localID] }, }); const newMessageIDs = messageStore.threads[threadID].messageIDs.filter( id => id !== localID, ); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...processedMessageStore, threads: { ...messageStore.threads, [threadID]: { ...messageStore.threads[threadID], messageIDs: newMessageIDs, }, }, }, }; } else if ( action.type === sendTextMessageActionTypes.success || action.type === sendMultimediaMessageActionTypes.success || action.type === sendReactionMessageActionTypes.success ) { const { payload } = action; invariant( !threadIsPending(payload.threadID), 'Successful message action should have realized thread id', ); const replaceMessageKey = (messageKey: string) => messageKey === payload.localID ? payload.serverID : messageKey; let newMessages; const messageStoreOperations = []; if (messageStore.messages[payload.serverID]) { // If somehow the serverID got in there already, we'll just update the // serverID message and scrub the localID one newMessages = _omitBy( (messageInfo: RawMessageInfo) => messageInfo.type === messageTypes.TEXT && !messageInfo.id && messageInfo.localID === payload.localID, )(messageStore.messages); messageStoreOperations.push({ type: 'remove', payload: { ids: [payload.localID] }, }); } else if (messageStore.messages[payload.localID]) { // The normal case, the localID message gets replaced by the serverID one newMessages = _mapKeys(replaceMessageKey)(messageStore.messages); messageStoreOperations.push({ type: 'rekey', payload: { from: payload.localID, to: payload.serverID }, }); } else { // Well this is weird, we probably got deauthorized between when the // action was dispatched and when we ran this reducer... return { messageStoreOperations, messageStore }; } const newMessage = { ...newMessages[payload.serverID], id: payload.serverID, localID: payload.localID, time: payload.time, }; newMessages[payload.serverID] = newMessage; messageStoreOperations.push({ type: 'replace', payload: { id: payload.serverID, messageInfo: newMessage }, }); const threadID = payload.threadID; const newMessageIDs = _flow( _uniq, sortMessageIDs(newMessages), )(messageStore.threads[threadID].messageIDs.map(replaceMessageKey)); const local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== payload.localID, )(messageStore.local); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...messageStore, messages: processedMessageStore.messages, threads: { ...messageStore.threads, [threadID]: { ...messageStore.threads[threadID], messageIDs: newMessageIDs, }, }, local, }, }; } else if (action.type === saveMessagesActionType) { const truncationStatuses = {}; for (const messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.UNCHANGED; } - const { - messageStoreOperations, - messageStore: newMessageStore, - } = mergeNewMessages( - messageStore, - action.payload.rawMessageInfos, - truncationStatuses, - newThreadInfos, - ); + const { messageStoreOperations, messageStore: newMessageStore } = + mergeNewMessages( + messageStore, + action.payload.rawMessageInfos, + truncationStatuses, + newThreadInfos, + ); return { messageStoreOperations, messageStore: { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, // We avoid bumping currentAsOf because notifs may include a contracted // RawMessageInfo, so we want to make sure we still fetch it currentAsOf: messageStore.currentAsOf, }, }; } else if (action.type === messageStorePruneActionType) { const now = Date.now(); const messageIDsToPrune = []; const newThreads = { ...messageStore.threads }; for (const threadID of action.payload.threadIDs) { let thread = newThreads[threadID]; if (!thread) { continue; } thread = { ...thread, lastPruned: now }; const newMessageIDs = [...thread.messageIDs]; const removed = newMessageIDs.splice(defaultNumberPerThread); if (removed.length > 0) { thread = { ...thread, messageIDs: newMessageIDs, startReached: false, }; } for (const id of removed) { messageIDsToPrune.push(id); } newThreads[threadID] = thread; } const messageStoreOperations = [ { type: 'remove', payload: { ids: messageIDsToPrune }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { messages: processedMessageStore.messages, threads: newThreads, local: _omit(messageIDsToPrune)(messageStore.local), currentAsOf: messageStore.currentAsOf, }, }; } else if (action.type === updateMultimediaMessageMediaActionType) { const { messageID: id, currentMediaID, mediaUpdate } = action.payload; const message = messageStore.messages[id]; invariant(message, `message with ID ${id} could not be found`); invariant( message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, `message with ID ${id} is not multimedia`, ); let updatedMessage; let replaced = false; if (message.type === messageTypes.IMAGES) { const media: Image[] = []; for (const singleMedia of message.media) { if (singleMedia.id !== currentMediaID) { media.push(singleMedia); } else { let updatedMedia: Image = { id: mediaUpdate.id ?? singleMedia.id, type: 'photo', uri: mediaUpdate.uri ?? singleMedia.uri, dimensions: mediaUpdate.dimensions ?? singleMedia.dimensions, }; if ( 'localMediaSelection' in singleMedia && !('localMediaSelection' in mediaUpdate) ) { updatedMedia = { ...updatedMedia, localMediaSelection: singleMedia.localMediaSelection, }; } else if (mediaUpdate.localMediaSelection) { updatedMedia = { ...updatedMedia, localMediaSelection: mediaUpdate.localMediaSelection, }; } media.push(updatedMedia); replaced = true; } } updatedMessage = { ...message, media }; } else { const media: Media[] = []; for (const singleMedia of message.media) { if (singleMedia.id !== currentMediaID) { media.push(singleMedia); } else if ( singleMedia.type === 'photo' && mediaUpdate.type === 'photo' ) { media.push({ ...singleMedia, ...mediaUpdate }); replaced = true; } else if ( singleMedia.type === 'video' && mediaUpdate.type === 'video' ) { media.push({ ...singleMedia, ...mediaUpdate }); replaced = true; } } updatedMessage = { ...message, media }; } invariant( replaced, `message ${id} did not contain media with ID ${currentMediaID}`, ); const messageStoreOperations = [ { type: 'replace', payload: { id, messageInfo: updatedMessage, }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...messageStore, messages: processedMessageStore.messages, }, }; } else if (action.type === createLocalMessageActionType) { const messageInfo = ensureRealizedThreadIDIsUsedWhenPossible( action.payload, newThreadInfos, ); const { localID, threadID } = messageInfo; const messageIDs = messageStore.threads[messageInfo.threadID]?.messageIDs ?? []; const now = Date.now(); const threadState: ThreadMessageInfo = messageStore.threads[threadID] ? { ...messageStore.threads[threadID], messageIDs: [localID, ...messageIDs], } : { messageIDs: [localID], startReached: true, lastNavigatedTo: now, lastPruned: now, }; const messageStoreOperations = [ { type: 'replace', payload: { id: localID, messageInfo }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...messageStore, threads: { ...messageStore.threads, [threadID]: threadState, }, messages: processedMessageStore.messages, }, }; } else if (action.type === processServerRequestsActionType) { const { messageStoreOperations, messageStore: messageStoreAfterReassignment, } = reassignMessagesToRealizedThreads(messageStore, newThreadInfos); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...messageStoreAfterReassignment, messages: processedMessageStore.messages, }, }; } else if (action.type === setClientDBStoreActionType) { - const { - messageStoreOperations, - messageStore: updatedMessageStore, - } = updateMessageStoreWithLatestThreadInfos(messageStore, newThreadInfos); + const { messageStoreOperations, messageStore: updatedMessageStore } = + updateMessageStoreWithLatestThreadInfos(messageStore, newThreadInfos); let threads = { ...updatedMessageStore.threads }; let local = { ...updatedMessageStore.local }; // Store message IDs already contained within threads so that we // do not insert duplicates const existingMessageIDs = new Set(); for (const threadID in threads) { threads[threadID].messageIDs.forEach(msgID => { existingMessageIDs.add(msgID); }); } const threadsNeedMsgIDsResorting = new Set(); - const actionPayloadMessages = translateClientDBMessageInfosToRawMessageInfos( - action.payload.messages, - ); + const actionPayloadMessages = + translateClientDBMessageInfosToRawMessageInfos(action.payload.messages); // When starting the app on native, we filter out any local-only multimedia // messages because the relevant context is no longer available const messageIDsToBeRemoved = []; for (const id in actionPayloadMessages) { const message = actionPayloadMessages[id]; const { threadID } = message; const existingThread = threads[threadID] ?? newThread(); if ( (message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA) && !message.id ) { messageIDsToBeRemoved.push(id); threads = { ...threads, [threadID]: { ...existingThread, messageIDs: existingThread.messageIDs.filter( curMessageID => curMessageID !== id, ), }, }; local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== id, )(local); } else if (!existingMessageIDs.has(id)) { threads = { ...threads, [threadID]: { ...existingThread, messageIDs: [...existingThread.messageIDs, id], }, }; threadsNeedMsgIDsResorting.add(threadID); } else if (!threads[threadID]) { threads = { ...threads, [threadID]: existingThread }; } } for (const threadID of threadsNeedMsgIDsResorting) { threads[threadID].messageIDs = sortMessageIDs(actionPayloadMessages)( threads[threadID].messageIDs, ); } const newMessageStore = { ...updatedMessageStore, messages: actionPayloadMessages, threads: threads, local: local, }; if (messageIDsToBeRemoved.length > 0) { messageStoreOperations.push({ type: 'remove', payload: { ids: messageIDsToBeRemoved }, }); } const processedMessageStore = processMessageStoreOperations( newMessageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: processedMessageStore, }; } return { messageStoreOperations: [], messageStore }; } type MergedUpdatesWithMessages = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; function mergeUpdatesWithMessageInfos( messageInfos: $ReadOnlyArray, newUpdates: $ReadOnlyArray, truncationStatuses?: MessageTruncationStatuses, ): MergedUpdatesWithMessages { const messageIDs = new Set(messageInfos.map(messageInfo => messageInfo.id)); const mergedMessageInfos = [...messageInfos]; const mergedTruncationStatuses = { ...truncationStatuses }; for (const updateInfo of newUpdates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } for (const messageInfo of updateInfo.rawMessageInfos) { if (messageIDs.has(messageInfo.id)) { continue; } mergedMessageInfos.push(messageInfo); messageIDs.add(messageInfo.id); } - mergedTruncationStatuses[ - updateInfo.threadInfo.id - ] = combineTruncationStatuses( - updateInfo.truncationStatus, - mergedTruncationStatuses[updateInfo.threadInfo.id], - ); + mergedTruncationStatuses[updateInfo.threadInfo.id] = + combineTruncationStatuses( + updateInfo.truncationStatus, + mergedTruncationStatuses[updateInfo.threadInfo.id], + ); } return { rawMessageInfos: mergedMessageInfos, truncationStatuses: mergedTruncationStatuses, }; } export { freshMessageStore, reduceMessageStore }; diff --git a/lib/reducers/message-reducer.test.js b/lib/reducers/message-reducer.test.js index eadf67cc1..75f119e09 100644 --- a/lib/reducers/message-reducer.test.js +++ b/lib/reducers/message-reducer.test.js @@ -1,321 +1,314 @@ // @flow import invariant from 'invariant'; import { reduceMessageStore } from './message-reducer.js'; import { createPendingThread } from '../shared/thread-utils.js'; import type { MessageStore } from '../types/message-types.js'; import { messageTypes } from '../types/message-types.js'; import { threadTypes } from '../types/thread-types.js'; const messageStoreBeforeMediaUpdate: MessageStore = { messages: { local1: { type: 14, threadID: '91140', creatorID: '91097', time: 1639522317443, media: [ { id: 'localUpload2', - uri: - 'assets-library://asset/asset.HEIC?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=HEIC', + uri: 'assets-library://asset/asset.HEIC?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=HEIC', type: 'photo', dimensions: { height: 3024, width: 4032 }, localMediaSelection: { step: 'photo_library', dimensions: { height: 3024, width: 4032 }, - uri: - 'assets-library://asset/asset.HEIC?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=HEIC', + uri: 'assets-library://asset/asset.HEIC?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=HEIC', filename: 'IMG_0006.HEIC', mediaNativeID: 'CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001', selectTime: 1639522317349, sendTime: 1639522317349, retries: 0, }, }, ], localID: 'local1', }, }, threads: { '91140': { messageIDs: ['local1'], startReached: true, lastNavigatedTo: 1639522314170, lastPruned: 1639522292271, }, }, local: {}, currentAsOf: 1639522292174, }; describe('UPDATE_MULTIMEDIA_MESSAGE_MEDIA', () => { const updateMultiMediaMessageMediaAction = { type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', payload: { messageID: 'local1', currentMediaID: 'localUpload2', mediaUpdate: { id: '91172', type: 'photo', uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde', dimensions: { height: 1440, width: 1920 }, localMediaSelection: undefined, }, }, }; const { messageStore: updatedMessageStore } = reduceMessageStore( messageStoreBeforeMediaUpdate, updateMultiMediaMessageMediaAction, {}, ); test('replace local media with uploaded media', () => { expect( updatedMessageStore.messages[ updateMultiMediaMessageMediaAction.payload.messageID ], ).toStrictEqual({ type: 14, threadID: '91140', creatorID: '91097', time: 1639522317443, media: [ { id: '91172', type: 'photo', uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde', dimensions: { height: 1440, width: 1920 }, }, ], localID: 'local1', }); }); test('localMediaSelection is unset when undefined in update', () => { const msg = updatedMessageStore.messages[ updateMultiMediaMessageMediaAction.payload.messageID ]; expect(msg.type).toEqual(messageTypes.IMAGES); invariant(msg.type === messageTypes.IMAGES, 'message is of type IMAGES'); expect(msg.media[0]).not.toHaveProperty('localMediaSelection'); }); test('localMediaSelection is unchanged when missing in update', () => { const actionWithoutLocalMediaSelectionUpdate = { type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', payload: { messageID: 'local1', currentMediaID: 'localUpload2', mediaUpdate: { id: '91172', type: 'photo', uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde', dimensions: { height: 1440, width: 1920 }, }, }, }; - const { - messageStore: storeWithoutLocalMediaSelectionUpdate, - } = reduceMessageStore( - messageStoreBeforeMediaUpdate, - actionWithoutLocalMediaSelectionUpdate, - {}, - ); + const { messageStore: storeWithoutLocalMediaSelectionUpdate } = + reduceMessageStore( + messageStoreBeforeMediaUpdate, + actionWithoutLocalMediaSelectionUpdate, + {}, + ); const prevMsg = messageStoreBeforeMediaUpdate.messages[ actionWithoutLocalMediaSelectionUpdate.payload.messageID ]; const updatedMsg = storeWithoutLocalMediaSelectionUpdate.messages[ actionWithoutLocalMediaSelectionUpdate.payload.messageID ]; expect(updatedMsg.type).toEqual(messageTypes.IMAGES); expect(prevMsg.type).toEqual(messageTypes.IMAGES); invariant( updatedMsg.type === messageTypes.IMAGES && prevMsg.type === messageTypes.IMAGES, 'message is of type IMAGES', ); expect(updatedMsg.media[0].localMediaSelection).toStrictEqual( prevMsg.media[0].localMediaSelection, ); }); test('localMediaSelection is updated when included in update', () => { const updateMultiMediaMessageMediaActionWithReplacement = { type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', payload: { messageID: 'local1', currentMediaID: 'localUpload2', mediaUpdate: { id: '91172', type: 'photo', uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde', dimensions: { height: 1440, width: 1920 }, localMediaSelection: { step: 'photo_library', dimensions: { height: 10, width: 10 }, uri: 'assets-library://asset/new/path', filename: 'NEWNAME.PNG', mediaNativeID: 'CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001', selectTime: 1639522317349, sendTime: 1639522317349, retries: 1, }, }, }, }; - const { - messageStore: updatedMessageStoreWithReplacement, - } = reduceMessageStore( - messageStoreBeforeMediaUpdate, - updateMultiMediaMessageMediaActionWithReplacement, - {}, - ); + const { messageStore: updatedMessageStoreWithReplacement } = + reduceMessageStore( + messageStoreBeforeMediaUpdate, + updateMultiMediaMessageMediaActionWithReplacement, + {}, + ); const updatedMsg = updatedMessageStoreWithReplacement.messages[ updateMultiMediaMessageMediaActionWithReplacement.payload.messageID ]; expect(updatedMsg.type).toEqual(messageTypes.IMAGES); invariant( updatedMsg.type === messageTypes.IMAGES, 'message is of type IMAGES', ); expect(updatedMsg.media[0].localMediaSelection).toStrictEqual({ step: 'photo_library', dimensions: { height: 10, width: 10 }, uri: 'assets-library://asset/new/path', filename: 'NEWNAME.PNG', mediaNativeID: 'CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001', selectTime: 1639522317349, sendTime: 1639522317349, retries: 1, }); }); }); describe('SET_MESSAGE_STORE_MESSAGES', () => { const clientDBMessages = [ { id: '103502', local_id: null, thread: '88471', user: '83809', type: '14', future_type: null, content: '[103501]', time: '1658168455316', media_infos: [ { id: '103501', uri: 'http://localhost/comm/upload/103501/425db25471f3acd5', type: 'photo', extras: '{"dimensions":{"width":1920,"height":1440},"loop":false}', }, ], }, { id: 'local10', local_id: 'local10', thread: '88471', user: '83809', type: '14', future_type: null, content: '[null]', time: '1658172650495', media_infos: [ { id: 'localUpload0', - uri: - 'assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic', + uri: 'assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic', type: 'photo', extras: '{"dimensions":{"height":3024,"width":4032},"loop":false,"local_media_selection":{"step":"photo_library","dimensions":{"height":3024,"width":4032},"uri":"assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic","filename":"IMG_0006.HEIC","mediaNativeID":"CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001","selectTime":1658172650370,"sendTime":1658172650370,"retries":0}}', }, ], }, { id: 'local11', local_id: 'local11', thread: '88471', user: '83809', type: '14', future_type: null, content: '[null,null]', time: '1658172656976', media_infos: [ { id: 'localUpload2', - uri: - 'assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic', + uri: 'assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic', type: 'photo', extras: '{"dimensions":{"height":3024,"width":4032},"loop":false,"local_media_selection":{"step":"photo_library","dimensions":{"height":3024,"width":4032},"uri":"assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic","filename":"IMG_0006.HEIC","mediaNativeID":"CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001","selectTime":1658172656826,"sendTime":1658172656826,"retries":0}}', }, { id: 'localUpload4', - uri: - 'assets-library://asset/asset.jpg?id=ED7AC36B-A150-4C38-BB8C-B6D696F4F2ED&ext=jpg', + uri: 'assets-library://asset/asset.jpg?id=ED7AC36B-A150-4C38-BB8C-B6D696F4F2ED&ext=jpg', type: 'photo', extras: '{"dimensions":{"height":2002,"width":3000},"loop":false,"local_media_selection":{"step":"photo_library","dimensions":{"height":2002,"width":3000},"uri":"assets-library://asset/asset.jpg?id=ED7AC36B-A150-4C38-BB8C-B6D696F4F2ED&ext=jpg","filename":"IMG_0005.JPG","mediaNativeID":"ED7AC36B-A150-4C38-BB8C-B6D696F4F2ED/L0/001","selectTime":1658172656826,"sendTime":1658172656826,"retries":0}}', }, ], }, ]; const threads = { '88471': { messageIDs: ['local11', 'local10', '103502'], startReached: false, lastNavigatedTo: 1658172614602, lastPruned: 1658169913623, }, }; const { messageStore: updatedMessageStore } = reduceMessageStore( { messages: {}, threads, local: {}, currentAsOf: 1234567890123, }, { type: 'SET_CLIENT_DB_STORE', payload: { currentUserID: '', drafts: [], threadStore: { threadInfos: {}, }, messages: clientDBMessages, }, }, { [88471]: createPendingThread({ viewerID: '', threadType: threadTypes.LOCAL, }), }, ); test('removes local media when constructing messageStore.messages', () => { expect(updatedMessageStore.messages).toHaveProperty('103502'); expect(updatedMessageStore.messages).not.toHaveProperty('local10'); expect(updatedMessageStore.messages).not.toHaveProperty('local11'); }); test('removes local media when constructing messageStore.threads', () => { expect(updatedMessageStore).toBeDefined(); expect(updatedMessageStore.threads).toBeDefined(); expect(updatedMessageStore.threads['88471']).toBeDefined(); expect(updatedMessageStore.threads['88471'].messageIDs).toBeDefined(); expect(updatedMessageStore.threads['88471'].messageIDs).toEqual(['103502']); }); }); diff --git a/lib/selectors/account-selectors.js b/lib/selectors/account-selectors.js index 79fe15c8c..bcd39f181 100644 --- a/lib/selectors/account-selectors.js +++ b/lib/selectors/account-selectors.js @@ -1,49 +1,52 @@ // @flow import { createSelector } from 'reselect'; import { currentCalendarQuery } from './nav-selectors.js'; import type { LogInExtraInfo } from '../types/account-types.js'; import { isDeviceType } from '../types/device-types.js'; import type { CalendarQuery } from '../types/entry-types.js'; import type { AppState } from '../types/redux-types.js'; import type { PreRequestUserState } from '../types/session-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; import { getConfig } from '../utils/config.js'; const logInExtraInfoSelector: ( state: AppState, ) => (calendarActive: boolean) => LogInExtraInfo = createSelector( (state: AppState) => state.deviceToken, currentCalendarQuery, ( deviceToken: ?string, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => { let deviceTokenUpdateRequest = null; const platform = getConfig().platformDetails.platform; if (deviceToken && isDeviceType(platform)) { deviceTokenUpdateRequest = { deviceToken }; } // Return a function since we depend on the time of evaluation return (calendarActive: boolean): LogInExtraInfo => ({ calendarQuery: calendarQuery(calendarActive), deviceTokenUpdateRequest, }); }, ); -const preRequestUserStateSelector: ( - state: AppState, -) => PreRequestUserState = createSelector( - (state: AppState) => state.currentUserInfo, - (state: AppState) => state.cookie, - (state: AppState) => state.sessionID, - (currentUserInfo: ?CurrentUserInfo, cookie: ?string, sessionID: ?string) => ({ - currentUserInfo, - cookie, - sessionID, - }), -); +const preRequestUserStateSelector: (state: AppState) => PreRequestUserState = + createSelector( + (state: AppState) => state.currentUserInfo, + (state: AppState) => state.cookie, + (state: AppState) => state.sessionID, + ( + currentUserInfo: ?CurrentUserInfo, + cookie: ?string, + sessionID: ?string, + ) => ({ + currentUserInfo, + cookie, + sessionID, + }), + ); export { logInExtraInfoSelector, preRequestUserStateSelector }; diff --git a/lib/selectors/calendar-filter-selectors.js b/lib/selectors/calendar-filter-selectors.js index d621465d9..04b42b671 100644 --- a/lib/selectors/calendar-filter-selectors.js +++ b/lib/selectors/calendar-filter-selectors.js @@ -1,104 +1,103 @@ // @flow import { createSelector } from 'reselect'; import { type CalendarFilter, calendarThreadFilterTypes, type CalendarThreadFilterType, } from '../types/filter-types.js'; import type { BaseAppState } from '../types/redux-types.js'; function filteredThreadIDs( calendarFilters: $ReadOnlyArray, ): ?Set { let threadIDs = []; let threadListFilterExists = false; for (const filter of calendarFilters) { if (filter.type === calendarThreadFilterTypes.THREAD_LIST) { threadListFilterExists = true; threadIDs = [...threadIDs, ...filter.threadIDs]; } } if (!threadListFilterExists) { return null; } return new Set(threadIDs); } const filteredThreadIDsSelector: ( state: BaseAppState<*>, ) => ?$ReadOnlySet = createSelector( (state: BaseAppState<*>) => state.calendarFilters, filteredThreadIDs, ); function filterFilters( calendarFilters: $ReadOnlyArray, filterTypeToExclude: CalendarThreadFilterType, ): $ReadOnlyArray { const filteredFilters = []; for (const filter of calendarFilters) { if (filter.type !== filterTypeToExclude) { filteredFilters.push(filter); } } return filteredFilters; } function nonThreadCalendarFilters( calendarFilters: $ReadOnlyArray, ): $ReadOnlyArray { return filterFilters(calendarFilters, calendarThreadFilterTypes.THREAD_LIST); } const nonThreadCalendarFiltersSelector: ( state: BaseAppState<*>, ) => $ReadOnlyArray = createSelector( (state: BaseAppState<*>) => state.calendarFilters, nonThreadCalendarFilters, ); function nonExcludeDeletedCalendarFilters( calendarFilters: $ReadOnlyArray, ): $ReadOnlyArray { return filterFilters(calendarFilters, calendarThreadFilterTypes.NOT_DELETED); } const nonExcludeDeletedCalendarFiltersSelector: ( state: BaseAppState<*>, ) => $ReadOnlyArray = createSelector( (state: BaseAppState<*>) => state.calendarFilters, nonExcludeDeletedCalendarFilters, ); function filterExists( calendarFilters: $ReadOnlyArray, filterType: CalendarThreadFilterType, ): boolean { for (const filter of calendarFilters) { if (filter.type === filterType) { return true; } } return false; } -const includeDeletedSelector: ( - state: BaseAppState<*>, -) => boolean = createSelector( - (state: BaseAppState<*>) => state.calendarFilters, - (calendarFilters: $ReadOnlyArray) => - !filterExists(calendarFilters, calendarThreadFilterTypes.NOT_DELETED), -); +const includeDeletedSelector: (state: BaseAppState<*>) => boolean = + createSelector( + (state: BaseAppState<*>) => state.calendarFilters, + (calendarFilters: $ReadOnlyArray) => + !filterExists(calendarFilters, calendarThreadFilterTypes.NOT_DELETED), + ); export { filteredThreadIDs, filteredThreadIDsSelector, nonThreadCalendarFilters, nonThreadCalendarFiltersSelector, nonExcludeDeletedCalendarFilters, nonExcludeDeletedCalendarFiltersSelector, filterExists, includeDeletedSelector, }; diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index c59028429..d98773098 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,614 +1,610 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _map from 'lodash/fp/map.js'; import _orderBy from 'lodash/fp/orderBy.js'; import * as React from 'react'; import { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { threadInfoSelector, sidebarInfoSelector, threadInfoFromSourceMessageIDSelector, } from './thread-selectors.js'; import { messageKey, robotextForMessageInfo, createMessageInfo, getMostRecentNonLocalMessageID, sortMessageInfoList, } from '../shared/message-utils.js'; import { threadIsPending, threadIsTopLevel, threadInChatList, } from '../shared/thread-utils.js'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, messageTypes, isComposableMessageType, } from '../types/message-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { type ThreadInfo, type RawThreadInfo, type SidebarInfo, maxReadSidebars, maxUnreadSidebars, threadTypes, } from '../types/thread-types.js'; import type { UserInfo, AccountUserInfo, RelativeUserInfo, } from '../types/user-types.js'; import { threeDays } from '../utils/date-utils.js'; import type { EntityText } from '../utils/entity-text.js'; import memoize2 from '../utils/memoize.js'; export type SidebarItem = | { ...SidebarInfo, +type: 'sidebar', } | { +type: 'seeMore', +unread: boolean, } | { +type: 'spacer' }; export type ChatThreadItem = { +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray, +pendingPersonalThreadUserInfo?: UserInfo, }; -const messageInfoSelector: ( - state: BaseAppState<*>, -) => { +[id: string]: ?MessageInfo } = createObjectSelector( +const messageInfoSelector: (state: BaseAppState<*>) => { + +[id: string]: ?MessageInfo, +} = createObjectSelector( (state: BaseAppState<*>) => state.messageStore.messages, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoSelector, createMessageInfo, ); function isEmptyMediaMessage(messageInfo: MessageInfo): boolean { return ( (messageInfo.type === messageTypes.MULTIMEDIA || messageInfo.type === messageTypes.IMAGES) && messageInfo.media.length === 0 ); } function getMostRecentMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { +[id: string]: ?MessageInfo }, ): ?MessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (const messageID of thread.messageIDs) { const messageInfo = messages[messageID]; if (!messageInfo || isEmptyMediaMessage(messageInfo)) { continue; } return messageInfo; } return null; } function getLastUpdatedTime( threadInfo: ThreadInfo, mostRecentMessageInfo: ?MessageInfo, ): number { return mostRecentMessageInfo ? mostRecentMessageInfo.time : threadInfo.creationTime; } function createChatThreadItem( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { +[id: string]: ?MessageInfo }, sidebarInfos: ?$ReadOnlyArray, ): ChatThreadItem { const mostRecentMessageInfo = getMostRecentMessageInfo( threadInfo, messageStore, messages, ); const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( threadInfo.id, messageStore, ); const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo); const sidebars = sidebarInfos ?? []; const allSidebarItems = sidebars.map(sidebarInfo => ({ type: 'sidebar', ...sidebarInfo, })); const lastUpdatedTimeIncludingSidebars = allSidebarItems.length > 0 ? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime) : lastUpdatedTime; const numUnreadSidebars = allSidebarItems.filter( sidebar => sidebar.threadInfo.currentUser.unread, ).length; let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars; const threeDaysAgo = Date.now() - threeDays; const sidebarItems = []; for (const sidebar of allSidebarItems) { if (sidebarItems.length >= maxUnreadSidebars) { break; } else if (sidebar.threadInfo.currentUser.unread) { sidebarItems.push(sidebar); } else if ( sidebar.lastUpdatedTime > threeDaysAgo && numReadSidebarsToShow > 0 ) { sidebarItems.push(sidebar); numReadSidebarsToShow--; } } const numReadButRecentSidebars = allSidebarItems.filter( sidebar => !sidebar.threadInfo.currentUser.unread && sidebar.lastUpdatedTime > threeDaysAgo, ).length; if ( sidebarItems.length < numUnreadSidebars + numReadButRecentSidebars || (sidebarItems.length < allSidebarItems.length && sidebarItems.length > 0) ) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, }); } if (sidebarItems.length !== 0) { sidebarItems.push({ type: 'spacer', }); } return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo, mostRecentNonLocalMessage, lastUpdatedTime, lastUpdatedTimeIncludingSidebars, sidebars: sidebarItems, }; } -const chatListData: ( - state: BaseAppState<*>, -) => $ReadOnlyArray = createSelector( - threadInfoSelector, - (state: BaseAppState<*>) => state.messageStore, - messageInfoSelector, - sidebarInfoSelector, - ( - threadInfos: { +[id: string]: ThreadInfo }, - messageStore: MessageStore, - messageInfos: { +[id: string]: ?MessageInfo }, - sidebarInfos: { +[id: string]: $ReadOnlyArray }, - ): $ReadOnlyArray => - getChatThreadItems( - threadInfos, - messageStore, - messageInfos, - sidebarInfos, - threadIsTopLevel, - ), -); +const chatListData: (state: BaseAppState<*>) => $ReadOnlyArray = + createSelector( + threadInfoSelector, + (state: BaseAppState<*>) => state.messageStore, + messageInfoSelector, + sidebarInfoSelector, + ( + threadInfos: { +[id: string]: ThreadInfo }, + messageStore: MessageStore, + messageInfos: { +[id: string]: ?MessageInfo }, + sidebarInfos: { +[id: string]: $ReadOnlyArray }, + ): $ReadOnlyArray => + getChatThreadItems( + threadInfos, + messageStore, + messageInfos, + sidebarInfos, + threadIsTopLevel, + ), + ); function useFlattenedChatListData(): $ReadOnlyArray { return useFilteredChatListData(threadInChatList); } function useFilteredChatListData( filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean, ): $ReadOnlyArray { const threadInfos = useSelector(threadInfoSelector); const messageInfos = useSelector(messageInfoSelector); const sidebarInfos = useSelector(sidebarInfoSelector); const messageStore = useSelector(state => state.messageStore); return React.useMemo( () => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, filterFunction, ), [messageInfos, messageStore, sidebarInfos, filterFunction, threadInfos], ); } function getChatThreadItems( threadInfos: { +[id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, sidebarInfos: { +[id: string]: $ReadOnlyArray }, filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean, ): $ReadOnlyArray { return _flow( _filter(filterFunction), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, sidebarInfos[threadInfo.id], ), ), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos); } export type RobotextChatMessageInfoItem = { +itemType: 'message', +messageInfoType: 'robotext', +messageInfo: RobotextMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, +robotext: EntityText, +threadCreatedFromMessage: ?ThreadInfo, +reactions: $ReadOnlyMap, }; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | { +itemType: 'message', +messageInfoType: 'composable', +messageInfo: ComposableMessageInfo, +localMessageInfo: ?LocalMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, +threadCreatedFromMessage: ?ThreadInfo, +reactions: $ReadOnlyMap, }; export type ChatMessageItem = { itemType: 'loader' } | ChatMessageInfoItem; export type MessageReactionInfo = { +viewerReacted: boolean, +users: $ReadOnlyArray, }; type TargetMessageReactions = Map>; const msInFiveMinutes = 5 * 60 * 1000; function createChatMessageItems( threadID: string, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, threadInfos: { +[id: string]: ThreadInfo }, threadInfoFromSourceMessageID: { +[id: string]: ThreadInfo }, additionalMessages: $ReadOnlyArray, viewerID: string, ): ChatMessageItem[] { const thread = messageStore.threads[threadID]; const threadMessageInfos = (thread?.messageIDs ?? []) .map((messageID: string) => messageInfos[messageID]) .filter(Boolean); const messages = additionalMessages.length > 0 ? sortMessageInfoList([...threadMessageInfos, ...additionalMessages]) : threadMessageInfos; const targetMessageReactionsMap = new Map(); // We need to iterate backwards to put the order of messages in chronological // order, starting with the oldest. This avoids the scenario where the most // recent message with the remove_reaction action may try to remove a user // that hasn't been added to the messageReactionUsersInfoMap, causing it // to be skipped. for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.REACTION) { continue; } if (!targetMessageReactionsMap.has(messageInfo.targetMessageID)) { const reactsMap: TargetMessageReactions = new Map(); targetMessageReactionsMap.set(messageInfo.targetMessageID, reactsMap); } const messageReactsMap = targetMessageReactionsMap.get( messageInfo.targetMessageID, ); invariant(messageReactsMap, 'messageReactsInfo should be set'); if (!messageReactsMap.has(messageInfo.reaction)) { const usersInfoMap = new Map(); messageReactsMap.set(messageInfo.reaction, usersInfoMap); } const messageReactionUsersInfoMap = messageReactsMap.get( messageInfo.reaction, ); invariant( messageReactionUsersInfoMap, 'messageReactionUsersInfoMap should be set', ); if (messageInfo.action === 'add_reaction') { messageReactionUsersInfoMap.set( messageInfo.creator.id, messageInfo.creator, ); } else { messageReactionUsersInfoMap.delete(messageInfo.creator.id); } } const chatMessageItems = []; let lastMessageInfo = null; for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type === messageTypes.REACTION) { continue; } const originalMessageInfo = messageInfo.type === messageTypes.SIDEBAR_SOURCE ? messageInfo.sourceMessage : messageInfo; if (isEmptyMediaMessage(originalMessageInfo)) { continue; } let startsConversation = true; let startsCluster = true; if ( lastMessageInfo && lastMessageInfo.time + msInFiveMinutes > originalMessageInfo.time ) { startsConversation = false; if ( isComposableMessageType(lastMessageInfo.type) && isComposableMessageType(originalMessageInfo.type) && lastMessageInfo.creator.id === originalMessageInfo.creator.id ) { startsCluster = false; } } if (startsCluster && chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } const threadCreatedFromMessage = messageInfo.id && threadInfos[threadID]?.type !== threadTypes.SIDEBAR ? threadInfoFromSourceMessageID[messageInfo.id] : undefined; - const renderedReactions: $ReadOnlyMap< - string, - MessageReactionInfo, - > = (() => { - const result = new Map(); - - let messageReactsMap; - if (originalMessageInfo.id) { - messageReactsMap = targetMessageReactionsMap.get( - originalMessageInfo.id, - ); - } - - if (!messageReactsMap) { - return result; - } + const renderedReactions: $ReadOnlyMap = + (() => { + const result = new Map(); - for (const reaction of messageReactsMap.keys()) { - const reactionUsersInfoMap = messageReactsMap.get(reaction); - invariant(reactionUsersInfoMap, 'reactionUsersInfoMap should be set'); + let messageReactsMap; + if (originalMessageInfo.id) { + messageReactsMap = targetMessageReactionsMap.get( + originalMessageInfo.id, + ); + } - if (reactionUsersInfoMap.size === 0) { - continue; + if (!messageReactsMap) { + return result; } - const reactionUserInfos = [...reactionUsersInfoMap.values()]; + for (const reaction of messageReactsMap.keys()) { + const reactionUsersInfoMap = messageReactsMap.get(reaction); + invariant(reactionUsersInfoMap, 'reactionUsersInfoMap should be set'); - const messageReactionInfo = { - users: reactionUserInfos, - viewerReacted: reactionUsersInfoMap.has(viewerID), - }; + if (reactionUsersInfoMap.size === 0) { + continue; + } - result.set(reaction, messageReactionInfo); - } + const reactionUserInfos = [...reactionUsersInfoMap.values()]; - return result; - })(); + const messageReactionInfo = { + users: reactionUserInfos, + viewerReacted: reactionUsersInfoMap.has(viewerID), + }; + + result.set(reaction, messageReactionInfo); + } + + return result; + })(); if (isComposableMessageType(originalMessageInfo.type)) { // We use these invariants instead of just checking the messageInfo.type // directly in the conditional above so that isComposableMessageType can // be the source of truth invariant( originalMessageInfo.type === messageTypes.TEXT || originalMessageInfo.type === messageTypes.IMAGES || originalMessageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const localMessageInfo = messageStore.local[messageKey(originalMessageInfo)]; chatMessageItems.push({ itemType: 'message', messageInfoType: 'composable', messageInfo: originalMessageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, threadCreatedFromMessage, reactions: renderedReactions, }); } else { invariant( originalMessageInfo.type !== messageTypes.TEXT && originalMessageInfo.type !== messageTypes.IMAGES && originalMessageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( originalMessageInfo, threadInfos[threadID], ); chatMessageItems.push({ itemType: 'message', messageInfoType: 'robotext', messageInfo: originalMessageInfo, startsConversation, startsCluster, endsCluster: false, threadCreatedFromMessage, robotext, reactions: renderedReactions, }); } lastMessageInfo = originalMessageInfo; } if (chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } chatMessageItems.reverse(); const hideSpinner = thread ? thread.startReached : threadIsPending(threadID); if (hideSpinner) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } const baseMessageListData = ( threadID: ?string, additionalMessages: $ReadOnlyArray, ) => createSelector( (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, threadInfoSelector, threadInfoFromSourceMessageIDSelector, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, ( messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, threadInfos: { +[id: string]: ThreadInfo }, threadInfoFromSourceMessageID: { +[id: string]: ThreadInfo }, viewerID: ?string, ): ?(ChatMessageItem[]) => { if (!threadID || !viewerID) { return null; } return createChatMessageItems( threadID, messageStore, messageInfos, threadInfos, threadInfoFromSourceMessageID, additionalMessages, viewerID, ); }, ); type MessageListData = ?(ChatMessageItem[]); const messageListData: ( threadID: ?string, additionalMessages: $ReadOnlyArray, -) => (state: BaseAppState<*>) => MessageListData = memoize2( - baseMessageListData, -); +) => (state: BaseAppState<*>) => MessageListData = + memoize2(baseMessageListData); type UseMessageListDataArgs = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, +threadInfo: ?ThreadInfo, }; function useMessageListData({ searching, userInfoInputArray, threadInfo, }: UseMessageListDataArgs): MessageListData { const pendingSidebarSourceMessageInfo = useSelector(state => { const sourceMessageID = threadInfo?.sourceMessageID; if ( !threadInfo || threadInfo.type !== threadTypes.SIDEBAR || !sourceMessageID ) { return null; } const thread = state.messageStore.threads[threadInfo.id]; const messageInfos = messageInfoSelector(state); const shouldSourceBeAdded = !thread || (thread.startReached && thread.messageIDs.every( id => messageInfos[id]?.type !== messageTypes.SIDEBAR_SOURCE, )); return shouldSourceBeAdded ? messageInfos[sourceMessageID] : null; }); invariant( !pendingSidebarSourceMessageInfo || pendingSidebarSourceMessageInfo.type !== messageTypes.SIDEBAR_SOURCE, 'sidebars can not be created from sidebar_source message', ); const additionalMessages = React.useMemo( () => pendingSidebarSourceMessageInfo ? [pendingSidebarSourceMessageInfo] : [], [pendingSidebarSourceMessageInfo], ); const boundMessageListData = useSelector( messageListData(threadInfo?.id, additionalMessages), ); return React.useMemo(() => { if (searching && userInfoInputArray.length === 0) { return []; } return boundMessageListData; }, [searching, userInfoInputArray.length, boundMessageListData]); } export { messageInfoSelector, createChatThreadItem, chatListData, createChatMessageItems, messageListData, useFlattenedChatListData, useFilteredChatListData, useMessageListData, }; diff --git a/lib/selectors/loading-selectors.js b/lib/selectors/loading-selectors.js index 4dc1ca7b6..c13150b70 100644 --- a/lib/selectors/loading-selectors.js +++ b/lib/selectors/loading-selectors.js @@ -1,97 +1,98 @@ // @flow import invariant from 'invariant'; import _includes from 'lodash/fp/includes.js'; import _isEmpty from 'lodash/fp/isEmpty.js'; import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import { registerFetchKey } from '../reducers/loading-reducer.js'; import type { LoadingStatus } from '../types/loading-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import type { ActionTypes } from '../utils/action-utils.js'; import { values } from '../utils/objects.js'; function loadingStatusFromInfo(loadingStatusInfo: { [idx: number]: LoadingStatus, }): LoadingStatus { if (_isEmpty(loadingStatusInfo)) { return 'inactive'; } else if (_includes('error')(loadingStatusInfo)) { return 'error'; } else { return 'loading'; } } // This is the key used to store the Promise state in Redux function getTrackingKey( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ) { if (overrideKey) { return overrideKey; } const startMatch = actionTypes.started.match(/(.*)_STARTED/); invariant( startMatch && startMatch[1], 'actionTypes.started should always end with _STARTED', ); return startMatch[1]; } const baseCreateLoadingStatusSelector = ( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ): ((state: BaseAppState<*>) => LoadingStatus) => { // This makes sure that reduceLoadingStatuses tracks this action registerFetchKey(actionTypes); const trackingKey = getTrackingKey(actionTypes, overrideKey); return createSelector( (state: BaseAppState<*>) => state.loadingStatuses[trackingKey], (loadingStatusInfo: { [idx: number]: LoadingStatus }) => loadingStatusFromInfo(loadingStatusInfo), ); }; const createLoadingStatusSelector: ( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ) => (state: BaseAppState<*>) => LoadingStatus = _memoize( baseCreateLoadingStatusSelector, getTrackingKey, ); function combineLoadingStatuses( ...loadingStatuses: $ReadOnlyArray ): LoadingStatus { let errorExists = false; for (const loadingStatus of loadingStatuses) { if (loadingStatus === 'loading') { return 'loading'; } if (loadingStatus === 'error') { errorExists = true; } } return errorExists ? 'error' : 'inactive'; } -const globalLoadingStatusSelector: ( - state: BaseAppState<*>, -) => LoadingStatus = createSelector( - (state: BaseAppState<*>) => state.loadingStatuses, - (loadingStatusInfos: { - [key: string]: { [idx: number]: LoadingStatus }, - }): LoadingStatus => { - const loadingStatusInfoValues = values(loadingStatusInfos); - const loadingStatuses = loadingStatusInfoValues.map(loadingStatusFromInfo); - return combineLoadingStatuses(...loadingStatuses); - }, -); +const globalLoadingStatusSelector: (state: BaseAppState<*>) => LoadingStatus = + createSelector( + (state: BaseAppState<*>) => state.loadingStatuses, + (loadingStatusInfos: { + [key: string]: { [idx: number]: LoadingStatus }, + }): LoadingStatus => { + const loadingStatusInfoValues = values(loadingStatusInfos); + const loadingStatuses = loadingStatusInfoValues.map( + loadingStatusFromInfo, + ); + return combineLoadingStatuses(...loadingStatuses); + }, + ); export { createLoadingStatusSelector, globalLoadingStatusSelector, combineLoadingStatuses, }; diff --git a/lib/selectors/nav-selectors.js b/lib/selectors/nav-selectors.js index 3239eca6d..72ef81180 100644 --- a/lib/selectors/nav-selectors.js +++ b/lib/selectors/nav-selectors.js @@ -1,160 +1,161 @@ // @flow import * as React from 'react'; import { createSelector } from 'reselect'; import { useENSNames } from '../hooks/ens-cache.js'; import SearchIndex from '../shared/search-index.js'; import { memberHasAdminPowers } from '../shared/thread-utils.js'; import type { Platform } from '../types/device-types.js'; import { type CalendarQuery, defaultCalendarQuery, } from '../types/entry-types.js'; import type { CalendarFilter } from '../types/filter-types.js'; import type { BaseNavInfo } from '../types/nav-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js'; import { getConfig } from '../utils/config.js'; import { values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; function timeUntilCalendarRangeExpiration( lastUserInteractionCalendar: number, ): ?number { const inactivityLimit = getConfig().calendarRangeInactivityLimit; if (inactivityLimit === null || inactivityLimit === undefined) { return null; } return lastUserInteractionCalendar + inactivityLimit - Date.now(); } function calendarRangeExpired(lastUserInteractionCalendar: number): boolean { const timeUntil = timeUntilCalendarRangeExpiration( lastUserInteractionCalendar, ); if (timeUntil === null || timeUntil === undefined) { return false; } return timeUntil <= 0; } const currentCalendarQuery: ( state: BaseAppState<*>, ) => (calendarActive: boolean) => CalendarQuery = createSelector( (state: BaseAppState<*>) => state.entryStore.lastUserInteractionCalendar, (state: BaseAppState<*>) => state.navInfo, (state: BaseAppState<*>) => state.calendarFilters, ( lastUserInteractionCalendar: number, navInfo: BaseNavInfo, calendarFilters: $ReadOnlyArray, ) => { // Return a function since we depend on the time of evaluation return (calendarActive: boolean, platform: ?Platform): CalendarQuery => { if (calendarActive) { return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; } if (calendarRangeExpired(lastUserInteractionCalendar)) { return defaultCalendarQuery(platform); } return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; }; }, ); function useThreadSearchIndex( threadInfos: $ReadOnlyArray, ): SearchIndex { const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nonViewerMembers = React.useMemo(() => { const allMembersOfAllThreads = new Map(); for (const threadInfo of threadInfos) { for (const member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } if (member.id === viewerID) { continue; } if (!allMembersOfAllThreads.has(member.id)) { const userInfo = userInfos[member.id]; if (userInfo?.username) { allMembersOfAllThreads.set(member.id, userInfo); } } } } return [...allMembersOfAllThreads.values()]; }, [threadInfos, userInfos, viewerID]); const nonViewerMembersWithENSNames = useENSNames(nonViewerMembers); const memberMap = React.useMemo(() => { const result = new Map(); for (const userInfo of nonViewerMembersWithENSNames) { result.set(userInfo.id, userInfo); } return result; }, [nonViewerMembersWithENSNames]); return React.useMemo(() => { const searchIndex = new SearchIndex(); for (const threadInfo of threadInfos) { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (const member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } if (member.id === viewerID) { continue; } const userInfo = userInfos[member.id]; const rawUsername = userInfo?.username; if (rawUsername) { searchTextArray.push(rawUsername); } const resolvedUserInfo = memberMap.get(member.id); const username = resolvedUserInfo?.username; if (username && username !== rawUsername) { searchTextArray.push(username); } } searchIndex.addEntry(threadInfo.id, searchTextArray.join(' ')); } return searchIndex; }, [threadInfos, viewerID, userInfos, memberMap]); } function useGlobalThreadSearchIndex(): SearchIndex { const threadInfos = useSelector(state => state.threadStore.threadInfos); - const threadInfosArray = React.useMemo(() => values(threadInfos), [ - threadInfos, - ]); + const threadInfosArray = React.useMemo( + () => values(threadInfos), + [threadInfos], + ); return useThreadSearchIndex(threadInfosArray); } export { timeUntilCalendarRangeExpiration, currentCalendarQuery, useThreadSearchIndex, useGlobalThreadSearchIndex, }; diff --git a/lib/selectors/relationship-selectors.js b/lib/selectors/relationship-selectors.js index 0e1562afa..8daf284fb 100644 --- a/lib/selectors/relationship-selectors.js +++ b/lib/selectors/relationship-selectors.js @@ -1,50 +1,49 @@ // @flow import _orderBy from 'lodash/fp/orderBy.js'; import { createSelector } from 'reselect'; import type { BaseAppState } from '../types/redux-types.js'; import { userRelationshipStatus, type UserRelationships, } from '../types/relationship-types.js'; import type { UserInfos } from '../types/user-types.js'; -const userRelationshipsSelector: ( - state: BaseAppState<*>, -) => UserRelationships = createSelector( - (state: BaseAppState<*>) => state.userStore.userInfos, - (userInfos: UserInfos) => { - const unorderedFriendRequests = []; - const unorderedFriends = []; - const blocked = []; - for (const userID in userInfos) { - const userInfo = userInfos[userID]; - const { id, username, relationshipStatus } = userInfo; - if (!username) { - continue; +const userRelationshipsSelector: (state: BaseAppState<*>) => UserRelationships = + createSelector( + (state: BaseAppState<*>) => state.userStore.userInfos, + (userInfos: UserInfos) => { + const unorderedFriendRequests = []; + const unorderedFriends = []; + const blocked = []; + for (const userID in userInfos) { + const userInfo = userInfos[userID]; + const { id, username, relationshipStatus } = userInfo; + if (!username) { + continue; + } + if ( + relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED || + relationshipStatus === userRelationshipStatus.REQUEST_SENT + ) { + unorderedFriendRequests.push({ id, username, relationshipStatus }); + } else if (relationshipStatus === userRelationshipStatus.FRIEND) { + unorderedFriends.push({ id, username, relationshipStatus }); + } else if ( + relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || + relationshipStatus === userRelationshipStatus.BOTH_BLOCKED + ) { + blocked.push({ id, username, relationshipStatus }); + } } - if ( - relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED || - relationshipStatus === userRelationshipStatus.REQUEST_SENT - ) { - unorderedFriendRequests.push({ id, username, relationshipStatus }); - } else if (relationshipStatus === userRelationshipStatus.FRIEND) { - unorderedFriends.push({ id, username, relationshipStatus }); - } else if ( - relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || - relationshipStatus === userRelationshipStatus.BOTH_BLOCKED - ) { - blocked.push({ id, username, relationshipStatus }); - } - } - const friendRequests = _orderBy('relationshipStatus')('desc')( - unorderedFriendRequests, - ); - const friends = friendRequests.concat(unorderedFriends); + const friendRequests = _orderBy('relationshipStatus')('desc')( + unorderedFriendRequests, + ); + const friends = friendRequests.concat(unorderedFriends); - return { friends, blocked }; - }, -); + return { friends, blocked }; + }, + ); export { userRelationshipsSelector }; diff --git a/lib/selectors/server-calls.js b/lib/selectors/server-calls.js index af0912040..b4e405b36 100644 --- a/lib/selectors/server-calls.js +++ b/lib/selectors/server-calls.js @@ -1,40 +1,39 @@ // @flow import { createSelector } from 'reselect'; import type { AppState } from '../types/redux-types.js'; import { type ConnectionStatus } from '../types/socket-types.js'; import { type CurrentUserInfo } from '../types/user-types.js'; export type ServerCallState = { +cookie: ?string, +urlPrefix: string, +sessionID: ?string, +currentUserInfo: ?CurrentUserInfo, +connectionStatus: ConnectionStatus, }; -const serverCallStateSelector: ( - state: AppState, -) => ServerCallState = createSelector( - (state: AppState) => state.cookie, - (state: AppState) => state.urlPrefix, - (state: AppState) => state.sessionID, - (state: AppState) => state.currentUserInfo, - (state: AppState) => state.connection.status, - ( - cookie: ?string, - urlPrefix: string, - sessionID: ?string, - currentUserInfo: ?CurrentUserInfo, - connectionStatus: ConnectionStatus, - ) => ({ - cookie, - urlPrefix, - sessionID, - currentUserInfo, - connectionStatus, - }), -); +const serverCallStateSelector: (state: AppState) => ServerCallState = + createSelector( + (state: AppState) => state.cookie, + (state: AppState) => state.urlPrefix, + (state: AppState) => state.sessionID, + (state: AppState) => state.currentUserInfo, + (state: AppState) => state.connection.status, + ( + cookie: ?string, + urlPrefix: string, + sessionID: ?string, + currentUserInfo: ?CurrentUserInfo, + connectionStatus: ConnectionStatus, + ) => ({ + cookie, + urlPrefix, + sessionID, + currentUserInfo, + connectionStatus, + }), + ); export { serverCallStateSelector }; diff --git a/lib/selectors/socket-selectors.js b/lib/selectors/socket-selectors.js index 9a168b998..0b5c61c1c 100644 --- a/lib/selectors/socket-selectors.js +++ b/lib/selectors/socket-selectors.js @@ -1,183 +1,185 @@ // @flow import { createSelector } from 'reselect'; import { currentCalendarQuery } from './nav-selectors.js'; import { serverEntryInfo, serverEntryInfosObject, filterRawEntryInfosByCalendarQuery, } from '../shared/entry-utils.js'; import threadWatcher from '../shared/thread-watcher.js'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types.js'; import type { AppState } from '../types/redux-types.js'; import type { ClientReportCreationRequest } from '../types/report-types.js'; import { serverRequestTypes, type ClientServerRequest, type ClientClientResponse, } from '../types/request-types.js'; import type { SessionState } from '../types/session-types.js'; import type { OneTimeKeyGenerator } from '../types/socket-types.js'; import type { RawThreadInfo } from '../types/thread-types.js'; import type { CurrentUserInfo, UserInfos } from '../types/user-types.js'; import { getConfig } from '../utils/config.js'; import { minimumOneTimeKeysRequired } from '../utils/crypto-utils.js'; import { values, hash } from '../utils/objects.js'; const queuedReports: ( state: AppState, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.reportStore.queuedReports, ( mainQueuedReports: $ReadOnlyArray, ): $ReadOnlyArray => mainQueuedReports, ); const getClientResponsesSelector: ( state: AppState, ) => ( calendarActive: boolean, oneTimeKeyGenerator: ?OneTimeKeyGenerator, serverRequests: $ReadOnlyArray, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.entryStore.entryInfos, (state: AppState) => state.userStore.userInfos, (state: AppState) => state.currentUserInfo, currentCalendarQuery, ( - threadInfos: { +[id: string]: RawThreadInfo }, - entryInfos: { +[id: string]: RawEntryInfo }, - userInfos: UserInfos, - currentUserInfo: ?CurrentUserInfo, - calendarQuery: (calendarActive: boolean) => CalendarQuery, - ) => ( - calendarActive: boolean, - oneTimeKeyGenerator: ?OneTimeKeyGenerator, - serverRequests: $ReadOnlyArray, - ): $ReadOnlyArray => { - const clientResponses = []; - const serverRequestedPlatformDetails = serverRequests.some( - request => request.type === serverRequestTypes.PLATFORM_DETAILS, - ); - for (const serverRequest of serverRequests) { - if ( - serverRequest.type === serverRequestTypes.PLATFORM && - !serverRequestedPlatformDetails - ) { - clientResponses.push({ - type: serverRequestTypes.PLATFORM, - platform: getConfig().platformDetails.platform, - }); - } else if (serverRequest.type === serverRequestTypes.PLATFORM_DETAILS) { - clientResponses.push({ - type: serverRequestTypes.PLATFORM_DETAILS, - platformDetails: getConfig().platformDetails, - }); - } else if (serverRequest.type === serverRequestTypes.CHECK_STATE) { - const filteredEntryInfos = filterRawEntryInfosByCalendarQuery( - serverEntryInfosObject(values(entryInfos)), - calendarQuery(calendarActive), - ); - const hashResults = {}; - for (const key in serverRequest.hashesToCheck) { - const expectedHashValue = serverRequest.hashesToCheck[key]; - let hashValue; - if (key === 'threadInfos') { - hashValue = hash(threadInfos); - } else if (key === 'entryInfos') { - hashValue = hash(filteredEntryInfos); - } else if (key === 'userInfos') { - hashValue = hash(userInfos); - } else if (key === 'currentUserInfo') { - hashValue = hash(currentUserInfo); - } else if (key.startsWith('threadInfo|')) { - const [, threadID] = key.split('|'); - hashValue = hash(threadInfos[threadID]); - } else if (key.startsWith('entryInfo|')) { - const [, entryID] = key.split('|'); - let rawEntryInfo = filteredEntryInfos[entryID]; - if (rawEntryInfo) { - rawEntryInfo = serverEntryInfo(rawEntryInfo); + threadInfos: { +[id: string]: RawThreadInfo }, + entryInfos: { +[id: string]: RawEntryInfo }, + userInfos: UserInfos, + currentUserInfo: ?CurrentUserInfo, + calendarQuery: (calendarActive: boolean) => CalendarQuery, + ) => + ( + calendarActive: boolean, + oneTimeKeyGenerator: ?OneTimeKeyGenerator, + serverRequests: $ReadOnlyArray, + ): $ReadOnlyArray => { + const clientResponses = []; + const serverRequestedPlatformDetails = serverRequests.some( + request => request.type === serverRequestTypes.PLATFORM_DETAILS, + ); + for (const serverRequest of serverRequests) { + if ( + serverRequest.type === serverRequestTypes.PLATFORM && + !serverRequestedPlatformDetails + ) { + clientResponses.push({ + type: serverRequestTypes.PLATFORM, + platform: getConfig().platformDetails.platform, + }); + } else if (serverRequest.type === serverRequestTypes.PLATFORM_DETAILS) { + clientResponses.push({ + type: serverRequestTypes.PLATFORM_DETAILS, + platformDetails: getConfig().platformDetails, + }); + } else if (serverRequest.type === serverRequestTypes.CHECK_STATE) { + const filteredEntryInfos = filterRawEntryInfosByCalendarQuery( + serverEntryInfosObject(values(entryInfos)), + calendarQuery(calendarActive), + ); + const hashResults = {}; + for (const key in serverRequest.hashesToCheck) { + const expectedHashValue = serverRequest.hashesToCheck[key]; + let hashValue; + if (key === 'threadInfos') { + hashValue = hash(threadInfos); + } else if (key === 'entryInfos') { + hashValue = hash(filteredEntryInfos); + } else if (key === 'userInfos') { + hashValue = hash(userInfos); + } else if (key === 'currentUserInfo') { + hashValue = hash(currentUserInfo); + } else if (key.startsWith('threadInfo|')) { + const [, threadID] = key.split('|'); + hashValue = hash(threadInfos[threadID]); + } else if (key.startsWith('entryInfo|')) { + const [, entryID] = key.split('|'); + let rawEntryInfo = filteredEntryInfos[entryID]; + if (rawEntryInfo) { + rawEntryInfo = serverEntryInfo(rawEntryInfo); + } + hashValue = hash(rawEntryInfo); + } else if (key.startsWith('userInfo|')) { + const [, userID] = key.split('|'); + hashValue = hash(userInfos[userID]); + } else { + continue; } - hashValue = hash(rawEntryInfo); - } else if (key.startsWith('userInfo|')) { - const [, userID] = key.split('|'); - hashValue = hash(userInfos[userID]); - } else { - continue; + hashResults[key] = expectedHashValue === hashValue; } - hashResults[key] = expectedHashValue === hashValue; - } - const { failUnmentioned } = serverRequest; - if (failUnmentioned && failUnmentioned.threadInfos) { - for (const threadID in threadInfos) { - const key = `threadInfo|${threadID}`; - const hashResult = hashResults[key]; - if (hashResult === null || hashResult === undefined) { - hashResults[key] = false; + const { failUnmentioned } = serverRequest; + if (failUnmentioned && failUnmentioned.threadInfos) { + for (const threadID in threadInfos) { + const key = `threadInfo|${threadID}`; + const hashResult = hashResults[key]; + if (hashResult === null || hashResult === undefined) { + hashResults[key] = false; + } } } - } - if (failUnmentioned && failUnmentioned.entryInfos) { - for (const entryID in filteredEntryInfos) { - const key = `entryInfo|${entryID}`; - const hashResult = hashResults[key]; - if (hashResult === null || hashResult === undefined) { - hashResults[key] = false; + if (failUnmentioned && failUnmentioned.entryInfos) { + for (const entryID in filteredEntryInfos) { + const key = `entryInfo|${entryID}`; + const hashResult = hashResults[key]; + if (hashResult === null || hashResult === undefined) { + hashResults[key] = false; + } } } - } - if (failUnmentioned && failUnmentioned.userInfos) { - for (const userID in userInfos) { - const key = `userInfo|${userID}`; - const hashResult = hashResults[key]; - if (hashResult === null || hashResult === undefined) { - hashResults[key] = false; + if (failUnmentioned && failUnmentioned.userInfos) { + for (const userID in userInfos) { + const key = `userInfo|${userID}`; + const hashResult = hashResults[key]; + if (hashResult === null || hashResult === undefined) { + hashResults[key] = false; + } } } - } - clientResponses.push({ - type: serverRequestTypes.CHECK_STATE, - hashResults, - }); - } else if ( - serverRequest.type === serverRequestTypes.MORE_ONE_TIME_KEYS && - oneTimeKeyGenerator - ) { - const keys: string[] = []; - for (let i = 0; i < minimumOneTimeKeysRequired; ++i) { - keys.push(oneTimeKeyGenerator(i)); + clientResponses.push({ + type: serverRequestTypes.CHECK_STATE, + hashResults, + }); + } else if ( + serverRequest.type === serverRequestTypes.MORE_ONE_TIME_KEYS && + oneTimeKeyGenerator + ) { + const keys: string[] = []; + for (let i = 0; i < minimumOneTimeKeysRequired; ++i) { + keys.push(oneTimeKeyGenerator(i)); + } + clientResponses.push({ + type: serverRequestTypes.MORE_ONE_TIME_KEYS, + keys, + }); } - clientResponses.push({ - type: serverRequestTypes.MORE_ONE_TIME_KEYS, - keys, - }); } - } - return clientResponses; - }, + return clientResponses; + }, ); const sessionStateFuncSelector: ( state: AppState, ) => (calendarActive: boolean) => SessionState = createSelector( (state: AppState) => state.messageStore.currentAsOf, (state: AppState) => state.updatesCurrentAsOf, currentCalendarQuery, ( - messagesCurrentAsOf: number, - updatesCurrentAsOf: number, - calendarQuery: (calendarActive: boolean) => CalendarQuery, - ) => (calendarActive: boolean): SessionState => ({ - calendarQuery: calendarQuery(calendarActive), - messagesCurrentAsOf, - updatesCurrentAsOf, - watchedIDs: threadWatcher.getWatchedIDs(), - }), + messagesCurrentAsOf: number, + updatesCurrentAsOf: number, + calendarQuery: (calendarActive: boolean) => CalendarQuery, + ) => + (calendarActive: boolean): SessionState => ({ + calendarQuery: calendarQuery(calendarActive), + messagesCurrentAsOf, + updatesCurrentAsOf, + watchedIDs: threadWatcher.getWatchedIDs(), + }), ); export { queuedReports, getClientResponsesSelector, sessionStateFuncSelector }; diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js index 34d3a7fe5..57febec7b 100644 --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -1,433 +1,431 @@ // @flow import _compact from 'lodash/fp/compact.js'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _map from 'lodash/fp/map.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _orderBy from 'lodash/fp/orderBy.js'; import _some from 'lodash/fp/some.js'; import _sortBy from 'lodash/fp/sortBy.js'; import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { filteredThreadIDsSelector, includeDeletedSelector, } from './calendar-filter-selectors.js'; import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors.js'; import { createEntryInfo } from '../shared/entry-utils.js'; import { getMostRecentNonLocalMessageID } from '../shared/message-utils.js'; import { threadInHomeChatList, threadInBackgroundChatList, threadInFilterList, threadInfoFromRawThreadInfo, threadHasPermission, threadInChatList, threadHasAdminRole, roleIsAdminRole, threadIsPending, getPendingThreadID, } from '../shared/thread-utils.js'; import type { EntryInfo } from '../types/entry-types.js'; import type { MessageStore, RawMessageInfo } from '../types/message-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { type ThreadInfo, type RawThreadInfo, type RelativeMemberInfo, threadPermissions, threadTypes, type SidebarInfo, threadTypeIsCommunityRoot, } from '../types/thread-types.js'; import { dateString, dateFromString } from '../utils/date-utils.js'; import { values } from '../utils/objects.js'; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); -type ThreadInfoSelectorType = ( - state: BaseAppState<*>, -) => { +[id: string]: ThreadInfo }; +type ThreadInfoSelectorType = (state: BaseAppState<*>) => { + +[id: string]: ThreadInfo, +}; const threadInfoSelector: ThreadInfoSelectorType = createObjectSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoFromRawThreadInfo, ); const communityThreadSelector: ( state: BaseAppState<*>, ) => $ReadOnlyArray = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadTypeIsCommunityRoot(threadInfo.type)) { continue; } result.push(threadInfo); } return result; }, ); const canBeOnScreenThreadInfos: ( state: BaseAppState<*>, ) => $ReadOnlyArray = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadInFilterList(threadInfo)) { continue; } result.push(threadInfo); } return result; }, ); const onScreenThreadInfos: ( state: BaseAppState<*>, ) => $ReadOnlyArray = createSelector( filteredThreadIDsSelector, canBeOnScreenThreadInfos, ( inputThreadIDs: ?$ReadOnlySet, threadInfos: $ReadOnlyArray, ) => { const threadIDs = inputThreadIDs; if (!threadIDs) { return threadInfos; } return threadInfos.filter(threadInfo => threadIDs.has(threadInfo.id)); }, ); const onScreenEntryEditableThreadInfos: ( state: BaseAppState<*>, ) => $ReadOnlyArray = createSelector( onScreenThreadInfos, (threadInfos: $ReadOnlyArray) => threadInfos.filter(threadInfo => threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES), ), ); -const entryInfoSelector: ( - state: BaseAppState<*>, -) => { +[id: string]: EntryInfo } = createObjectSelector( +const entryInfoSelector: (state: BaseAppState<*>) => { + +[id: string]: EntryInfo, +} = createObjectSelector( (state: BaseAppState<*>) => state.entryStore.entryInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, createEntryInfo, ); // "current" means within startDate/endDate range, not deleted, and in // onScreenThreadInfos -const currentDaysToEntries: ( - state: BaseAppState<*>, -) => { +[dayString: string]: EntryInfo[] } = createSelector( +const currentDaysToEntries: (state: BaseAppState<*>) => { + +[dayString: string]: EntryInfo[], +} = createSelector( entryInfoSelector, (state: BaseAppState<*>) => state.entryStore.daysToEntries, (state: BaseAppState<*>) => state.navInfo.startDate, (state: BaseAppState<*>) => state.navInfo.endDate, onScreenThreadInfos, includeDeletedSelector, ( entryInfos: { +[id: string]: EntryInfo }, daysToEntries: { +[day: string]: string[] }, startDateString: string, endDateString: string, onScreen: $ReadOnlyArray, includeDeleted: boolean, ) => { const allDaysWithinRange = {}, startDate = dateFromString(startDateString), endDate = dateFromString(endDateString); for ( const curDate = startDate; curDate <= endDate; curDate.setDate(curDate.getDate() + 1) ) { allDaysWithinRange[dateString(curDate)] = []; } return _mapValuesWithKeys((_: string[], dayString: string) => _flow( _map((entryID: string) => entryInfos[entryID]), _compact, _filter( (entryInfo: EntryInfo) => (includeDeleted || !entryInfo.deleted) && _some(['id', entryInfo.threadID])(onScreen), ), _sortBy('creationTime'), )(daysToEntries[dayString] ? daysToEntries[dayString] : []), )(allDaysWithinRange); }, ); -const childThreadInfos: ( - state: BaseAppState<*>, -) => { +[id: string]: $ReadOnlyArray } = createSelector( +const childThreadInfos: (state: BaseAppState<*>) => { + +[id: string]: $ReadOnlyArray, +} = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; const parentThreadID = threadInfo.parentThreadID; if (parentThreadID === null || parentThreadID === undefined) { continue; } if (result[parentThreadID] === undefined) { result[parentThreadID] = []; } result[parentThreadID].push(threadInfo); } return result; }, ); function getMostRecentRawMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?RawMessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (const messageID of thread.messageIDs) { return messageStore.messages[messageID]; } return null; } -const sidebarInfoSelector: ( - state: BaseAppState<*>, -) => { +[id: string]: $ReadOnlyArray } = createObjectSelector( +const sidebarInfoSelector: (state: BaseAppState<*>) => { + +[id: string]: $ReadOnlyArray, +} = createObjectSelector( childThreadInfos, (state: BaseAppState<*>) => state.messageStore, (childThreads: $ReadOnlyArray, messageStore: MessageStore) => { const sidebarInfos = []; for (const childThreadInfo of childThreads) { if ( !threadInChatList(childThreadInfo) || childThreadInfo.type !== threadTypes.SIDEBAR ) { continue; } const mostRecentRawMessageInfo = getMostRecentRawMessageInfo( childThreadInfo, messageStore, ); const lastUpdatedTime = mostRecentRawMessageInfo?.time ?? childThreadInfo.creationTime; const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( childThreadInfo.id, messageStore, ); sidebarInfos.push({ threadInfo: childThreadInfo, lastUpdatedTime, mostRecentNonLocalMessage, }); } return _orderBy('lastUpdatedTime')('desc')(sidebarInfos); }, ); const unreadCount: (state: BaseAppState<*>) => number = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (threadInfos: { +[id: string]: RawThreadInfo }): number => values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); -const unreadBackgroundCount: ( - state: BaseAppState<*>, -) => number = createSelector( - (state: BaseAppState<*>) => state.threadStore.threadInfos, - (threadInfos: { +[id: string]: RawThreadInfo }): number => - values(threadInfos).filter( - threadInfo => - threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread, - ).length, -); +const unreadBackgroundCount: (state: BaseAppState<*>) => number = + createSelector( + (state: BaseAppState<*>) => state.threadStore.threadInfos, + (threadInfos: { +[id: string]: RawThreadInfo }): number => + values(threadInfos).filter( + threadInfo => + threadInBackgroundChatList(threadInfo) && + threadInfo.currentUser.unread, + ).length, + ); const baseAncestorThreadInfos = (threadID: string) => createSelector( (state: BaseAppState<*>) => threadInfoSelector(state), (threadInfos: { +[id: string]: ThreadInfo, }): $ReadOnlyArray => { const pathComponents: ThreadInfo[] = []; let node: ?ThreadInfo = threadInfos[threadID]; while (node) { pathComponents.push(node); node = node.parentThreadID ? threadInfos[node.parentThreadID] : null; } pathComponents.reverse(); return pathComponents; }, ); const ancestorThreadInfos: ( threadID: string, ) => (state: BaseAppState<*>) => $ReadOnlyArray = _memoize( baseAncestorThreadInfos, ); const baseOtherUsersButNoOtherAdmins = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos[threadID], relativeMemberInfoSelectorForMembersOfThread(threadID), ( threadInfo: ?RawThreadInfo, members: $ReadOnlyArray, ): boolean => { if (!threadInfo) { return false; } if (!threadHasAdminRole(threadInfo)) { return false; } let otherUsersExist = false; let otherAdminsExist = false; for (const member of members) { const role = member.role; if (role === undefined || role === null || member.isViewer) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo?.roles[role])) { otherAdminsExist = true; break; } } return otherUsersExist && !otherAdminsExist; }, ); const otherUsersButNoOtherAdmins: ( threadID: string, ) => (state: BaseAppState<*>) => boolean = _memoize( baseOtherUsersButNoOtherAdmins, ); function mostRecentlyReadThread( messageStore: MessageStore, threadInfos: { +[id: string]: RawThreadInfo }, ): ?string { let mostRecent = null; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.currentUser.unread) { continue; } const threadMessageInfo = messageStore.threads[threadID]; if (!threadMessageInfo) { continue; } const mostRecentMessageTime = threadMessageInfo.messageIDs.length === 0 ? threadInfo.creationTime : messageStore.messages[threadMessageInfo.messageIDs[0]].time; if (mostRecent && mostRecent.time >= mostRecentMessageTime) { continue; } const topLevelThreadID = threadInfo.type === threadTypes.SIDEBAR ? threadInfo.parentThreadID : threadID; mostRecent = { threadID: topLevelThreadID, time: mostRecentMessageTime }; } return mostRecent ? mostRecent.threadID : null; } -const mostRecentlyReadThreadSelector: ( - state: BaseAppState<*>, -) => ?string = createSelector( - (state: BaseAppState<*>) => state.messageStore, - (state: BaseAppState<*>) => state.threadStore.threadInfos, - mostRecentlyReadThread, -); +const mostRecentlyReadThreadSelector: (state: BaseAppState<*>) => ?string = + createSelector( + (state: BaseAppState<*>) => state.messageStore, + (state: BaseAppState<*>) => state.threadStore.threadInfos, + mostRecentlyReadThread, + ); -const threadInfoFromSourceMessageIDSelector: ( - state: BaseAppState<*>, -) => { +[id: string]: ThreadInfo } = createSelector( +const threadInfoFromSourceMessageIDSelector: (state: BaseAppState<*>) => { + +[id: string]: ThreadInfo, +} = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, threadInfoSelector, ( rawThreadInfos: { +[id: string]: RawThreadInfo }, threadInfos: { +[id: string]: ThreadInfo }, ) => { - const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector( - rawThreadInfos, - ); + const pendingToRealizedThreadIDs = + pendingToRealizedThreadIDsSelector(rawThreadInfos); const result = {}; for (const realizedID of pendingToRealizedThreadIDs.values()) { const threadInfo = threadInfos[realizedID]; if (threadInfo && threadInfo.sourceMessageID) { result[threadInfo.sourceMessageID] = threadInfo; } } return result; }, ); const pendingToRealizedThreadIDsSelector: (rawThreadInfos: { +[id: string]: RawThreadInfo, }) => $ReadOnlyMap = createSelector( (rawThreadInfos: { +[id: string]: RawThreadInfo }) => rawThreadInfos, (rawThreadInfos: { +[id: string]: RawThreadInfo }) => { const result = new Map(); for (const threadID in rawThreadInfos) { const rawThreadInfo = rawThreadInfos[threadID]; if (threadIsPending(threadID)) { continue; } const actualMemberIDs = rawThreadInfo.members .filter(member => member.role) .map(member => member.id); const pendingThreadID = getPendingThreadID( rawThreadInfo.type, actualMemberIDs, rawThreadInfo.sourceMessageID, ); const existingResult = result.get(pendingThreadID); if ( !existingResult || rawThreadInfos[existingResult].creationTime > rawThreadInfo.creationTime ) { result.set(pendingThreadID, threadID); } } return result; }, ); export { ancestorThreadInfos, threadInfoSelector, communityThreadSelector, onScreenThreadInfos, onScreenEntryEditableThreadInfos, entryInfoSelector, currentDaysToEntries, childThreadInfos, unreadCount, unreadBackgroundCount, otherUsersButNoOtherAdmins, mostRecentlyReadThread, mostRecentlyReadThreadSelector, sidebarInfoSelector, threadInfoFromSourceMessageIDSelector, pendingToRealizedThreadIDsSelector, }; diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js index bded924a5..6e7cda8ad 100644 --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -1,218 +1,217 @@ // @flow import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import SearchIndex from '../shared/search-index.js'; import { getSingleOtherUser, memberHasAdminPowers, } from '../shared/thread-utils.js'; import type { BaseAppState } from '../types/redux-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { type RawThreadInfo, type RelativeMemberInfo, threadTypes, } from '../types/thread-types.js'; import type { UserInfos, RelativeUserInfo, AccountUserInfo, } from '../types/user-types.js'; // Used for specific message payloads that include an array of user IDs, ie. // array of initial users, array of added users function userIDsToRelativeUserInfos( userIDs: $ReadOnlyArray, viewerID: ?string, userInfos: UserInfos, ): RelativeUserInfo[] { const relativeUserInfos = []; for (const userID of userIDs) { const username = userInfos[userID] ? userInfos[userID].username : null; if (userID === viewerID) { relativeUserInfos.unshift({ id: userID, username, isViewer: true, }); } else { relativeUserInfos.push({ id: userID, username, isViewer: false, }); } } return relativeUserInfos; } const emptyArray = []; function getRelativeMemberInfos( threadInfo: ?RawThreadInfo, currentUserID: ?string, userInfos: UserInfos, ): $ReadOnlyArray { const relativeMemberInfos = []; if (!threadInfo) { return relativeMemberInfos; } const memberInfos = threadInfo.members; for (const memberInfo of memberInfos) { const isParentAdmin = memberHasAdminPowers(memberInfo); if (!memberInfo.role && !isParentAdmin) { continue; } const username = userInfos[memberInfo.id] ? userInfos[memberInfo.id].username : null; if (memberInfo.id === currentUserID) { relativeMemberInfos.unshift({ id: memberInfo.id, role: memberInfo.role, permissions: memberInfo.permissions, username, isViewer: true, isSender: memberInfo.isSender, }); } else { relativeMemberInfos.push({ id: memberInfo.id, role: memberInfo.role, permissions: memberInfo.permissions, username, isViewer: false, isSender: memberInfo.isSender, }); } } return relativeMemberInfos; } // Includes current user at the start const baseRelativeMemberInfoSelectorForMembersOfThread = ( threadID: ?string, ) => { if (!threadID) { return () => emptyArray; } return createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos[threadID], (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, getRelativeMemberInfos, ); }; const relativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<*>) => $ReadOnlyArray = _memoize( baseRelativeMemberInfoSelectorForMembersOfThread, ); -const userInfoSelectorForPotentialMembers: ( - state: BaseAppState<*>, -) => { [id: string]: AccountUserInfo } = createSelector( +const userInfoSelectorForPotentialMembers: (state: BaseAppState<*>) => { + [id: string]: AccountUserInfo, +} = createSelector( (state: BaseAppState<*>) => state.userStore.userInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, ( userInfos: UserInfos, currentUserID: ?string, ): { [id: string]: AccountUserInfo } => { const availableUsers: { [id: string]: AccountUserInfo } = {}; for (const id in userInfos) { const { username, relationshipStatus } = userInfos[id]; if (id === currentUserID || !username) { continue; } if ( relationshipStatus !== userRelationshipStatus.BLOCKED_VIEWER && relationshipStatus !== userRelationshipStatus.BOTH_BLOCKED ) { availableUsers[id] = { id, username, relationshipStatus }; } } return availableUsers; }, ); function searchIndexFromUserInfos(userInfos: { [id: string]: AccountUserInfo, }) { const searchIndex = new SearchIndex(); for (const id in userInfos) { searchIndex.addEntry(id, userInfos[id].username); } return searchIndex; } const userSearchIndexForPotentialMembers: ( state: BaseAppState<*>, ) => SearchIndex = createSelector( userInfoSelectorForPotentialMembers, searchIndexFromUserInfos, ); const isLoggedIn = (state: BaseAppState<*>): boolean => !!( state.currentUserInfo && !state.currentUserInfo.anonymous && state.dataLoaded ); -const userStoreSearchIndex: ( - state: BaseAppState<*>, -) => SearchIndex = createSelector( - (state: BaseAppState<*>) => state.userStore.userInfos, - (userInfos: UserInfos) => { - const searchIndex = new SearchIndex(); - for (const id in userInfos) { - const { username } = userInfos[id]; - if (!username) { - continue; +const userStoreSearchIndex: (state: BaseAppState<*>) => SearchIndex = + createSelector( + (state: BaseAppState<*>) => state.userStore.userInfos, + (userInfos: UserInfos) => { + const searchIndex = new SearchIndex(); + for (const id in userInfos) { + const { username } = userInfos[id]; + if (!username) { + continue; + } + searchIndex.addEntry(id, username); } - searchIndex.addEntry(id, username); - } - return searchIndex; - }, -); + return searchIndex; + }, + ); const usersWithPersonalThreadSelector: ( state: BaseAppState<*>, ) => $ReadOnlySet = createSelector( state => state.currentUserInfo && state.currentUserInfo.id, state => state.threadStore.threadInfos, (viewerID, threadInfos) => { const personalThreadMembers = new Set(); for (const threadID in threadInfos) { const thread = threadInfos[threadID]; if ( thread.type !== threadTypes.PERSONAL || !thread.members.find(member => member.id === viewerID) ) { continue; } const otherMemberID = getSingleOtherUser(thread, viewerID); if (otherMemberID) { personalThreadMembers.add(otherMemberID); } } return personalThreadMembers; }, ); export { userIDsToRelativeUserInfos, getRelativeMemberInfos, relativeMemberInfoSelectorForMembersOfThread, userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, isLoggedIn, userStoreSearchIndex, usersWithPersonalThreadSelector, }; diff --git a/lib/shared/entry-utils.js b/lib/shared/entry-utils.js index 5a08262ed..549b4e21e 100644 --- a/lib/shared/entry-utils.js +++ b/lib/shared/entry-utils.js @@ -1,264 +1,264 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import { filteredThreadIDs, nonThreadCalendarFilters, filterExists, } from '../selectors/calendar-filter-selectors.js'; import type { RawEntryInfo, EntryInfo, CalendarQuery, } from '../types/entry-types.js'; import { calendarThreadFilterTypes } from '../types/filter-types.js'; import type { UserInfos } from '../types/user-types.js'; import { dateString, getDate, dateFromString } from '../utils/date-utils.js'; type HasEntryIDs = { localID?: string, id?: string, ... }; function entryKey(entryInfo: HasEntryIDs): string { if (entryInfo.localID) { return entryInfo.localID; } invariant(entryInfo.id, 'localID should exist if ID does not'); return entryInfo.id; } function entryID(entryInfo: HasEntryIDs): string { if (entryInfo.id) { return entryInfo.id; } invariant(entryInfo.localID, 'localID should exist if ID does not'); return entryInfo.localID; } function createEntryInfo( rawEntryInfo: RawEntryInfo, viewerID: ?string, userInfos: UserInfos, ): EntryInfo { const creatorInfo = userInfos[rawEntryInfo.creatorID]; return { id: rawEntryInfo.id, localID: rawEntryInfo.localID, threadID: rawEntryInfo.threadID, text: rawEntryInfo.text, year: rawEntryInfo.year, month: rawEntryInfo.month, day: rawEntryInfo.day, creationTime: rawEntryInfo.creationTime, creator: creatorInfo, deleted: rawEntryInfo.deleted, }; } // Make sure EntryInfo is between startDate and endDate, and that if the // NOT_DELETED filter is active, the EntryInfo isn't deleted function rawEntryInfoWithinActiveRange( rawEntryInfo: RawEntryInfo, calendarQuery: CalendarQuery, ): boolean { const entryInfoDate = getDate( rawEntryInfo.year, rawEntryInfo.month, rawEntryInfo.day, ); const startDate = dateFromString(calendarQuery.startDate); const endDate = dateFromString(calendarQuery.endDate); if (entryInfoDate < startDate || entryInfoDate > endDate) { return false; } if ( rawEntryInfo.deleted && filterExists(calendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED) ) { return false; } return true; } function rawEntryInfoWithinCalendarQuery( rawEntryInfo: RawEntryInfo, calendarQuery: CalendarQuery, ): boolean { if (!rawEntryInfoWithinActiveRange(rawEntryInfo, calendarQuery)) { return false; } const filterToThreadIDs = filteredThreadIDs(calendarQuery.filters); if (filterToThreadIDs && !filterToThreadIDs.has(rawEntryInfo.threadID)) { return false; } return true; } function filterRawEntryInfosByCalendarQuery( rawEntryInfos: { +[id: string]: RawEntryInfo }, calendarQuery: CalendarQuery, ): { +[id: string]: RawEntryInfo } { let filtered = false; const filteredRawEntryInfos = {}; for (const id in rawEntryInfos) { const rawEntryInfo = rawEntryInfos[id]; if (!rawEntryInfoWithinCalendarQuery(rawEntryInfo, calendarQuery)) { filtered = true; continue; } filteredRawEntryInfos[id] = rawEntryInfo; } return filtered ? filteredRawEntryInfos : rawEntryInfos; } function usersInRawEntryInfos( entryInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (const entryInfo of entryInfos) { userIDs.add(entryInfo.creatorID); } return [...userIDs]; } // Note: fetchEntriesForSession expects that all of the CalendarQueries in the // resultant array either filter deleted entries or don't function calendarQueryDifference( oldCalendarQuery: CalendarQuery, newCalendarQuery: CalendarQuery, ): CalendarQuery[] { if (_isEqual(oldCalendarQuery)(newCalendarQuery)) { return []; } const deletedEntriesWereIncluded = filterExists( oldCalendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED, ); const deletedEntriesAreIncluded = filterExists( newCalendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED, ); if (!deletedEntriesWereIncluded && deletedEntriesAreIncluded) { // The new query includes all deleted entries, but the old one didn't. Since // we have no way to include ONLY deleted entries in a CalendarQuery, we // can't separate newCalendarQuery into a query for just deleted entries on // the old range, and a query for all entries on the full range. We'll have // to just query for the whole newCalendarQuery range directly. return [newCalendarQuery]; } const oldFilteredThreadIDs = filteredThreadIDs(oldCalendarQuery.filters); const newFilteredThreadIDs = filteredThreadIDs(newCalendarQuery.filters); if (oldFilteredThreadIDs && !newFilteredThreadIDs) { // The new query is for all thread IDs, but the old one had a THREAD_LIST. // Since we have no way to exclude particular thread IDs from a // CalendarQuery, we can't separate newCalendarQuery into a query for just // the new thread IDs on the old range, and a query for all the thread IDs // on the full range. We'll have to just query for the whole // newCalendarQuery range directly. return [newCalendarQuery]; } const difference = []; const oldStartDate = dateFromString(oldCalendarQuery.startDate); const oldEndDate = dateFromString(oldCalendarQuery.endDate); const newStartDate = dateFromString(newCalendarQuery.startDate); const newEndDate = dateFromString(newCalendarQuery.endDate); if ( oldFilteredThreadIDs && newFilteredThreadIDs && // This checks that there exists an intersection at all oldStartDate <= newEndDate && oldEndDate >= newStartDate ) { const newNotInOld = [...newFilteredThreadIDs].filter( x => !oldFilteredThreadIDs.has(x), ); if (newNotInOld.length > 0) { // In this case, we have added new threadIDs to the THREAD_LIST. // We should query the calendar range for these threads. const intersectionStartDate = oldStartDate < newStartDate ? newCalendarQuery.startDate : oldCalendarQuery.startDate; const intersectionEndDate = oldEndDate > newEndDate ? newCalendarQuery.endDate : oldCalendarQuery.endDate; difference.push({ startDate: intersectionStartDate, endDate: intersectionEndDate, filters: [ ...nonThreadCalendarFilters(newCalendarQuery.filters), { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: newNotInOld, }, ], }); } } if (newStartDate < oldStartDate) { const partialEndDate = new Date(oldStartDate.getTime()); partialEndDate.setDate(partialEndDate.getDate() - 1); difference.push({ filters: newCalendarQuery.filters, startDate: newCalendarQuery.startDate, endDate: dateString(partialEndDate), }); } if (newEndDate > oldEndDate) { const partialStartDate = new Date(oldEndDate.getTime()); partialStartDate.setDate(partialStartDate.getDate() + 1); difference.push({ filters: newCalendarQuery.filters, startDate: dateString(partialStartDate), endDate: newCalendarQuery.endDate, }); } return difference; } function serverEntryInfo(rawEntryInfo: RawEntryInfo): ?RawEntryInfo { const { id } = rawEntryInfo; if (!id) { return null; } const { localID, ...rest } = rawEntryInfo; return { ...rest }; // we only do this for Flow } -function serverEntryInfosObject( - array: $ReadOnlyArray, -): { +[id: string]: RawEntryInfo } { +function serverEntryInfosObject(array: $ReadOnlyArray): { + +[id: string]: RawEntryInfo, +} { const obj = {}; for (const rawEntryInfo of array) { const entryInfo = serverEntryInfo(rawEntryInfo); if (!entryInfo) { continue; } const { id } = entryInfo; invariant(id, 'should be set'); obj[id] = entryInfo; } return obj; } export { entryKey, entryID, createEntryInfo, rawEntryInfoWithinActiveRange, rawEntryInfoWithinCalendarQuery, filterRawEntryInfosByCalendarQuery, usersInRawEntryInfos, calendarQueryDifference, serverEntryInfo, serverEntryInfosObject, }; diff --git a/lib/shared/markdown.js b/lib/shared/markdown.js index 45325124f..c2c09739b 100644 --- a/lib/shared/markdown.js +++ b/lib/shared/markdown.js @@ -1,317 +1,322 @@ // @flow import invariant from 'invariant'; import { oldValidUsernameRegexString } from './account-utils.js'; import type { RelativeMemberInfo } from '../types/thread-types.js'; // simple-markdown types export type State = { key?: string | number | void, inline?: ?boolean, [string]: any, }; export type Parser = (source: string, state?: ?State) => Array; export type Capture = | (Array & { +index: number, ... }) | (Array & { +index?: number, ... }); export type SingleASTNode = { type: string, [string]: any, }; export type ASTNode = SingleASTNode | Array; type UnTypedASTNode = { [string]: any, ... }; type MatchFunction = { regex?: RegExp, ... } & (( source: string, state: State, prevCapture: string, ) => ?Capture); export type ReactElement = React$Element; type ReactElements = React$Node; export type Output = (node: ASTNode, state?: ?State) => Result; type ArrayNodeOutput = ( node: Array, nestedOutput: Output, state: State, ) => Result; type ArrayRule = { +react?: ArrayNodeOutput, +html?: ArrayNodeOutput, +[string]: ArrayNodeOutput, }; type ParseFunction = ( capture: Capture, nestedParse: Parser, state: State, ) => UnTypedASTNode | ASTNode; type ParserRule = { +order: number, +match: MatchFunction, +quality?: (capture: Capture, state: State, prevCapture: string) => number, +parse: ParseFunction, ... }; export type ParserRules = { +Array?: ArrayRule, +[type: string]: ParserRule, ... }; const paragraphRegex: RegExp = /^((?:[^\n]*)(?:\n|$))/; const paragraphStripTrailingNewlineRegex: RegExp = /^([^\n]*)(?:\n|$)/; const headingRegex: RegExp = /^ *(#{1,6}) ([^\n]+?)#* *(?![^\n])/; -const headingStripFollowingNewlineRegex: RegExp = /^ *(#{1,6}) ([^\n]+?)#* *(?:\n|$)/; +const headingStripFollowingNewlineRegex: RegExp = + /^ *(#{1,6}) ([^\n]+?)#* *(?:\n|$)/; const fenceRegex: RegExp = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?\n)\1(?:\n|$)/; -const fenceStripTrailingNewlineRegex: RegExp = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1(?:\n|$)/; +const fenceStripTrailingNewlineRegex: RegExp = + /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1(?:\n|$)/; const codeBlockRegex: RegExp = /^(?: {4}[^\n]*\n*?)+(?!\n* {4}[^\n])(?:\n|$)/; -const codeBlockStripTrailingNewlineRegex: RegExp = /^((?: {4}[^\n]*\n*?)+)(?!\n* {4}[^\n])(?:\n|$)/; +const codeBlockStripTrailingNewlineRegex: RegExp = + /^((?: {4}[^\n]*\n*?)+)(?!\n* {4}[^\n])(?:\n|$)/; const urlRegex: RegExp = /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/i; const mentionRegex = new RegExp(`^(@(${oldValidUsernameRegexString}))\\b`); export type JSONCapture = Array & { +json: Object, +index?: void, ... }; function jsonMatch(source: string): ?JSONCapture { if (!source.startsWith('{')) { return null; } let jsonString = ''; let counter = 0; for (let i = 0; i < source.length; i++) { const char = source[i]; jsonString += char; if (char === '{') { counter++; } else if (char === '}') { counter--; } if (counter === 0) { break; } } if (counter !== 0) { return null; } let json; try { json = JSON.parse(jsonString); } catch { return null; } if (!json || typeof json !== 'object') { return null; } return { ...([jsonString]: any), json }; } function jsonPrint(capture: JSONCapture): string { return JSON.stringify(capture.json, null, ' '); } const listRegex = /^( *)([*+-]|\d+\.) ([\s\S]+?)(?:\n{2}|\s*\n*$)/; -const listItemRegex = /^( *)([*+-]|\d+\.) [^\n]*(?:\n(?!\1(?:[*+-]|\d+\.) )[^\n]*)*(\n|$)/gm; +const listItemRegex = + /^( *)([*+-]|\d+\.) [^\n]*(?:\n(?!\1(?:[*+-]|\d+\.) )[^\n]*)*(\n|$)/gm; const listItemPrefixRegex = /^( *)([*+-]|\d+\.) /; const listLookBehindRegex = /(?:^|\n)( *)$/; function matchList(source: string, state: State): RegExp$matchResult | null { if (state.inline) { return null; } const prevCaptureStr = state.prevCapture ? state.prevCapture[0] : ''; const isStartOfLineCapture = listLookBehindRegex.exec(prevCaptureStr); if (!isStartOfLineCapture) { return null; } const fullSource = isStartOfLineCapture[1] + source; return listRegex.exec(fullSource); } // We've defined our own parse function for lists because simple-markdown // handles newlines differently. Outside of that our implementation is fairly // similar. For more details about list parsing works, take a look at the // comments in the simple-markdown package function parseList( capture: Capture, parse: Parser, state: State, ): UnTypedASTNode { const bullet = capture[2]; const ordered = bullet.length > 1; const start = ordered ? Number(bullet) : undefined; const items = capture[0].match(listItemRegex); let itemContent = null; if (items) { itemContent = items.map((item: string) => { const prefixCapture = listItemPrefixRegex.exec(item); const space = prefixCapture ? prefixCapture[0].length : 0; const spaceRegex = new RegExp('^ {1,' + space + '}', 'gm'); const content: string = item .replace(spaceRegex, '') .replace(listItemPrefixRegex, ''); // We're handling this different than simple-markdown - // each item is a paragraph return parse(content, state); }); } return { ordered: ordered, start: start, items: itemContent, }; } function matchMentions( members: $ReadOnlyArray, ): MatchFunction { const memberSet = new Set( members .filter(({ role }) => role) .map(({ username }) => username?.toLowerCase()) .filter(Boolean), ); const match = (source: string, state: State) => { if (!state.inline) { return null; } const result = mentionRegex.exec(source); if (!result) { return null; } const username = result[2]; invariant(username, 'mentionRegex should match two capture groups'); if (!memberSet.has(username.toLowerCase())) { return null; } return result; }; match.regex = mentionRegex; return match; } const blockQuoteRegex: RegExp = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$)/; -const blockQuoteStripFollowingNewlineRegex: RegExp = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$){2}/; +const blockQuoteStripFollowingNewlineRegex: RegExp = + /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$){2}/; const maxNestedQuotations = 5; // Custom match and parse functions implementation for block quotes // to allow us to specify quotes parsing depth // to avoid too many recursive calls and e.g. app crash function matchBlockQuote(quoteRegex: RegExp): MatchFunction { return (source: string, state: State) => { if ( state.inline || (state?.quotationsDepth && state.quotationsDepth >= maxNestedQuotations) ) { return null; } return quoteRegex.exec(source); }; } function parseBlockQuote( capture: Capture, parse: Parser, state: State, ): UnTypedASTNode { const content = capture[1].replace(/^ *> ?/gm, ''); const currentQuotationsDepth = state?.quotationsDepth ?? 0; return { content: parse(content, { ...state, quotationsDepth: currentQuotationsDepth + 1, }), }; } const spoilerRegex: RegExp = /^\|\|([^\n]+?)\|\|/g; const replaceSpoilerRegex: RegExp = /\|\|(.+?)\|\|/g; const spoilerReplacement: string = '⬛⬛⬛'; const stripSpoilersFromNotifications = (text: string): string => text.replace(replaceSpoilerRegex, spoilerReplacement); function stripSpoilersFromMarkdownAST(ast: SingleASTNode[]): SingleASTNode[] { // Either takes top-level AST, or array of nodes under an items node (list) return ast.map(replaceSpoilersFromMarkdownAST); } function replaceSpoilersFromMarkdownAST(node: SingleASTNode): SingleASTNode { const { content, items, type } = node; if (typeof content === 'string') { // Base case (leaf node) return node; } else if (type === 'spoiler') { // The actual point of this function: replacing the spoilers return { type: 'text', content: spoilerReplacement, }; } else if (content) { // Common case... most nodes nest children with content // If content isn't a string, it should be an array return { ...node, content: stripSpoilersFromMarkdownAST(content), }; } else if (items) { // Special case for lists, which has a nested array of arrays within items return { ...node, items: items.map(stripSpoilersFromMarkdownAST), }; } throw new Error( `unexpected Markdown node of type ${type} with no content or items`, ); } export { paragraphRegex, paragraphStripTrailingNewlineRegex, urlRegex, blockQuoteRegex, blockQuoteStripFollowingNewlineRegex, headingRegex, headingStripFollowingNewlineRegex, codeBlockRegex, codeBlockStripTrailingNewlineRegex, fenceRegex, fenceStripTrailingNewlineRegex, spoilerRegex, matchBlockQuote, parseBlockQuote, jsonMatch, jsonPrint, matchList, parseList, matchMentions, stripSpoilersFromNotifications, stripSpoilersFromMarkdownAST, }; diff --git a/lib/shared/markdown.test.js b/lib/shared/markdown.test.js index ae8335b58..8bec0b8f5 100644 --- a/lib/shared/markdown.test.js +++ b/lib/shared/markdown.test.js @@ -1,84 +1,88 @@ // @flow import { spoilerRegex } from './markdown.js'; // Each of the below tests is subject to the following RegEx pattern: // Spoiler RegEx: /^\|\|([^\n]+?)\|\|/g describe('spoilerRegex', () => { it('We expect a spoiler with a single space character to match.', () => { expect('|| ||').toMatch(spoilerRegex); }); it('We expect a spoiler with regular text + spaces to match (1).', () => { expect('|| hello ||').toMatch(spoilerRegex); }); it('We expect a spoiler with regular text + spaces to match (2).', () => { expect('||SPOILER||').toMatch(spoilerRegex); }); it('We expect a spoiler containing any number of || within it to match (1).', () => { expect('|| || ||').toMatch(spoilerRegex); }); it('We expect a spoiler containing any number of || within it to match (2).', () => { expect('||||||').toMatch(spoilerRegex); }); it('We expect a spoiler containing any number of || within it, as well as regular text + spaces, to match.', () => { expect('||SPOILER||SPOILER||').toMatch(spoilerRegex); }); it('We do not expect a spoiler containing a new line character to match (1).', () => { expect('||\n||').not.toMatch(spoilerRegex); }); it('We do not expect a spoiler containing a new line character to match (2).', () => { expect('||\r\n||').not.toMatch(spoilerRegex); }); it('We do not expect an empty spoiler to match.', () => { expect('||||').not.toMatch(spoilerRegex); }); it('We expect a spoiler containing a single space to match, even when split across multiple lines.', () => { - expect('|| \ - ||').toMatch(spoilerRegex); + expect( + '|| \ + ||', + ).toMatch(spoilerRegex); }); it('We do not expect a spoiler containing a new line character to match (3).', () => { - expect('|| \n\ - ||').not.toMatch(spoilerRegex); + expect( + '|| \n\ + ||', + ).not.toMatch(spoilerRegex); }); it("We expect to extract 'hello' from the following spoiler: ||hello||", () => { const spoiler = '||hello||'; const extracted = spoiler.match(spoilerRegex); expect(extracted ? extracted[0] : null).toMatch('hello'); }); it("We expect to extract '||' from the following spoiler: ||||||", () => { const spoiler = '||||||'; const extracted = spoiler.match(spoilerRegex); expect(extracted ? extracted[0] : null).toMatch('||'); }); it("We expect to extract ' ' from the following spoiler: || || ||", () => { const spoiler = '|| || ||'; const extracted = spoiler.match(spoilerRegex); expect(extracted ? extracted[0] : null).toMatch(' '); }); it("We expect to extract '||THISISASPOILER||' from the following spoiler: ||||THISISASPOILER||||", () => { const spoiler = '||||THISISASPOILER||||'; const extracted = spoiler.match(spoilerRegex); expect(extracted ? extracted[0] : null).toMatch('||THISISASPOILER||'); }); it("We expect to extract '' from the following spoiler: || \\|\\| ||", () => { const spoiler = '|| \\|\\| ||'; const extracted = spoiler.match(spoilerRegex); expect(extracted ? extracted[0] : null).toMatch(' \\|\\| '); }); }); diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index ca779e231..5df773436 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1541 +1,1533 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import * as React from 'react'; import stringHash from 'string-hash'; import tinycolor from 'tinycolor2'; import { type ParserRules } from './markdown.js'; import { getMessageTitle } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import threadWatcher from './thread-watcher.js'; import { fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from '../actions/message-actions.js'; import { changeThreadMemberRolesActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions.js'; import { searchUsers as searchUserCall } from '../actions/user-actions.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; import type { ChatThreadItem, ChatMessageInfoItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import type { CalendarQuery } from '../types/entry-types.js'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { type RawThreadInfo, type ThreadInfo, type ThreadPermission, type MemberInfo, type ServerThreadInfo, type RelativeMemberInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ThreadPermissionsInfo, type ThreadType, type ClientNewThreadRequest, type NewThreadResult, type ChangeThreadSettingsPayload, threadTypes, threadPermissions, threadTypeIsCommunityRoot, assertThreadType, } from '../types/thread-types.js'; import { type ClientUpdateInfo, updateTypes } from '../types/update-types.js'; import type { GlobalAccountUserInfo, UserInfos, UserInfo, AccountUserInfo, } from '../types/user-types.js'; import { useDispatchActionPromise, useServerCall, } from '../utils/action-utils.js'; import type { DispatchActionPromise } from '../utils/action-utils.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { ET, entityTextToRawString, getEntityTextAsString, type ThreadEntity, } from '../utils/entity-text.js'; import { values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { trimText } from '../utils/text-utils.js'; const chatNameMaxLength = 191; const chatNameMinLength = 0; const secondCharRange = `{${chatNameMinLength},${chatNameMaxLength}}`; const validChatNameRegexString = `^.${secondCharRange}$`; const validChatNameRegex: RegExp = new RegExp(validChatNameRegexString); function colorIsDark(color: string): boolean { return tinycolor(`#${color}`).isDark(); } const selectedThreadColorsObj = Object.freeze({ a: '4b87aa', b: '5c9f5f', c: 'b8753d', d: 'aa4b4b', e: '6d49ab', f: 'c85000', g: '008f83', h: '648caa', i: '57697f', j: '575757', }); const selectedThreadColors: $ReadOnlyArray = values( selectedThreadColorsObj, ); export type SelectedThreadColors = $Values; function generateRandomColor(): string { return selectedThreadColors[ Math.floor(Math.random() * selectedThreadColors.length) ]; } function generatePendingThreadColor(userIDs: $ReadOnlyArray): string { const ids = [...userIDs].sort().join('#'); const colorIdx = stringHash(ids) % selectedThreadColors.length; return selectedThreadColors[colorIdx]; } function threadHasPermission( threadInfo: ?(ThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { if (!threadInfo) { return false; } invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (!threadInfo.currentUser.permissions[permission]) { return false; } return threadInfo.currentUser.permissions[permission].value; } function viewerIsMember(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } function threadIsChannel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } function threadIsSidebar(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return threadInfo?.type === threadTypes.SIDEBAR; } function threadInBackgroundChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(ThreadInfo | RawThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } if (threadInfo.id === genesis.id) { return true; } return threadInfo.members.some(member => member.id === userID && member.role); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter(memberInfo => memberInfo.role) .map(memberInfo => memberInfo.id); } function threadOtherMembers( memberInfos: $ReadOnlyArray, viewerID: ?string, ): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } function threadMembersWithoutAddedAshoat( threadInfo: T, ): $PropertyType { if (threadInfo.community !== genesis.id) { return threadInfo.members; } return threadInfo.members.filter( member => member.id !== ashoat.id || member.role, ); } function threadIsGroupChat(threadInfo: ThreadInfo | RawThreadInfo): boolean { return ( threadMembersWithoutAddedAshoat(threadInfo).filter( member => member.role || member.permissions[threadPermissions.VOICED]?.value, ).length > 2 ); } function threadOrParentThreadIsGroupChat( threadInfo: RawThreadInfo | ThreadInfo, ) { return threadMembersWithoutAddedAshoat(threadInfo).length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function getSingleOtherUser( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ): ?string { if (!viewerID) { return undefined; } const otherMembers = threadOtherMembers(threadInfo.members, viewerID); if (otherMembers.length !== 1) { return undefined; } return otherMembers[0].id; } function getPendingThreadID( threadType: ThreadType, memberIDs: $ReadOnlyArray, sourceMessageID: ?string, ): string { const pendingThreadKey = sourceMessageID ? `sidebar/${sourceMessageID}` : [...memberIDs].sort().join('+'); const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`; return `pending/${pendingThreadTypeString}${pendingThreadKey}`; } const pendingThreadIDRegex = 'pending/(type[0-9]+/[0-9]+(\\+[0-9]+)*|sidebar/[0-9]+)'; type PendingThreadIDContents = { +threadType: ThreadType, +memberIDs: $ReadOnlyArray, +sourceMessageID: ?string, }; function parsePendingThreadID( pendingThreadID: string, ): ?PendingThreadIDContents { const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`); const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID); if (!pendingThreadIDMatches) { return null; } const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/'); const threadType = threadTypeString === 'sidebar' ? threadTypes.SIDEBAR : assertThreadType(Number(threadTypeString.replace('type', ''))); const memberIDs = threadTypeString === 'sidebar' ? [] : threadKey.split('+'); const sourceMessageID = threadTypeString === 'sidebar' ? threadKey : null; return { threadType, memberIDs, sourceMessageID, }; } type CreatePendingThreadArgs = { +viewerID: string, +threadType: ThreadType, +members?: $ReadOnlyArray, +parentThreadInfo?: ?ThreadInfo, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs): ThreadInfo { const now = Date.now(); const nonViewerMembers = members ? members.filter(member => member.id !== viewerID) : []; const nonViewerMemberIDs = nonViewerMembers.map(member => member.id); const memberIDs = [...nonViewerMemberIDs, viewerID]; const threadID = getPendingThreadID(threadType, memberIDs, sourceMessageID); const permissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role = { id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }; const rawThreadInfo = { id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID(parentThreadInfo, threadType), community: getCommunity(parentThreadInfo), members: [ { id: viewerID, role: role.id, permissions: membershipPermissions, isSender: false, }, ...nonViewerMembers.map(member => ({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, })), ], roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, repliesCount: 0, sourceMessageID, }; const userInfos = {}; nonViewerMembers.forEach(member => (userInfos[member.id] = member)); return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } function createPendingThreadItem( viewerID: string, user: GlobalAccountUserInfo, ): ChatThreadItem { const threadInfo = createPendingThread({ viewerID, threadType: threadTypes.PERSONAL, members: [user], }); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo: { id: user.id, username: user.username, }, }; } type SharedCreatePendingSidebarInput = { +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, +parentThreadInfo: ThreadInfo, +viewerID: string, }; type BaseCreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +messageTitle: string, }; function baseCreatePendingSidebar( input: BaseCreatePendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, viewerID, messageTitle } = input; const { color, type: parentThreadType } = parentThreadInfo; const threadName = trimText(messageTitle, 30); const initialMembers = new Map(); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { - const { - id: sourceAuthorID, - username: sourceAuthorUsername, - } = sourceMessageInfo.creator; + const { id: sourceAuthorID, username: sourceAuthorUsername } = + sourceMessageInfo.creator; invariant( sourceAuthorUsername, 'sourceAuthorUsername should be set in createPendingSidebar', ); const initialMemberUserInfo: GlobalAccountUserInfo = { id: sourceAuthorID, username: sourceAuthorUsername, }; initialMembers.set(sourceAuthorID, initialMemberUserInfo); } const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); if (parentThreadType === threadTypes.PERSONAL && singleOtherUser) { const singleOtherUsername = parentThreadInfo.members.find( member => member.id === singleOtherUser, )?.username; invariant( singleOtherUsername, 'singleOtherUsername should be set in createPendingSidebar', ); const singleOtherUserInfo: GlobalAccountUserInfo = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: [...initialMembers.values()], parentThreadInfo, threadColor: color, name: threadName, sourceMessageID: sourceMessageInfo.id, }); } // The message title here may have ETH addresses that aren't resolved to ENS // names. This function should only be used in cases where we're sure that we // don't care about the thread title. We should prefer createPendingSidebar // wherever possible type CreateUnresolvedPendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, }; function createUnresolvedPendingSidebar( input: CreateUnresolvedPendingSidebarInput, ): ThreadInfo { - const { - sourceMessageInfo, - parentThreadInfo, - viewerID, - markdownRules, - } = input; + const { sourceMessageInfo, parentThreadInfo, viewerID, markdownRules } = + input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, markdownRules, ); const messageTitle = entityTextToRawString(messageTitleEntityText, { ignoreViewer: true, }); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, viewerID, }); } type CreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, +getENSNames: ?GetENSNames, }; async function createPendingSidebar( input: CreatePendingSidebarInput, ): Promise { const { sourceMessageInfo, parentThreadInfo, viewerID, markdownRules, getENSNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, markdownRules, ); const messageTitle = await getEntityTextAsString( messageTitleEntityText, getENSNames, { ignoreViewer: true }, ); invariant( messageTitle !== null && messageTitle !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, viewerID, }); } function pendingThreadType(numberOfOtherMembers: number): 4 | 6 | 7 { if (numberOfOtherMembers === 0) { return threadTypes.PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.PERSONAL; } else { return threadTypes.LOCAL; } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( threadType === threadTypes.PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.SIDEBAR || threadType === threadTypes.PRIVATE ); } type CreateRealThreadParameters = { +threadInfo: ThreadInfo, +dispatchActionPromise: DispatchActionPromise, +createNewThread: ClientNewThreadRequest => Promise, +sourceMessageID: ?string, +viewerID: ?string, +handleError?: () => mixed, +calendarQuery: CalendarQuery, }; async function createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise, createNewThread, sourceMessageID, viewerID, calendarQuery, }: CreateRealThreadParameters): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } const otherMemberIDs = threadOtherMembers(threadInfo.members, viewerID).map( member => member.id, ); let resultPromise; if (threadInfo.type !== threadTypes.SIDEBAR) { invariant( otherMemberIDs.length > 0, 'otherMemberIDs should not be empty for threads', ); resultPromise = createNewThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, calendarQuery, }); } else { invariant( sourceMessageID, 'sourceMessageID should be set when creating a sidebar', ); resultPromise = createNewThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, calendarQuery, }); } dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } type RawThreadInfoOptions = { +includeVisibilityRules?: ?boolean, +filterMemberList?: ?boolean, +hideThreadStructure?: ?boolean, +shimThreadTypes?: ?{ +[inType: ThreadType]: ThreadType, }, +filterDetailedThreadEditPermissions?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const includeVisibilityRules = options?.includeVisibilityRules; const filterMemberList = options?.filterMemberList; const hideThreadStructure = options?.hideThreadStructure; const shimThreadTypes = options?.shimThreadTypes; const filterDetailedThreadEditPermissions = options?.filterDetailedThreadEditPermissions; const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( filterMemberList && serverMember.id !== viewerID && !serverMember.role && !memberHasAdminPowers(serverMember) ) { continue; } if ( serverThreadInfo.id === genesis.id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadEditDetailedPermissions( serverMember.permissions, filterDetailedThreadEditPermissions, ); members.push({ id: serverMember.id, role: serverMember.role, permissions: memberPermissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: memberPermissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = filterThreadEditDetailedPermissions( getAllThreadPermissions(null, serverThreadInfo.id), filterDetailedThreadEditPermissions, ); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } let { type } = serverThreadInfo; if ( shimThreadTypes && shimThreadTypes[type] !== null && shimThreadTypes[type] !== undefined ) { type = shimThreadTypes[type]; } let rawThreadInfo: any = { id: serverThreadInfo.id, type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: serverThreadInfo.roles, currentUser, repliesCount: serverThreadInfo.repliesCount, }; if (!hideThreadStructure) { rawThreadInfo = { ...rawThreadInfo, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; } const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (includeVisibilityRules) { return { ...rawThreadInfo, visibilityRules: rawThreadInfo.type, }; } return rawThreadInfo; } function filterThreadEditDetailedPermissions( permissions: ThreadPermissionsInfo, shouldFilter: ?boolean, ): ThreadPermissionsInfo { if (!shouldFilter) { return permissions; } - const { - edit_thread_color, - edit_thread_description, - ...newPermissions - } = permissions; + const { edit_thread_color, edit_thread_description, ...newPermissions } = + permissions; return newPermissions; } function threadUIName(threadInfo: ThreadInfo): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } const threadMembers = threadInfo.members.filter( memberInfo => memberInfo.role, ); const memberEntities = threadMembers.map(member => ET.user({ userInfo: member }), ); return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: memberEntities, }; } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { let threadInfo: ThreadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: '', description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, containingThreadID: rawThreadInfo.containingThreadID, community: rawThreadInfo.community, members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos), roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; threadInfo = { ...threadInfo, uiName: threadUIName(threadInfo), }; const { sourceMessageID } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } return threadInfo; } function getCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function threadIsWithBlockedUserOnly( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadFrozenDueToBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } function rawThreadInfoFromThreadInfo(threadInfo: ThreadInfo): RawThreadInfo { let rawThreadInfo: RawThreadInfo = { id: threadInfo.id, type: threadInfo.type, name: threadInfo.name, description: threadInfo.description, color: threadInfo.color, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, containingThreadID: threadInfo.containingThreadID, community: threadInfo.community, members: threadInfo.members.map(relativeMemberInfo => ({ id: relativeMemberInfo.id, role: relativeMemberInfo.role, permissions: relativeMemberInfo.permissions, isSender: relativeMemberInfo.isSender, })), roles: threadInfo.roles, currentUser: threadInfo.currentUser, repliesCount: threadInfo.repliesCount, }; const { sourceMessageID } = threadInfo; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } return rawThreadInfo; } const threadTypeDescriptions: { [ThreadType]: string } = { [threadTypes.COMMUNITY_OPEN_SUBTHREAD]: 'Anybody in the parent channel can see an open subchannel.', [threadTypes.COMMUNITY_SECRET_SUBTHREAD]: 'Only visible to its members and admins of ancestor channels.', }; function usersInThreadInfo(threadInfo: RawThreadInfo | ThreadInfo): string[] { const userIDs = new Set(); for (const member of threadInfo.members) { userIDs.add(member.id); } return [...userIDs]; } function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, threadInfo: ThreadInfo | RawThreadInfo, ): boolean { return !!( memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]) ); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo): boolean { return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo), ): boolean { if (!threadInfo) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: RawThreadInfo | ThreadInfo, ) { return ( threadMembersWithoutAddedAshoat(threadInfo).filter(member => memberHasAdminPowers(member), ).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ threadPermissions.VOICED, threadPermissions.EDIT_ENTRIES, threadPermissions.EDIT_THREAD_NAME, threadPermissions.EDIT_THREAD_COLOR, threadPermissions.EDIT_THREAD_DESCRIPTION, threadPermissions.CREATE_SUBCHANNELS, threadPermissions.CREATE_SIDEBARS, threadPermissions.JOIN_THREAD, threadPermissions.EDIT_PERMISSIONS, threadPermissions.ADD_MEMBERS, threadPermissions.REMOVE_MEMBERS, ]; const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); -const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( - (permissions: ThreadPermissionsInfo, permission: string) => ({ - ...permissions, - [permission]: { value: false, source: null }, - }), - {}, -); +const disabledPermissions: ThreadPermissionsInfo = + permissionsDisabledByBlockArray.reduce( + (permissions: ThreadPermissionsInfo, permission: string) => ({ + ...permissions, + [permission]: { value: false, source: null }, + }), + {}, + ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText: string = `Background chats are just like normal chats, except they don't ` + `contribute to your unread count.\n\n` + `To move a chat over here, switch the “Background” option in its settings.`; function threadNoun(threadType: ThreadType): string { if (threadType === threadTypes.SIDEBAR) { return 'thread'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.GENESIS ) { return 'channel'; } else { return 'chat'; } } function threadLabel(threadType: ThreadType): string { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return 'Open'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Thread'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS ) { return 'Community'; } else { return 'Secret'; } } function useWatchThread(threadInfo: ?ThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages(threadID), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, ) => ?ThreadInfo; function useExistingThreadInfoFinder( baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const userInfos = useSelector(state => state.userStore.userInfos); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!viewerID || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } invariant( threadTypeCanBePending(baseThreadInfo.type), `ThreadInfo has pending ID ${baseThreadInfo.id}, but type that ` + `should not be pending ${baseThreadInfo.type}`, ); const { searching, userInfoInputArray } = params; const { sourceMessageID } = baseThreadInfo; const pendingThreadID = searching ? getPendingThreadID( pendingThreadType(userInfoInputArray.length), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ) : getPendingThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: userInfoInputArray, }) : baseThreadInfo; return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; }, [ baseThreadInfo, threadInfos, viewerID, pendingToRealizedThreadIDs, userInfos, ], ); } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || //threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.SIDEBAR ) { return 'required'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS || threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function threadMemberHasPermission( threadInfo: ServerThreadInfo | RawThreadInfo | ThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } return permissionLookup(member.permissions, permission); } return false; } function useCanCreateSidebarFromMessage( threadInfo: ThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); if (!messageInfo.id || threadInfo.sourceMessageID === messageInfo.id) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; const creatorRelationshipHasBlock = messageCreatorRelationship && relationshipBlockedInEitherDirection(messageCreatorRelationship); const hasPermission = threadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); return hasPermission && !creatorRelationshipHasBlock; } function useSidebarExistsOrCanBeCreated( threadInfo: ThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } function checkIfDefaultMembersAreVoiced(threadInfo: ThreadInfo): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; return !!defaultRole.permissions[threadPermissions.VOICED]; } function draftKeyFromThreadID(threadID: string): string { return `${threadID}/message_composer`; } function getContainingThreadID( parentThreadInfo: ?ServerThreadInfo | RawThreadInfo | ThreadInfo, threadType: ThreadType, ): ?string { if (!parentThreadInfo) { return null; } if (threadType === threadTypes.SIDEBAR) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( parentThreadInfo: ?ServerThreadInfo | RawThreadInfo | ThreadInfo, ): ?string { if (!parentThreadInfo) { return null; } const { id, community, type } = parentThreadInfo; if (community !== null && community !== undefined) { return community; } if (threadTypeIsCommunityRoot(type)) { return id; } return null; } function getThreadListSearchResults( chatListData: $ReadOnlyArray, searchText: string, threadFilter: ThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, viewerID: ?string, ): $ReadOnlyArray { if (!searchText) { return chatListData.filter( item => threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo), ); } const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of chatListData) { if (!threadSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { otherThreads.push({ ...item, sidebars: [] }); } } const chatItems = [...privateThreads, ...personalThreads, ...otherThreads]; if (viewerID) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(viewerID, user), ), ); } return chatItems; } type ThreadListSearchResult = { +threadSearchResults: $ReadOnlySet, +usersSearchResults: $ReadOnlyArray, }; function useThreadListSearch( chatListData: $ReadOnlyArray, searchText: string, viewerID: ?string, ): ThreadListSearchResult { const callSearchUsers = useServerCall(searchUserCall); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); const searchUsers = React.useCallback( async (usernamePrefix: string) => { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await callSearchUsers(usernamePrefix); return userInfos.filter( info => !usersWithPersonalThread.has(info.id) && info.id !== viewerID, ); }, [callSearchUsers, usersWithPersonalThread, viewerID], ); const [threadSearchResults, setThreadSearchResults] = React.useState( new Set(), ); const [usersSearchResults, setUsersSearchResults] = React.useState([]); const threadSearchIndex = useGlobalThreadSearchIndex(); React.useEffect(() => { (async () => { const results = threadSearchIndex.getSearchResults(searchText); setThreadSearchResults(new Set(results)); const usersResults = await searchUsers(searchText); setUsersSearchResults(usersResults); })(); }, [searchText, chatListData, threadSearchIndex, searchUsers]); return { threadSearchResults, usersSearchResults }; } function removeMemberFromThread( threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, dispatchActionPromise: DispatchActionPromise, removeUserFromThreadServerCall: ( threadID: string, memberIDs: $ReadOnlyArray, ) => Promise, ) { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( removeUsersFromThreadActionTypes, removeUserFromThreadServerCall(threadInfo.id, [memberInfo.id]), { customKeyName }, ); } function switchMemberAdminRoleInThread( threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, isCurrentlyAdmin: boolean, dispatchActionPromise: DispatchActionPromise, changeUserRoleServerCall: ( threadID: string, memberIDs: $ReadOnlyArray, newRole: string, ) => Promise, ) { let newRole = null; for (const roleID in threadInfo.roles) { const role = threadInfo.roles[roleID]; if (isCurrentlyAdmin && role.isDefault) { newRole = role.id; break; } else if (!isCurrentlyAdmin && roleIsAdminRole(role)) { newRole = role.id; break; } } invariant(newRole !== null, 'Could not find new role'); const customKeyName = `${changeThreadMemberRolesActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( changeThreadMemberRolesActionTypes, changeUserRoleServerCall(threadInfo.id, [memberInfo.id], newRole), { customKeyName }, ); } function getAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, threadInfo: ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'remove_user' | 'remove_admin' | 'make_admin'> { const role = memberInfo.role; if (!canEdit || !role) { return []; } const canRemoveMembers = threadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = threadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); const result = []; if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || threadInfo.roles[role]?.isDefault) ) { result.push('remove_user'); } if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) { result.push( memberIsAdmin(memberInfo, threadInfo) ? 'remove_admin' : 'make_admin', ); } return result; } export { colorIsDark, generateRandomColor, generatePendingThreadColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, getSingleOtherUser, getPendingThreadID, pendingThreadIDRegex, parsePendingThreadID, createPendingThread, createUnresolvedPendingSidebar, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, filterThreadEditDetailedPermissions, threadUIName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useThreadListSearch, removeMemberFromThread, switchMemberAdminRoleInThread, getAvailableThreadMemberActions, selectedThreadColors, threadMembersWithoutAddedAshoat, validChatNameRegex, chatNameMaxLength, }; diff --git a/lib/socket/api-request-handler.react.js b/lib/socket/api-request-handler.react.js index af593d195..78acf3ddc 100644 --- a/lib/socket/api-request-handler.react.js +++ b/lib/socket/api-request-handler.react.js @@ -1,99 +1,98 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { InflightRequests, SocketOffline } from './inflight-requests.js'; import type { APIRequest } from '../types/endpoints.js'; import { clientSocketMessageTypes, serverSocketMessageTypes, type ClientSocketMessageWithoutID, type ConnectionInfo, } from '../types/socket-types.js'; import { registerActiveSocket } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type BaseProps = { +inflightRequests: ?InflightRequests, +sendMessage: (message: ClientSocketMessageWithoutID) => number, }; type Props = { ...BaseProps, +connection: ConnectionInfo, }; class APIRequestHandler extends React.PureComponent { static isConnected(props: Props, request?: APIRequest) { const { inflightRequests, connection } = props; if (!inflightRequests) { return false; } // This is a hack. We actually have a race condition between // ActivityHandler and Socket. Both of them respond to a backgrounding, but // we want ActivityHandler to go first. Once it sends its message, Socket // will wait for the response before shutting down. But if Socket starts // shutting down first, we'll have a problem. Note that this approach only // stops the race in fetchResponse below, and not in action-utils (which // happens earlier via the registerActiveSocket call below), but empircally // that hasn't been an issue. // The reason I didn't rewrite this to happen in a single component is // because I want to maintain separation of concerns. Upcoming React Hooks // will be a great way to rewrite them to be related but still separated. return ( connection.status === 'connected' || (request && request.endpoint === 'update_activity') ); } get registeredResponseFetcher() { return APIRequestHandler.isConnected(this.props) ? this.fetchResponse : null; } componentDidMount() { registerActiveSocket(this.registeredResponseFetcher); } componentWillUnmount() { registerActiveSocket(null); } componentDidUpdate(prevProps: Props) { const isConnected = APIRequestHandler.isConnected(this.props); const wasConnected = APIRequestHandler.isConnected(prevProps); if (isConnected !== wasConnected) { registerActiveSocket(this.registeredResponseFetcher); } } render() { return null; } fetchResponse = async (request: APIRequest): Promise => { if (!APIRequestHandler.isConnected(this.props, request)) { throw new SocketOffline('socket_offline'); } const { inflightRequests } = this.props; invariant(inflightRequests, 'inflightRequests falsey inside fetchResponse'); const messageID = this.props.sendMessage({ type: clientSocketMessageTypes.API_REQUEST, payload: request, }); const response = await inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.API_RESPONSE, ); return response.payload; }; } -const ConnectedAPIRequestHandler: React.ComponentType = React.memo( - function ConnectedAPIRequestHandler(props) { +const ConnectedAPIRequestHandler: React.ComponentType = + React.memo(function ConnectedAPIRequestHandler(props) { const connection = useSelector(state => state.connection); return ; - }, -); + }); export default ConnectedAPIRequestHandler; diff --git a/lib/socket/calendar-query-handler.react.js b/lib/socket/calendar-query-handler.react.js index 6b450771f..220e0ac3c 100644 --- a/lib/socket/calendar-query-handler.react.js +++ b/lib/socket/calendar-query-handler.react.js @@ -1,146 +1,145 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { updateCalendarQueryActionTypes, updateCalendarQuery, } from '../actions/entry-actions.js'; import { timeUntilCalendarRangeExpiration } from '../selectors/nav-selectors.js'; import { useIsAppForegrounded } from '../shared/lifecycle-utils.js'; import type { CalendarQuery, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from '../types/entry-types.js'; import { type ConnectionInfo } from '../types/socket-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type BaseProps = { +currentCalendarQuery: () => CalendarQuery, +frozen: boolean, }; type Props = { ...BaseProps, +connection: ConnectionInfo, +lastUserInteractionCalendar: number, +foreground: boolean, +dispatchActionPromise: DispatchActionPromise, +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, }; class CalendarQueryHandler extends React.PureComponent { serverCalendarQuery: CalendarQuery; expirationTimeoutID: ?TimeoutID; constructor(props: Props) { super(props); this.serverCalendarQuery = this.props.connection.actualizedCalendarQuery; } componentDidMount() { if (this.props.connection.status === 'connected') { this.possiblyUpdateCalendarQuery(); } } componentDidUpdate(prevProps: Props) { const { actualizedCalendarQuery } = this.props.connection; if (this.props.connection.status !== 'connected') { if (!_isEqual(this.serverCalendarQuery)(actualizedCalendarQuery)) { this.serverCalendarQuery = actualizedCalendarQuery; } return; } if ( !_isEqual(this.serverCalendarQuery)(actualizedCalendarQuery) && _isEqual(this.props.currentCalendarQuery())(actualizedCalendarQuery) ) { this.serverCalendarQuery = actualizedCalendarQuery; } const shouldUpdate = (this.isExpired || prevProps.connection.status !== 'connected' || this.props.currentCalendarQuery !== prevProps.currentCalendarQuery) && this.shouldUpdateCalendarQuery; if (shouldUpdate) { this.updateCalendarQuery(); } } render() { return null; } get isExpired() { const timeUntilExpiration = timeUntilCalendarRangeExpiration( this.props.lastUserInteractionCalendar, ); return ( timeUntilExpiration !== null && timeUntilExpiration !== undefined && timeUntilExpiration <= 0 ); } get shouldUpdateCalendarQuery() { if (this.props.connection.status !== 'connected' || this.props.frozen) { return false; } const calendarQuery = this.props.currentCalendarQuery(); return !_isEqual(calendarQuery)(this.serverCalendarQuery); } updateCalendarQuery() { const calendarQuery = this.props.currentCalendarQuery(); this.serverCalendarQuery = calendarQuery; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(calendarQuery, true), undefined, ({ calendarQuery }: CalendarQueryUpdateStartingPayload), ); } possiblyUpdateCalendarQuery = () => { if (this.shouldUpdateCalendarQuery) { this.updateCalendarQuery(); } }; } -const ConnectedCalendarQueryHandler: React.ComponentType = React.memo( - function ConnectedCalendarQueryHandler(props) { +const ConnectedCalendarQueryHandler: React.ComponentType = + React.memo(function ConnectedCalendarQueryHandler(props) { const connection = useSelector(state => state.connection); const lastUserInteractionCalendar = useSelector( state => state.entryStore.lastUserInteractionCalendar, ); // We include this so that componentDidUpdate will be called on foreground const foreground = useIsAppForegrounded(); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); const dispatchActionPromise = useDispatchActionPromise(); return ( ); - }, -); + }); export default ConnectedCalendarQueryHandler; diff --git a/lib/socket/report-handler.react.js b/lib/socket/report-handler.react.js index 6cdacd3ab..ce453fe7a 100644 --- a/lib/socket/report-handler.react.js +++ b/lib/socket/report-handler.react.js @@ -1,98 +1,97 @@ // @flow import * as React from 'react'; import { sendReportsActionTypes, sendReports, } from '../actions/report-actions.js'; import { queuedReports as queuedReportsSelector } from '../selectors/socket-selectors.js'; import { type ClientReportCreationRequest, type ClearDeliveredReportsPayload, } from '../types/report-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type BaseProps = { +canSendReports: boolean, }; type Props = { ...BaseProps, +queuedReports: $ReadOnlyArray, +dispatchActionPromise: DispatchActionPromise, +sendReports: ( reports: $ReadOnlyArray, ) => Promise, }; class ReportHandler extends React.PureComponent { componentDidMount() { if (this.props.canSendReports) { this.dispatchSendReports(this.props.queuedReports); } } componentDidUpdate(prevProps: Props) { if (!this.props.canSendReports) { return; } const couldSend = prevProps.canSendReports; const curReports = this.props.queuedReports; if (!couldSend) { this.dispatchSendReports(curReports); return; } const prevReports = prevProps.queuedReports; if (curReports !== prevReports) { const prevResponses = new Set(prevReports); const newResponses = curReports.filter( response => !prevResponses.has(response), ); this.dispatchSendReports(newResponses); } } render() { return null; } dispatchSendReports(reports: $ReadOnlyArray) { if (reports.length === 0) { return; } this.props.dispatchActionPromise( sendReportsActionTypes, this.sendReports(reports), ); } async sendReports(reports: $ReadOnlyArray) { await this.props.sendReports(reports); return ({ reports }: ClearDeliveredReportsPayload); } } -const ConnectedReportHandler: React.ComponentType = React.memo( - function ConnectedReportHandler(props) { +const ConnectedReportHandler: React.ComponentType = + React.memo(function ConnectedReportHandler(props) { const queuedReports = useSelector(queuedReportsSelector); const callSendReports = useServerCall(sendReports); const dispatchActionPromise = useDispatchActionPromise(); return ( ); - }, -); + }); export default ConnectedReportHandler; diff --git a/lib/socket/request-response-handler.react.js b/lib/socket/request-response-handler.react.js index 15214a31c..aeaa451d6 100644 --- a/lib/socket/request-response-handler.react.js +++ b/lib/socket/request-response-handler.react.js @@ -1,149 +1,148 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { InflightRequests, SocketTimeout } from './inflight-requests.js'; import type { CalendarQuery } from '../types/entry-types.js'; import type { Dispatch } from '../types/redux-types.js'; import { processServerRequestsActionType, type ClientClientResponse, type ClientServerRequest, } from '../types/request-types.js'; import { type ClientRequestsServerSocketMessage, type ClientServerSocketMessage, clientSocketMessageTypes, serverSocketMessageTypes, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionInfo, } from '../types/socket-types.js'; import { ServerError } from '../utils/errors.js'; import { useSelector } from '../utils/redux-utils.js'; type BaseProps = { +inflightRequests: ?InflightRequests, +sendMessage: (message: ClientSocketMessageWithoutID) => number, +addListener: (listener: SocketListener) => void, +removeListener: (listener: SocketListener) => void, +getClientResponses: ( activeServerRequests: $ReadOnlyArray, ) => $ReadOnlyArray, +currentCalendarQuery: () => CalendarQuery, }; type Props = { ...BaseProps, +connection: ConnectionInfo, +dispatch: Dispatch, }; class RequestResponseHandler extends React.PureComponent { componentDidMount() { this.props.addListener(this.onMessage); } componentWillUnmount() { this.props.removeListener(this.onMessage); } render() { return null; } onMessage = (message: ClientServerSocketMessage) => { if (message.type !== serverSocketMessageTypes.REQUESTS) { return; } const { serverRequests } = message.payload; if (serverRequests.length === 0) { return; } const calendarQuery = this.props.currentCalendarQuery(); this.props.dispatch({ type: processServerRequestsActionType, payload: { serverRequests, calendarQuery, }, }); if (this.props.inflightRequests) { const clientResponses = this.props.getClientResponses(serverRequests); this.sendAndHandleClientResponsesToServerRequests(clientResponses); } }; sendClientResponses( clientResponses: $ReadOnlyArray, ): Promise { const { inflightRequests } = this.props; invariant( inflightRequests, 'inflightRequests falsey inside sendClientResponses', ); const messageID = this.props.sendMessage({ type: clientSocketMessageTypes.RESPONSES, payload: { clientResponses }, }); return inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.REQUESTS, ); } sendAndHandleClientResponsesToServerRequests( clientResponses: $ReadOnlyArray, ) { if (clientResponses.length === 0) { return; } const promise = this.sendClientResponses(clientResponses); this.handleClientResponsesToServerRequests(promise, clientResponses); } async handleClientResponsesToServerRequests( promise: Promise, clientResponses: $ReadOnlyArray, retriesLeft: number = 1, ): Promise { try { await promise; } catch (e) { console.log(e); if ( !(e instanceof SocketTimeout) && (!(e instanceof ServerError) || e.message === 'unknown_error') && retriesLeft > 0 && this.props.connection.status === 'connected' && this.props.inflightRequests ) { // We'll only retry if the connection is healthy and the error is either // an unknown_error ServerError or something is neither a ServerError // nor a SocketTimeout. const newPromise = this.sendClientResponses(clientResponses); await this.handleClientResponsesToServerRequests( newPromise, clientResponses, retriesLeft - 1, ); } } } } -const ConnectedRequestResponseHandler: React.ComponentType = React.memo( - function ConnectedRequestResponseHandler(props) { +const ConnectedRequestResponseHandler: React.ComponentType = + React.memo(function ConnectedRequestResponseHandler(props) { const connection = useSelector(state => state.connection); const dispatch = useDispatch(); return ( ); - }, -); + }); export default ConnectedRequestResponseHandler; diff --git a/lib/socket/socket.react.js b/lib/socket/socket.react.js index 69aee00bf..4711643e5 100644 --- a/lib/socket/socket.react.js +++ b/lib/socket/socket.react.js @@ -1,775 +1,776 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import ActivityHandler from './activity-handler.react.js'; import APIRequestHandler from './api-request-handler.react.js'; import CalendarQueryHandler from './calendar-query-handler.react.js'; import { InflightRequests, SocketTimeout, SocketOffline, } from './inflight-requests.js'; import MessageHandler from './message-handler.react.js'; import ReportHandler from './report-handler.react.js'; import RequestResponseHandler from './request-response-handler.react.js'; import UpdateHandler from './update-handler.react.js'; import { updateActivityActionTypes } from '../actions/activity-actions.js'; import { logOutActionTypes } from '../actions/user-actions.js'; import { unsupervisedBackgroundActionType } from '../reducers/lifecycle-state-reducer.js'; import { pingFrequency, serverRequestSocketTimeout, clientRequestVisualTimeout, clientRequestSocketTimeout, } from '../shared/timeouts.js'; import { logInActionSources, type LogOutResult, } from '../types/account-types.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { forcePolicyAcknowledgmentActionType } from '../types/policy-types.js'; import type { Dispatch } from '../types/redux-types.js'; import { serverRequestTypes, type ClientClientResponse, type ClientServerRequest, } from '../types/request-types.js'; import { type SessionState, type SessionIdentification, type PreRequestUserState, } from '../types/session-types.js'; import { clientSocketMessageTypes, type ClientClientSocketMessage, serverSocketMessageTypes, type ClientServerSocketMessage, stateSyncPayloadTypes, fullStateSyncActionType, incrementalStateSyncActionType, updateConnectionStatusActionType, type ConnectionInfo, type ClientInitialClientSocketMessage, type ClientResponsesClientSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionStatus, setLateResponseActionType, type CommTransportLayer, } from '../types/socket-types.js'; import { actionLogger } from '../utils/action-logger.js'; import type { DispatchActionPromise } from '../utils/action-utils.js'; import { setNewSessionActionType, fetchNewCookieFromNativeCredentials, } from '../utils/action-utils.js'; import { getConfig } from '../utils/config.js'; import { ServerError } from '../utils/errors.js'; import { promiseAll } from '../utils/promises.js'; import sleep from '../utils/sleep.js'; const remainingTimeAfterVisualTimeout = clientRequestSocketTimeout - clientRequestVisualTimeout; export type BaseSocketProps = { +detectUnsupervisedBackgroundRef?: ( detectUnsupervisedBackground: (alreadyClosed: boolean) => boolean, ) => void, }; type Props = { ...BaseSocketProps, // Redux state +active: boolean, +openSocket: () => CommTransportLayer, +getClientResponses: ( activeServerRequests: $ReadOnlyArray, ) => $ReadOnlyArray, +activeThread: ?string, +sessionStateFunc: () => SessionState, +sessionIdentification: SessionIdentification, +cookie: ?string, +urlPrefix: string, +connection: ConnectionInfo, +currentCalendarQuery: () => CalendarQuery, +canSendReports: boolean, +frozen: boolean, +preRequestUserState: PreRequestUserState, +noDataAfterPolicyAcknowledgment?: boolean, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logOut: (preRequestUserState: PreRequestUserState) => Promise, +socketCrashLoopRecovery?: () => Promise, }; type State = { +inflightRequests: ?InflightRequests, }; class Socket extends React.PureComponent { state: State = { inflightRequests: null, }; socket: ?CommTransportLayer; nextClientMessageID: number = 0; listeners: Set = new Set(); pingTimeoutID: ?TimeoutID; messageLastReceived: ?number; initialPlatformDetailsSent: boolean = getConfig().platformDetails.platform === 'web'; reopenConnectionAfterClosing: boolean = false; invalidationRecoveryInProgress: boolean = false; initializedWithUserState: ?PreRequestUserState; failuresAfterPolicyAcknowledgment: number = 0; openSocket(newStatus: ConnectionStatus) { if ( this.props.frozen || (getConfig().platformDetails.platform !== 'web' && (!this.props.cookie || !this.props.cookie.startsWith('user='))) ) { return; } if (this.socket) { const { status } = this.props.connection; if (status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = true; return; } else if (status === 'disconnecting' && this.socket.readyState === 1) { this.markSocketInitialized(); return; } else if ( status === 'connected' || status === 'connecting' || status === 'reconnecting' ) { return; } if (this.socket.readyState < 2) { this.socket.close(); console.log(`this.socket seems open, but Redux thinks it's ${status}`); } } this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: newStatus }, }); const socket = this.props.openSocket(); const openObject = {}; socket.onopen = () => { if (this.socket === socket) { this.initializeSocket(); openObject.initializeMessageSent = true; } }; socket.onmessage = this.receiveMessage; socket.onclose = () => { if (this.socket === socket) { this.onClose(); } }; this.socket = socket; (async () => { await sleep(clientRequestVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.setLateResponse(-1, true); await sleep(remainingTimeAfterVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.finishClosingSocket(); })(); this.setState({ inflightRequests: new InflightRequests({ timeout: () => { if (this.socket === socket) { this.finishClosingSocket(); } }, setLateResponse: (messageID: number, isLate: boolean) => { if (this.socket === socket) { this.setLateResponse(messageID, isLate); } }, }), }); } markSocketInitialized() { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'connected' }, }); this.resetPing(); } closeSocket( // This param is a hack. When closing a socket there is a race between this // function and the one to propagate the activity update. We make sure that // the activity update wins the race by passing in this param. activityUpdatePending: boolean, ) { const { status } = this.props.connection; if (status === 'disconnected') { return; } else if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = false; return; } this.stopPing(); this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnecting' }, }); if (!activityUpdatePending) { this.finishClosingSocket(); } } forceCloseSocket() { this.stopPing(); const { status } = this.props.connection; if (status !== 'forcedDisconnecting' && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'forcedDisconnecting' }, }); } this.finishClosingSocket(); } finishClosingSocket(receivedResponseTo?: ?number) { const { inflightRequests } = this.state; if ( inflightRequests && !inflightRequests.allRequestsResolvedExcept(receivedResponseTo) ) { return; } if (this.socket && this.socket.readyState < 2) { // If it's not closing already, close it this.socket.close(); } this.socket = null; this.stopPing(); this.setState({ inflightRequests: null }); if (this.props.connection.status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } if (this.reopenConnectionAfterClosing) { this.reopenConnectionAfterClosing = false; if (this.props.active) { this.openSocket('connecting'); } } } reconnect: $Call void, number> = _throttle( () => this.openSocket('reconnecting'), 2000, ); componentDidMount() { if (this.props.detectUnsupervisedBackgroundRef) { this.props.detectUnsupervisedBackgroundRef( this.detectUnsupervisedBackground, ); } if (this.props.active) { this.openSocket('connecting'); } } componentWillUnmount() { this.closeSocket(false); this.reconnect.cancel(); } componentDidUpdate(prevProps: Props) { if (this.props.active && !prevProps.active) { this.openSocket('connecting'); } else if (!this.props.active && prevProps.active) { this.closeSocket(!!prevProps.activeThread); } else if ( this.props.active && prevProps.openSocket !== this.props.openSocket ) { // This case happens when the baseURL/urlPrefix is changed this.reopenConnectionAfterClosing = true; this.forceCloseSocket(); } else if ( this.props.active && this.props.connection.status === 'disconnected' && prevProps.connection.status !== 'disconnected' && !this.invalidationRecoveryInProgress ) { this.reconnect(); } } render(): React.Node { // It's important that APIRequestHandler get rendered first here. This is so // that it is registered with Redux first, so that its componentDidUpdate // processes before the other Handlers. This allows APIRequestHandler to // register itself with action-utils before other Handlers call // dispatchActionPromise in response to the componentDidUpdate triggered by // the same Redux change (state.connection.status). return ( ); } - sendMessageWithoutID: ( - message: ClientSocketMessageWithoutID, - ) => number = message => { - const id = this.nextClientMessageID++; - // These conditions all do the same thing and the runtime checks are only - // necessary for Flow - if (message.type === clientSocketMessageTypes.INITIAL) { - this.sendMessage(({ ...message, id }: ClientInitialClientSocketMessage)); - } else if (message.type === clientSocketMessageTypes.RESPONSES) { - this.sendMessage( - ({ ...message, id }: ClientResponsesClientSocketMessage), - ); - } else if (message.type === clientSocketMessageTypes.PING) { - this.sendMessage(({ ...message, id }: PingClientSocketMessage)); - } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { - this.sendMessage(({ ...message, id }: AckUpdatesClientSocketMessage)); - } else if (message.type === clientSocketMessageTypes.API_REQUEST) { - this.sendMessage(({ ...message, id }: APIRequestClientSocketMessage)); - } - return id; - }; + sendMessageWithoutID: (message: ClientSocketMessageWithoutID) => number = + message => { + const id = this.nextClientMessageID++; + // These conditions all do the same thing and the runtime checks are only + // necessary for Flow + if (message.type === clientSocketMessageTypes.INITIAL) { + this.sendMessage( + ({ ...message, id }: ClientInitialClientSocketMessage), + ); + } else if (message.type === clientSocketMessageTypes.RESPONSES) { + this.sendMessage( + ({ ...message, id }: ClientResponsesClientSocketMessage), + ); + } else if (message.type === clientSocketMessageTypes.PING) { + this.sendMessage(({ ...message, id }: PingClientSocketMessage)); + } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { + this.sendMessage(({ ...message, id }: AckUpdatesClientSocketMessage)); + } else if (message.type === clientSocketMessageTypes.API_REQUEST) { + this.sendMessage(({ ...message, id }: APIRequestClientSocketMessage)); + } + return id; + }; sendMessage(message: ClientClientSocketMessage) { const socket = this.socket; invariant(socket, 'should be set'); socket.send(JSON.stringify(message)); } static messageFromEvent(event: MessageEvent): ?ClientServerSocketMessage { if (typeof event.data !== 'string') { console.log('socket received a non-string message'); return null; } try { return JSON.parse(event.data); } catch (e) { console.log(e); return null; } } receiveMessage: (event: MessageEvent) => Promise = async event => { const message = Socket.messageFromEvent(event); if (!message) { return; } this.failuresAfterPolicyAcknowledgment = 0; const { inflightRequests } = this.state; if (!inflightRequests) { // inflightRequests can be falsey here if we receive a message after we've // begun shutting down the socket. It's possible for a React Native // WebSocket to deliver a message even after close() is called on it. In // this case the message is probably a PONG, which we can safely ignore. // If it's not a PONG, it has to be something server-initiated (like // UPDATES or MESSAGES), since InflightRequests.allRequestsResolvedExcept // will wait for all responses to client-initiated requests to be // delivered before closing a socket. UPDATES and MESSAGES are both // checkpointed on the client, so should be okay to just ignore here and // redownload them later, probably in an incremental STATE_SYNC. return; } // If we receive any message, that indicates that our connection is healthy, // so we can reset the ping timeout. this.resetPing(); inflightRequests.resolveRequestsForMessage(message); const { status } = this.props.connection; if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.finishClosingSocket( // We do this for Flow message.responseTo !== undefined ? message.responseTo : null, ); } for (const listener of this.listeners) { listener(message); } if (message.type === serverSocketMessageTypes.ERROR) { const { message: errorMessage, payload } = message; if (payload) { console.log(`socket sent error ${errorMessage} with payload`, payload); } else { console.log(`socket sent error ${errorMessage}`); } if (errorMessage === 'policies_not_accepted') { this.props.dispatch({ type: forcePolicyAcknowledgmentActionType, payload, }); } } else if (message.type === serverSocketMessageTypes.AUTH_ERROR) { const { sessionChange } = message; const cookie = sessionChange ? sessionChange.cookie : this.props.cookie; this.invalidationRecoveryInProgress = true; const recoverySessionChange = await fetchNewCookieFromNativeCredentials( this.props.dispatch, cookie, this.props.urlPrefix, logInActionSources.socketAuthErrorResolutionAttempt, ); if (!recoverySessionChange && sessionChange) { // This should only happen in the cookieSources.BODY (native) case when // the resolution attempt failed const { cookie: newerCookie, currentUserInfo } = sessionChange; this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: true, currentUserInfo, cookie: newerCookie, }, preRequestUserState: this.initializedWithUserState, error: null, logInActionSource: logInActionSources.socketAuthErrorResolutionAttempt, }, }); } else if (!recoverySessionChange) { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } this.invalidationRecoveryInProgress = false; } }; addListener: (listener: SocketListener) => void = listener => { this.listeners.add(listener); }; removeListener: (listener: SocketListener) => void = listener => { this.listeners.delete(listener); }; onClose: () => void = () => { const { status } = this.props.connection; this.socket = null; this.stopPing(); if (this.state.inflightRequests) { this.state.inflightRequests.rejectAll(new Error('socket closed')); this.setState({ inflightRequests: null }); } const handled = this.detectUnsupervisedBackground(true); if (!handled && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } }; async sendInitialMessage() { const { inflightRequests } = this.state; invariant( inflightRequests, 'inflightRequests falsey inside sendInitialMessage', ); const messageID = this.nextClientMessageID++; const promises = {}; const clientResponses = []; if (!this.initialPlatformDetailsSent) { this.initialPlatformDetailsSent = true; clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } const { queuedActivityUpdates } = this.props.connection; if (queuedActivityUpdates.length > 0) { clientResponses.push({ type: serverRequestTypes.INITIAL_ACTIVITY_UPDATES, activityUpdates: queuedActivityUpdates, }); promises.activityUpdateMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, ); } const sessionState = this.props.sessionStateFunc(); const { sessionIdentification } = this.props; const initialMessage = { type: clientSocketMessageTypes.INITIAL, id: messageID, payload: { clientResponses, sessionState, sessionIdentification, }, }; this.initializedWithUserState = this.props.preRequestUserState; this.sendMessage(initialMessage); promises.stateSyncMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.STATE_SYNC, ); const { stateSyncMessage, activityUpdateMessage } = await promiseAll( promises, ); if (activityUpdateMessage) { this.props.dispatch({ type: updateActivityActionTypes.success, payload: { activityUpdates: queuedActivityUpdates, result: activityUpdateMessage.payload, }, }); } if (stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL) { const { sessionID, type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: fullStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); if (sessionID !== null && sessionID !== undefined) { invariant( this.initializedWithUserState, 'initializedWithUserState should be set when state sync received', ); this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: false, sessionID }, preRequestUserState: this.initializedWithUserState, error: null, logInActionSource: undefined, }, }); } } else { const { type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: incrementalStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); } const currentAsOf = stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL ? stateSyncMessage.payload.updatesCurrentAsOf : stateSyncMessage.payload.updatesResult.currentAsOf; this.sendMessageWithoutID({ type: clientSocketMessageTypes.ACK_UPDATES, payload: { currentAsOf }, }); this.markSocketInitialized(); } initializeSocket: (retriesLeft?: number) => Promise = async ( retriesLeft = 1, ) => { try { await this.sendInitialMessage(); } catch (e) { if (this.props.noDataAfterPolicyAcknowledgment) { this.failuresAfterPolicyAcknowledgment++; } else { this.failuresAfterPolicyAcknowledgment = 0; } if ( this.failuresAfterPolicyAcknowledgment >= 2 && this.props.socketCrashLoopRecovery ) { this.failuresAfterPolicyAcknowledgment = 0; try { await this.props.socketCrashLoopRecovery(); } catch (error) { console.log(error); this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } return; } console.log(e); const { status } = this.props.connection; if ( e instanceof SocketTimeout || e instanceof SocketOffline || (status !== 'connecting' && status !== 'reconnecting') ) { // This indicates that the socket will be closed. Do nothing, since the // connection status update will trigger a reconnect. } else if ( retriesLeft === 0 || (e instanceof ServerError && e.message !== 'unknown_error') ) { if (e.message === 'not_logged_in') { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } else if (this.socket) { this.socket.close(); } } else { await this.initializeSocket(retriesLeft - 1); } } }; stopPing() { if (this.pingTimeoutID) { clearTimeout(this.pingTimeoutID); this.pingTimeoutID = null; } } resetPing() { this.stopPing(); const socket = this.socket; this.messageLastReceived = Date.now(); this.pingTimeoutID = setTimeout(() => { if (this.socket === socket) { this.sendPing(); } }, pingFrequency); } async sendPing() { if (this.props.connection.status !== 'connected') { // This generally shouldn't happen because anything that changes the // connection status should call stopPing(), but it's good to make sure return; } const messageID = this.sendMessageWithoutID({ type: clientSocketMessageTypes.PING, }); try { invariant( this.state.inflightRequests, 'inflightRequests falsey inside sendPing', ); await this.state.inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.PONG, ); } catch (e) {} } setLateResponse: (messageID: number, isLate: boolean) => void = ( messageID, isLate, ) => { this.props.dispatch({ type: setLateResponseActionType, payload: { messageID, isLate }, }); }; cleanUpServerTerminatedSocket() { if (this.socket && this.socket.readyState < 2) { this.socket.close(); } else { this.onClose(); } } - detectUnsupervisedBackground: ( - alreadyClosed: boolean, - ) => boolean = alreadyClosed => { - // On native, sometimes the app is backgrounded without the proper callbacks - // getting triggered. This leaves us in an incorrect state for two reasons: - // (1) The connection is still considered to be active, causing API requests - // to be processed via socket and failing. - // (2) We rely on flipping foreground state in Redux to detect activity - // changes, and thus won't think we need to update activity. - if ( - this.props.connection.status !== 'connected' || - !this.messageLastReceived || - this.messageLastReceived + serverRequestSocketTimeout >= Date.now() || - (actionLogger.mostRecentActionTime && - actionLogger.mostRecentActionTime + 3000 < Date.now()) - ) { - return false; - } - if (!alreadyClosed) { - this.cleanUpServerTerminatedSocket(); - } - this.props.dispatch({ - type: unsupervisedBackgroundActionType, - payload: null, - }); - return true; - }; + detectUnsupervisedBackground: (alreadyClosed: boolean) => boolean = + alreadyClosed => { + // On native, sometimes the app is backgrounded without the proper + // callbacks getting triggered. This leaves us in an incorrect state for + // two reasons: + // (1) The connection is still considered to be active, causing API + // requests to be processed via socket and failing. + // (2) We rely on flipping foreground state in Redux to detect activity + // changes, and thus won't think we need to update activity. + if ( + this.props.connection.status !== 'connected' || + !this.messageLastReceived || + this.messageLastReceived + serverRequestSocketTimeout >= Date.now() || + (actionLogger.mostRecentActionTime && + actionLogger.mostRecentActionTime + 3000 < Date.now()) + ) { + return false; + } + if (!alreadyClosed) { + this.cleanUpServerTerminatedSocket(); + } + this.props.dispatch({ + type: unsupervisedBackgroundActionType, + payload: null, + }); + return true; + }; } export default Socket; diff --git a/lib/types/account-types.js b/lib/types/account-types.js index c0f40c13c..1f726acf7 100644 --- a/lib/types/account-types.js +++ b/lib/types/account-types.js @@ -1,191 +1,190 @@ // @flow import type { PlatformDetails } from './device-types.js'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types.js'; import type { RawMessageInfo, MessageTruncationStatuses, GenericMessagesResult, } from './message-types.js'; import type { PreRequestUserState } from './session-types.js'; import type { RawThreadInfo } from './thread-types.js'; import type { UserInfo, LoggedOutUserInfo, LoggedInUserInfo, OldLoggedInUserInfo, } from './user-types.js'; import type { PolicyType } from '../facts/policies.js'; import { values } from '../utils/objects.js'; export type ResetPasswordRequest = { +usernameOrEmail: string, }; export type LogOutResult = { +currentUserInfo: ?LoggedOutUserInfo, +preRequestUserState: PreRequestUserState, }; export type LogOutResponse = { +currentUserInfo: LoggedOutUserInfo, }; export type RegisterInfo = { ...LogInExtraInfo, +username: string, +password: string, +primaryIdentityPublicKey?: string, }; type DeviceTokenUpdateRequest = { +deviceToken: string, }; export type RegisterRequest = { +username: string, +password: string, +calendarQuery?: ?CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +primaryIdentityPublicKey?: ?string, }; export type RegisterResponse = { id: string, rawMessageInfos: $ReadOnlyArray, currentUserInfo: OldLoggedInUserInfo | LoggedInUserInfo, cookieChange: { threadInfos: { +[id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, }, }; export type RegisterResult = { +currentUserInfo: LoggedInUserInfo, +rawMessageInfos: $ReadOnlyArray, +threadInfos: { +[id: string]: RawThreadInfo }, +userInfos: $ReadOnlyArray, +calendarQuery: CalendarQuery, }; export type DeleteAccountRequest = { +password: ?string, }; export const logInActionSources = Object.freeze({ cookieInvalidationResolutionAttempt: 'COOKIE_INVALIDATION_RESOLUTION_ATTEMPT', appStartCookieLoggedInButInvalidRedux: 'APP_START_COOKIE_LOGGED_IN_BUT_INVALID_REDUX', appStartReduxLoggedInButInvalidCookie: 'APP_START_REDUX_LOGGED_IN_BUT_INVALID_COOKIE', socketAuthErrorResolutionAttempt: 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT', sqliteOpFailure: 'SQLITE_OP_FAILURE', sqliteLoadFailure: 'SQLITE_LOAD_FAILURE', logInFromWebForm: 'LOG_IN_FROM_WEB_FORM', logInFromNativeForm: 'LOG_IN_FROM_NATIVE_FORM', logInFromNativeSIWE: 'LOG_IN_FROM_NATIVE_SIWE', corruptedDatabaseDeletion: 'CORRUPTED_DATABASE_DELETION', refetchUserDataAfterAcknowledgment: 'REFETCH_USER_DATA_AFTER_ACKNOWLEDGMENT', }); export type LogInActionSource = $Values; export type LogInStartingPayload = { +calendarQuery: CalendarQuery, +logInActionSource?: LogInActionSource, }; export type LogInExtraInfo = { +calendarQuery: CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +primaryIdentityPublicKey?: string, }; export type LogInInfo = { ...LogInExtraInfo, +username: string, +password: string, +logInActionSource: LogInActionSource, }; export type LogInRequest = { +usernameOrEmail?: ?string, +username?: ?string, +password: string, +calendarQuery?: ?CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +watchedIDs: $ReadOnlyArray, +source?: LogInActionSource, }; export type LogInResponse = { +currentUserInfo: LoggedInUserInfo | OldLoggedInUserInfo, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: $ReadOnlyArray, +rawEntryInfos?: ?$ReadOnlyArray, +serverTime: number, +cookieChange: { +threadInfos: { +[id: string]: RawThreadInfo }, +userInfos: $ReadOnlyArray, }, +notAcknowledgedPolicies?: $ReadOnlyArray, }; export type LogInResult = { +threadInfos: { +[id: string]: RawThreadInfo }, +currentUserInfo: LoggedInUserInfo, +messagesResult: GenericMessagesResult, +userInfos: $ReadOnlyArray, +calendarResult: CalendarResult, +updatesCurrentAsOf: number, +logInActionSource: LogInActionSource, +notAcknowledgedPolicies?: $ReadOnlyArray, }; export type UpdatePasswordRequest = { code: string, password: string, calendarQuery?: ?CalendarQuery, deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, platformDetails: PlatformDetails, watchedIDs: $ReadOnlyArray, }; export type PolicyAcknowledgmentRequest = { +policy: PolicyType, }; export type EmailSubscriptionRequest = { +email: string, }; export type UpdateUserSettingsRequest = { +name: 'default_user_notifications', +data: NotificationTypes, }; export const userSettingsTypes = Object.freeze({ DEFAULT_NOTIFICATIONS: 'default_user_notifications', }); export type DefaultNotificationPayload = { +default_user_notifications: ?NotificationTypes, }; export const notificationTypes = Object.freeze({ FOCUSED: 'focused', BADGE_ONLY: 'badge_only', BACKGROUND: 'background', }); export type NotificationTypes = $Values; -export const notificationTypeValues: $ReadOnlyArray = values( - notificationTypes, -); +export const notificationTypeValues: $ReadOnlyArray = + values(notificationTypes); diff --git a/lib/types/relationship-types.js b/lib/types/relationship-types.js index ae25f7a39..f03553799 100644 --- a/lib/types/relationship-types.js +++ b/lib/types/relationship-types.js @@ -1,77 +1,76 @@ // @flow import type { AccountUserInfo } from './user-types.js'; import { values } from '../utils/objects.js'; export const undirectedStatus = Object.freeze({ KNOW_OF: 0, FRIEND: 2, }); export type UndirectedStatus = $Values; export const directedStatus = Object.freeze({ PENDING_FRIEND: 1, BLOCKED: 3, }); export type DirectedStatus = $Values; export const userRelationshipStatus = Object.freeze({ REQUEST_SENT: 1, REQUEST_RECEIVED: 2, FRIEND: 3, BLOCKED_BY_VIEWER: 4, BLOCKED_VIEWER: 5, BOTH_BLOCKED: 6, }); export type UserRelationshipStatus = $Values; export const relationshipActions = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', }); export type RelationshipAction = $Values; -export const relationshipActionsList: $ReadOnlyArray = values( - relationshipActions, -); +export const relationshipActionsList: $ReadOnlyArray = + values(relationshipActions); export const relationshipButtons = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', ACCEPT: 'accept', WITHDRAW: 'withdraw', REJECT: 'reject', }); export type RelationshipButton = $Values; export type RelationshipRequest = { action: RelationshipAction, userIDs: $ReadOnlyArray, }; type SharedRelationshipRow = { user1: string, user2: string, }; export type DirectedRelationshipRow = { ...SharedRelationshipRow, status: DirectedStatus, }; export type UndirectedRelationshipRow = { ...SharedRelationshipRow, status: UndirectedStatus, }; export type RelationshipErrors = $Shape<{ invalid_user: string[], already_friends: string[], user_blocked: string[], }>; export type UserRelationships = { +friends: $ReadOnlyArray, +blocked: $ReadOnlyArray, }; diff --git a/lib/utils/action-logger.js b/lib/utils/action-logger.js index 4fc69350d..32eb01bf3 100644 --- a/lib/utils/action-logger.js +++ b/lib/utils/action-logger.js @@ -1,180 +1,178 @@ // @flow import { type Middleware } from 'redux'; import inspect from 'util-inspect'; import { sanitizeActionSecrets } from './sanitization.js'; import { rehydrateActionType } from '../types/redux-types.js'; import type { ActionSummary } from '../types/report-types.js'; const uninterestingActionTypes = new Set(['Navigation/COMPLETE_TRANSITION']); const maxActionSummaryLength = 500; type Subscriber = (action: Object, state: Object) => void; class ActionLogger { static n: number = 30; lastNActions: Array<{ +action: Object, +time: number }> = []; lastNStates: Array<{ +state: Object, +time: number }> = []; currentReduxState: Object = undefined; currentOtherStates: { [key: string]: Object } = {}; subscribers: Subscriber[] = []; get preloadedState(): Object { return this.lastNStates[0].state; } get actions(): Object[] { return this.lastNActions.map(({ action }) => action); } get interestingActionSummaries(): ActionSummary[] { return this.lastNActions .filter(({ action }) => !uninterestingActionTypes.has(action.type)) .map(({ action, time }) => ({ type: action.type, time, summary: ActionLogger.getSummaryForAction(action), })); } static getSummaryForAction(action: Object): string { const sanitized = sanitizeActionSecrets(action); let summary, length, depth = 3; do { summary = inspect(sanitized, { depth }); length = summary.length; depth--; } while (length > maxActionSummaryLength && depth > 0); return summary; } prepareForAction() { if ( this.lastNActions.length > 0 && this.lastNActions[this.lastNActions.length - 1].action.type === rehydrateActionType ) { // redux-persist can't handle replaying REHYDRATE // https://github.com/rt2zz/redux-persist/issues/743 this.lastNActions = []; this.lastNStates = []; } if (this.lastNActions.length === ActionLogger.n) { this.lastNActions.shift(); this.lastNStates.shift(); } } addReduxAction(action: Object, beforeState: Object, afterState: Object) { this.prepareForAction(); if (this.currentReduxState === undefined) { for (let i = 0; i < this.lastNStates.length; i++) { this.lastNStates[i] = { ...this.lastNStates[i], state: { ...this.lastNStates[i].state, ...beforeState, }, }; } } this.currentReduxState = afterState; const state = { ...beforeState }; for (const stateKey in this.currentOtherStates) { state[stateKey] = this.currentOtherStates[stateKey]; } const time = Date.now(); this.lastNActions.push({ action, time }); this.lastNStates.push({ state, time }); this.triggerSubscribers(action); } addOtherAction( key: string, action: Object, beforeState: Object, afterState: Object, ) { this.prepareForAction(); const currentState = this.currentOtherStates[key]; if (currentState === undefined) { for (let i = 0; i < this.lastNStates.length; i++) { this.lastNStates[i] = { ...this.lastNStates[i], state: { ...this.lastNStates[i].state, [key]: beforeState, }, }; } } this.currentOtherStates[key] = afterState; const state = { ...this.currentState, [key]: beforeState, }; const time = Date.now(); this.lastNActions.push({ action, time }); this.lastNStates.push({ state, time }); this.triggerSubscribers(action); } get mostRecentActionTime(): ?number { if (this.lastNActions.length === 0) { return null; } return this.lastNActions[this.lastNActions.length - 1].time; } get currentState(): Object { const state = this.currentReduxState ? { ...this.currentReduxState } : {}; for (const stateKey in this.currentOtherStates) { state[stateKey] = this.currentOtherStates[stateKey]; } return state; } subscribe(subscriber: Subscriber) { this.subscribers.push(subscriber); } unsubscribe(subscriber: Subscriber) { this.subscribers = this.subscribers.filter( candidate => candidate !== subscriber, ); } triggerSubscribers(action: Object) { if (uninterestingActionTypes.has(action.type)) { return; } const state = this.currentState; this.subscribers.forEach(subscriber => subscriber(action, state)); } } const actionLogger: ActionLogger = new ActionLogger(); -const reduxLoggerMiddleware: Middleware< - Object, - Object, -> = store => next => action => { - const beforeState = store.getState(); - const result = next(action); - const afterState = store.getState(); - actionLogger.addReduxAction(action, beforeState, afterState); - return result; -}; +const reduxLoggerMiddleware: Middleware = + store => next => action => { + const beforeState = store.getState(); + const result = next(action); + const afterState = store.getState(); + actionLogger.addReduxAction(action, beforeState, afterState); + return result; + }; export { actionLogger, reduxLoggerMiddleware }; diff --git a/lib/utils/action-utils.js b/lib/utils/action-utils.js index 3adeef238..e355fe9f3 100644 --- a/lib/utils/action-utils.js +++ b/lib/utils/action-utils.js @@ -1,442 +1,441 @@ // @flow import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import callServerEndpoint from './call-server-endpoint.js'; import type { CallServerEndpoint, CallServerEndpointOptions, } from './call-server-endpoint.js'; import { getConfig } from './config.js'; import { serverCallStateSelector } from '../selectors/server-calls.js'; import { logInActionSources, type LogInActionSource, type LogInStartingPayload, type LogInResult, } from '../types/account-types.js'; import type { Endpoint, SocketAPIHandler } from '../types/endpoints.js'; import type { LoadingOptions, LoadingInfo } from '../types/loading-types.js'; import type { ActionPayload, Dispatch, PromisedAction, BaseAction, } from '../types/redux-types.js'; import type { ClientSessionChange, PreRequestUserState, } from '../types/session-types.js'; import type { ConnectionStatus } from '../types/socket-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; let nextPromiseIndex = 0; export type ActionTypes< STARTED_ACTION_TYPE: string, SUCCESS_ACTION_TYPE: string, FAILED_ACTION_TYPE: string, > = { started: STARTED_ACTION_TYPE, success: SUCCESS_ACTION_TYPE, failed: FAILED_ACTION_TYPE, }; function wrapActionPromise< STARTED_ACTION_TYPE: string, STARTED_PAYLOAD: ActionPayload, SUCCESS_ACTION_TYPE: string, SUCCESS_PAYLOAD: ActionPayload, FAILED_ACTION_TYPE: string, >( actionTypes: ActionTypes< STARTED_ACTION_TYPE, SUCCESS_ACTION_TYPE, FAILED_ACTION_TYPE, >, promise: Promise, loadingOptions: ?LoadingOptions, startingPayload: ?STARTED_PAYLOAD, ): PromisedAction { const loadingInfo: LoadingInfo = { fetchIndex: nextPromiseIndex++, trackMultipleRequests: !!loadingOptions?.trackMultipleRequests, customKeyName: loadingOptions?.customKeyName || null, }; return async (dispatch: Dispatch): Promise => { const startAction = startingPayload ? { type: (actionTypes.started: STARTED_ACTION_TYPE), loadingInfo, payload: (startingPayload: STARTED_PAYLOAD), } : { type: (actionTypes.started: STARTED_ACTION_TYPE), loadingInfo, }; dispatch(startAction); try { const result = await promise; dispatch({ type: (actionTypes.success: SUCCESS_ACTION_TYPE), payload: (result: SUCCESS_PAYLOAD), loadingInfo, }); } catch (e) { console.log(e); dispatch({ type: (actionTypes.failed: FAILED_ACTION_TYPE), error: true, payload: (e: Error), loadingInfo, }); } }; } export type DispatchActionPromise = < STARTED: BaseAction, SUCCESS: BaseAction, FAILED: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ) => Promise; function useDispatchActionPromise(): DispatchActionPromise { const dispatch = useDispatch(); return React.useMemo(() => createDispatchActionPromise(dispatch), [dispatch]); } function createDispatchActionPromise(dispatch: Dispatch) { const dispatchActionPromise = function < STARTED: BaseAction, SUCCESS: BaseAction, FAILED: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ): Promise { return dispatch( wrapActionPromise(actionTypes, promise, loadingOptions, startingPayload), ); }; return dispatchActionPromise; } export type DispatchFunctions = { +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, }; let currentlyWaitingForNewCookie = false; let serverEndpointCallsWaitingForNewCookie: (( callServerEndpoint: ?CallServerEndpoint, ) => void)[] = []; export type DispatchRecoveryAttempt = ( actionTypes: ActionTypes<'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED'>, promise: Promise, startingPayload: LogInStartingPayload, ) => Promise; const setNewSessionActionType = 'SET_NEW_SESSION'; function setNewSession( dispatch: Dispatch, sessionChange: ClientSessionChange, preRequestUserState: ?PreRequestUserState, error: ?string, logInActionSource: ?LogInActionSource, ) { dispatch({ type: setNewSessionActionType, payload: { sessionChange, preRequestUserState, error, logInActionSource }, }); } // This function calls resolveInvalidatedCookie, which dispatchs a log in action // using the native credentials. Note that we never actually specify a sessionID // here, on the assumption that only native clients will call this. (Native // clients don't specify a sessionID, indicating to the server that it should // use the cookieID as the sessionID.) async function fetchNewCookieFromNativeCredentials( dispatch: Dispatch, cookie: ?string, urlPrefix: string, logInActionSource: LogInActionSource, ): Promise { const resolveInvalidatedCookie = getConfig().resolveInvalidatedCookie; if (!resolveInvalidatedCookie) { return null; } let newSessionChange = null; let callServerEndpointCallback = null; const boundCallServerEndpoint = async ( endpoint: Endpoint, data: { [key: string]: mixed }, options?: ?CallServerEndpointOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; setNewSession(dispatch, sessionChange, null, error, logInActionSource); }; try { const result = await callServerEndpoint( cookie, innerBoundSetNewSession, () => new Promise(r => r(null)), () => new Promise(r => r(null)), urlPrefix, null, 'disconnected', null, endpoint, data, dispatch, options, ); if (callServerEndpointCallback) { callServerEndpointCallback(!!newSessionChange); } return result; } catch (e) { if (callServerEndpointCallback) { callServerEndpointCallback(!!newSessionChange); } throw e; } }; const dispatchRecoveryAttempt = ( actionTypes: ActionTypes< 'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED', >, promise: Promise, inputStartingPayload: LogInStartingPayload, ) => { const startingPayload = { ...inputStartingPayload, logInActionSource }; dispatch(wrapActionPromise(actionTypes, promise, null, startingPayload)); return new Promise(r => (callServerEndpointCallback = r)); }; await resolveInvalidatedCookie( boundCallServerEndpoint, dispatchRecoveryAttempt, logInActionSource, ); return newSessionChange; } // Third param is optional and gets called with newCookie if we get a new cookie // Necessary to propagate cookie in cookieInvalidationRecovery below function bindCookieAndUtilsIntoCallServerEndpoint( params: BindServerCallsParams, ): CallServerEndpoint { const { dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = params; const loggedIn = !!(currentUserInfo && !currentUserInfo.anonymous && true); const boundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => setNewSession( dispatch, sessionChange, { currentUserInfo, cookie, sessionID }, error, undefined, ); // This function gets called before callServerEndpoint sends a request, // to make sure that we're not in the middle of trying to recover // an invalidated cookie const waitIfCookieInvalidated = () => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // callServerEndpoint instance continue return Promise.resolve(null); } if (!currentlyWaitingForNewCookie) { // Our cookie seems to be valid return Promise.resolve(null); } // Wait to run until we get our new cookie return new Promise(r => serverEndpointCallsWaitingForNewCookie.push(r)); }; // This function is a helper for the next function defined below const attemptToResolveInvalidation = async ( sessionChange: ClientSessionChange, ) => { const newAnonymousCookie = sessionChange.cookie; const newSessionChange = await fetchNewCookieFromNativeCredentials( dispatch, newAnonymousCookie, urlPrefix, logInActionSources.cookieInvalidationResolutionAttempt, ); currentlyWaitingForNewCookie = false; const currentWaitingCalls = serverEndpointCallsWaitingForNewCookie; serverEndpointCallsWaitingForNewCookie = []; const newCallServerEndpoint = newSessionChange ? bindCookieAndUtilsIntoCallServerEndpoint({ ...params, cookie: newSessionChange.cookie, sessionID: newSessionChange.sessionID, currentUserInfo: newSessionChange.currentUserInfo, }) : null; for (const func of currentWaitingCalls) { func(newCallServerEndpoint); } return newCallServerEndpoint; }; // If this function is called, callServerEndpoint got a response invalidating // its cookie, and is wondering if it should just like... give up? // Or if there's a chance at redemption const cookieInvalidationRecovery = (sessionChange: ClientSessionChange) => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // callServerEndpoint instance continue return Promise.resolve(null); } if (!loggedIn) { // We don't want to attempt any use native credentials of a logged out // user to log-in after a cookieInvalidation while logged out return Promise.resolve(null); } if (currentlyWaitingForNewCookie) { return new Promise(r => serverEndpointCallsWaitingForNewCookie.push(r)); } currentlyWaitingForNewCookie = true; return attemptToResolveInvalidation(sessionChange); }; return ( endpoint: Endpoint, data: Object, options?: ?CallServerEndpointOptions, ) => callServerEndpoint( cookie, boundSetNewSession, waitIfCookieInvalidated, cookieInvalidationRecovery, urlPrefix, sessionID, connectionStatus, socketAPIHandler, endpoint, data, dispatch, options, ); } export type ActionFunc = (callServerEndpoint: CallServerEndpoint) => F; export type BindServerCall = (serverCall: ActionFunc) => F; type BindServerCallsParams = { dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, }; // All server calls needs to include some information from the Redux state // (namely, the cookie). This information is used deep in the server call, // at the point where callServerEndpoint is called. We don't want to bother // propagating the cookie (and any future config info that callServerEndpoint // needs) through to the server calls so they can pass it to callServerEndpoint. // Instead, we "curry" the cookie onto callServerEndpoint within react-redux's // connect's mapStateToProps function, and then pass that "bound" // callServerEndpoint that no longer needs the cookie as a parameter on to // the server call. const baseCreateBoundServerCallsSelector = ( actionFunc: ActionFunc, ): (BindServerCallsParams => F) => createSelector( (state: BindServerCallsParams) => state.dispatch, (state: BindServerCallsParams) => state.cookie, (state: BindServerCallsParams) => state.urlPrefix, (state: BindServerCallsParams) => state.sessionID, (state: BindServerCallsParams) => state.currentUserInfo, (state: BindServerCallsParams) => state.connectionStatus, ( dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, ) => { const boundCallServerEndpoint = bindCookieAndUtilsIntoCallServerEndpoint({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }); return actionFunc(boundCallServerEndpoint); }, ); type CreateBoundServerCallsSelectorType = ( ActionFunc, ) => BindServerCallsParams => F; -const createBoundServerCallsSelector: CreateBoundServerCallsSelectorType = (_memoize( - baseCreateBoundServerCallsSelector, -): any); +const createBoundServerCallsSelector: CreateBoundServerCallsSelectorType = + (_memoize(baseCreateBoundServerCallsSelector): any); function useServerCall(serverCall: ActionFunc): F { const dispatch = useDispatch(); const serverCallState = useSelector(serverCallStateSelector); return React.useMemo( () => createBoundServerCallsSelector(serverCall)({ ...serverCallState, dispatch, }), [serverCall, dispatch, serverCallState], ); } let socketAPIHandler: ?SocketAPIHandler = null; function registerActiveSocket(passedSocketAPIHandler: ?SocketAPIHandler) { socketAPIHandler = passedSocketAPIHandler; } export { useDispatchActionPromise, setNewSessionActionType, fetchNewCookieFromNativeCredentials, createBoundServerCallsSelector, registerActiveSocket, useServerCall, }; diff --git a/lib/utils/call-server-endpoint.js b/lib/utils/call-server-endpoint.js index 03d3d525c..c7d8a4d87 100644 --- a/lib/utils/call-server-endpoint.js +++ b/lib/utils/call-server-endpoint.js @@ -1,214 +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 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; } 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 66fd479eb..92d00b323 100644 --- a/lib/utils/date-utils.js +++ b/lib/utils/date-utils.js @@ -1,174 +1,175 @@ // @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; } } 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 } { +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-helpers.js b/lib/utils/entity-helpers.js index 39aa8d6c7..c578da5cf 100644 --- a/lib/utils/entity-helpers.js +++ b/lib/utils/entity-helpers.js @@ -1,121 +1,122 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ET, useENSNamesForEntityText, entityTextToRawString, } from './entity-text.js'; import type { ThreadInfo, ResolvedThreadInfo } from '../types/thread-types.js'; import { values } from '../utils/objects.js'; function useResolvedThreadInfos( threadInfos: $ReadOnlyArray, ): $ReadOnlyArray { const entityText = React.useMemo( () => threadInfos.map(threadInfo => threadInfo.uiName), [threadInfos], ); const withENSNames = useENSNamesForEntityText(entityText); invariant( withENSNames, 'useENSNamesForEntityText only returns falsey when passed falsey', ); return React.useMemo( () => threadInfos.map((threadInfo, i) => { if (typeof threadInfo.uiName === 'string') { // Flow wants return { ...threadInfo, uiName: threadInfo.uiName } // but that's wasteful and unneeded, so we any-cast here return (threadInfo: any); } const resolvedThreadEntity = withENSNames[i]; return { ...threadInfo, uiName: entityTextToRawString([resolvedThreadEntity]), }; }), [threadInfos, withENSNames], ); } function useResolvedOptionalThreadInfos( threadInfos: ?$ReadOnlyArray, ): ?$ReadOnlyArray { const entityText = React.useMemo(() => { if (!threadInfos) { return null; } return threadInfos.map(threadInfo => ET.thread({ display: 'uiName', threadInfo }), ); }, [threadInfos]); const withENSNames = useENSNamesForEntityText(entityText); return React.useMemo(() => { if (!threadInfos) { return threadInfos; } invariant( withENSNames, 'useENSNamesForEntityText only returns falsey when passed falsey', ); return threadInfos.map((threadInfo, i) => { if (typeof threadInfo.uiName === 'string') { // Flow wants return { ...threadInfo, uiName: threadInfo.uiName } // but that's wasteful and unneeded, so we any-cast here return (threadInfo: any); } const resolvedThreadEntity = withENSNames[i]; return { ...threadInfo, uiName: entityTextToRawString([resolvedThreadEntity]), }; }); }, [threadInfos, withENSNames]); } function useResolvedThreadInfosObj(threadInfosObj: { +[id: string]: ThreadInfo, }): { +[id: string]: ResolvedThreadInfo } { - const threadInfosArray = React.useMemo(() => values(threadInfosObj), [ - threadInfosObj, - ]); + const threadInfosArray = React.useMemo( + () => values(threadInfosObj), + [threadInfosObj], + ); const resolvedThreadInfosArray = useResolvedThreadInfos(threadInfosArray); return React.useMemo(() => { const obj = {}; for (const resolvedThreadInfo of resolvedThreadInfosArray) { obj[resolvedThreadInfo.id] = resolvedThreadInfo; } return obj; }, [resolvedThreadInfosArray]); } function useResolvedThreadInfo(threadInfo: ThreadInfo): ResolvedThreadInfo { const resolutionInput = React.useMemo(() => [threadInfo], [threadInfo]); const [resolvedThreadInfo] = useResolvedThreadInfos(resolutionInput); return resolvedThreadInfo; } function useResolvedOptionalThreadInfo( threadInfo: ?ThreadInfo, ): ?ResolvedThreadInfo { const resolutionInput = React.useMemo( () => (threadInfo ? [threadInfo] : []), [threadInfo], ); const [resolvedThreadInfo] = useResolvedThreadInfos(resolutionInput); if (!threadInfo) { return threadInfo; } return resolvedThreadInfo; } export { useResolvedThreadInfos, useResolvedOptionalThreadInfos, useResolvedThreadInfosObj, useResolvedThreadInfo, useResolvedOptionalThreadInfo, }; diff --git a/lib/utils/message-ops-utils.js b/lib/utils/message-ops-utils.js index efe32bd81..bd0f89a25 100644 --- a/lib/utils/message-ops-utils.js +++ b/lib/utils/message-ops-utils.js @@ -1,207 +1,207 @@ // @flow import _keyBy from 'lodash/fp/keyBy.js'; import { messageID } from '../shared/message-utils.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; import type { Media, ClientDBMediaInfo, Image, Video, } from '../types/media-types.js'; import { type ClientDBMessageInfo, type RawMessageInfo, messageTypes, assertMessageType, type MessageStoreOperation, type ClientDBMessageStoreOperation, } from '../types/message-types.js'; import type { MediaMessageServerDBContent } from '../types/messages/media.js'; function translateMediaToClientDBMediaInfos( media: $ReadOnlyArray, ): $ReadOnlyArray { const clientDBMediaInfos = []; for (const m of media) { clientDBMediaInfos.push({ id: m.id, uri: m.uri, type: m.type, extras: JSON.stringify({ dimensions: m.dimensions, loop: m.type === 'video' ? m.loop : false, local_media_selection: m.localMediaSelection, }), }); if (m.type === 'video') { clientDBMediaInfos.push({ id: m.thumbnailID, uri: m.thumbnailURI, type: 'photo', extras: JSON.stringify({ dimensions: m.dimensions, loop: false, }), }); } } return clientDBMediaInfos; } function translateClientDBMediaInfoToImage( clientDBMediaInfo: ClientDBMediaInfo, ): Image { const { dimensions, local_media_selection } = JSON.parse( clientDBMediaInfo.extras, ); if (!local_media_selection) { return { id: clientDBMediaInfo.id, uri: clientDBMediaInfo.uri, type: 'photo', dimensions: dimensions, }; } return { id: clientDBMediaInfo.id, uri: clientDBMediaInfo.uri, type: 'photo', dimensions: dimensions, localMediaSelection: local_media_selection, }; } function translateClientDBMediaInfosToMedia( clientDBMessageInfo: ClientDBMessageInfo, ): $ReadOnlyArray { if (parseInt(clientDBMessageInfo.type) === messageTypes.IMAGES) { if (!clientDBMessageInfo.media_infos) { return []; } return clientDBMessageInfo.media_infos.map( translateClientDBMediaInfoToImage, ); } if (!clientDBMessageInfo.media_infos) { return []; } const mediaInfos: $ReadOnlyArray = clientDBMessageInfo.media_infos; const mediaMap = _keyBy('id')(mediaInfos); if (!clientDBMessageInfo.content) { return []; } - const messageContent: $ReadOnlyArray = JSON.parse( - clientDBMessageInfo.content, - ); + const messageContent: $ReadOnlyArray = + JSON.parse(clientDBMessageInfo.content); const translatedMedia = []; for (const media of messageContent) { if (media.type === 'photo') { const extras = JSON.parse(mediaMap[media.uploadID].extras); const { dimensions } = extras; const image: Image = { id: media.uploadID, uri: mediaMap[media.uploadID].uri, type: 'photo', dimensions, }; translatedMedia.push(image); } else if (media.type === 'video') { const extras = JSON.parse(mediaMap[media.uploadID].extras); const { dimensions, loop, local_media_selection: localMediaSelection, } = extras; const video: Video = { id: media.uploadID, uri: mediaMap[media.uploadID].uri, type: 'video', dimensions, loop, thumbnailID: media.thumbnailUploadID, thumbnailURI: mediaMap[media.thumbnailUploadID].uri, }; translatedMedia.push( localMediaSelection ? { ...video, localMediaSelection } : video, ); } } return translatedMedia; } function translateRawMessageInfoToClientDBMessageInfo( rawMessageInfo: RawMessageInfo, ): ClientDBMessageInfo { return { id: messageID(rawMessageInfo), local_id: rawMessageInfo.localID ? rawMessageInfo.localID : null, thread: rawMessageInfo.threadID, user: rawMessageInfo.creatorID, type: rawMessageInfo.type.toString(), future_type: rawMessageInfo.type === messageTypes.UNSUPPORTED ? rawMessageInfo.unsupportedMessageInfo.type.toString() : null, time: rawMessageInfo.time.toString(), - content: messageSpecs[rawMessageInfo.type].messageContentForClientDB?.( - rawMessageInfo, - ), + content: + messageSpecs[rawMessageInfo.type].messageContentForClientDB?.( + rawMessageInfo, + ), media_infos: rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ? translateMediaToClientDBMediaInfos(rawMessageInfo.media) : null, }; } function translateClientDBMessageInfoToRawMessageInfo( clientDBMessageInfo: ClientDBMessageInfo, ): RawMessageInfo { return messageSpecs[ assertMessageType(parseInt(clientDBMessageInfo.type)) ].rawMessageInfoFromClientDB(clientDBMessageInfo); } function translateClientDBMessageInfosToRawMessageInfos( clientDBMessageInfos: $ReadOnlyArray, ): { +[id: string]: RawMessageInfo } { return Object.fromEntries( clientDBMessageInfos.map((dbMessageInfo: ClientDBMessageInfo) => [ dbMessageInfo.id, translateClientDBMessageInfoToRawMessageInfo(dbMessageInfo), ]), ); } function convertMessageStoreOperationsToClientDBOperations( messageStoreOperations: $ReadOnlyArray, ): $ReadOnlyArray { return messageStoreOperations.map(messageStoreOperation => { if (messageStoreOperation.type !== 'replace') { return messageStoreOperation; } return { type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo( messageStoreOperation.payload.messageInfo, ), }; }); } export { translateClientDBMediaInfoToImage, translateRawMessageInfoToClientDBMessageInfo, translateClientDBMessageInfoToRawMessageInfo, translateClientDBMessageInfosToRawMessageInfos, convertMessageStoreOperationsToClientDBOperations, translateClientDBMediaInfosToMedia, }; diff --git a/lib/utils/message-ops-utils.test.js b/lib/utils/message-ops-utils.test.js index aa610b3b2..9736e864b 100644 --- a/lib/utils/message-ops-utils.test.js +++ b/lib/utils/message-ops-utils.test.js @@ -1,451 +1,446 @@ // @flow import { translateRawMessageInfoToClientDBMessageInfo, translateClientDBMessageInfoToRawMessageInfo, translateClientDBMediaInfosToMedia, } from './message-ops-utils.js'; import type { ClientDBMessageInfo, RawSidebarSourceMessageInfo, } from '../types/message-types.js'; import type { RawAddMembersMessageInfo } from '../types/messages/add-members.js'; import type { RawChangeSettingsMessageInfo } from '../types/messages/change-settings.js'; import type { RawCreateEntryMessageInfo } from '../types/messages/create-entry.js'; import type { RawCreateSidebarMessageInfo } from '../types/messages/create-sidebar.js'; import type { RawCreateSubthreadMessageInfo } from '../types/messages/create-subthread.js'; import type { RawCreateThreadMessageInfo } from '../types/messages/create-thread.js'; import type { RawDeleteEntryMessageInfo } from '../types/messages/delete-entry.js'; import type { RawEditEntryMessageInfo } from '../types/messages/edit-entry.js'; import type { RawImagesMessageInfo } from '../types/messages/images.js'; import type { RawJoinThreadMessageInfo } from '../types/messages/join-thread.js'; import type { RawLeaveThreadMessageInfo } from '../types/messages/leave-thread.js'; import type { RawRemoveMembersMessageInfo } from '../types/messages/remove-members.js'; import type { RawRestoreEntryMessageInfo } from '../types/messages/restore-entry.js'; import type { RawTextMessageInfo } from '../types/messages/text.js'; import type { RawUpdateRelationshipMessageInfo } from '../types/messages/update-relationship.js'; test('TEXT: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawTextMessageInfo: RawTextMessageInfo = { type: 0, localID: 'local7', threadID: '85466', text: 'Hello world', creatorID: '85435', time: 1637788332565, id: '85551', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawTextMessageInfo), ), ).toStrictEqual(rawTextMessageInfo); }); test('TEXT (local): rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const localRawTextMessageInfo: RawTextMessageInfo = { type: 0, localID: 'local7', threadID: '85466', text: 'Hello world', creatorID: '85435', time: 1637788332565, }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(localRawTextMessageInfo), ), ).toStrictEqual(localRawTextMessageInfo); }); test('CREATE_THREAD: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawCreateThreadMessageInfo: RawCreateThreadMessageInfo = { type: 1, threadID: '85466', creatorID: '85435', time: 1637778853178, initialThreadState: { type: 6, name: null, parentThreadID: '1', color: '648CAA', memberIDs: ['256', '85435'], }, id: '85482', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawCreateThreadMessageInfo), ), ).toStrictEqual(rawCreateThreadMessageInfo); }); test('ADD_MEMBER: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawAddMemberMessageInfo: RawAddMembersMessageInfo = { type: 2, threadID: '85946', creatorID: '83809', time: 1638236346010, addedUserIDs: ['256'], id: '85986', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawAddMemberMessageInfo), ), ).toStrictEqual(rawAddMemberMessageInfo); }); test('CREATE_SUB_THREAD: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawCreateSubthreadMessageInfo: RawCreateSubthreadMessageInfo = { type: 3, threadID: '85946', creatorID: '83809', time: 1638237345553, childThreadID: '85990', id: '85997', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo( rawCreateSubthreadMessageInfo, ), ), ).toStrictEqual(rawCreateSubthreadMessageInfo); }); test('CHANGE_SETTINGS: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawChangeSettingsMessageInfo: RawChangeSettingsMessageInfo = { type: 4, threadID: '85946', creatorID: '83809', time: 1638236125774, field: 'color', value: '009cc8', id: '85972', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo( rawChangeSettingsMessageInfo, ), ), ).toStrictEqual(rawChangeSettingsMessageInfo); }); test('REMOVE_MEMBERS: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawRemoveMembersMessageInfo: RawRemoveMembersMessageInfo = { type: 5, threadID: '85990', creatorID: '83809', time: 1638237832234, removedUserIDs: ['85435'], id: '86014', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawRemoveMembersMessageInfo), ), ).toStrictEqual(rawRemoveMembersMessageInfo); }); test('LEAVE_THREAD: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawLeaveThreadMessageInfo: RawLeaveThreadMessageInfo = { type: 7, id: '86088', threadID: '85946', time: 1638238389038, creatorID: '85435', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawLeaveThreadMessageInfo), ), ).toStrictEqual(rawLeaveThreadMessageInfo); }); test('JOIN_THREAD: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawJoinThreadMessageInfo: RawJoinThreadMessageInfo = { type: 8, threadID: '86125', creatorID: '85435', time: 1638239691665, id: '86149', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawJoinThreadMessageInfo), ), ).toStrictEqual(rawJoinThreadMessageInfo); }); test('CREATE_ENTRY: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawCreateEntryMessageInfo: RawCreateEntryMessageInfo = { type: 9, threadID: '85630', creatorID: '85435', time: 1638239928303, entryID: '86151', date: '2021-11-29', text: 'Hello world', id: '86154', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawCreateEntryMessageInfo), ), ).toStrictEqual(rawCreateEntryMessageInfo); }); test('EDIT_ENTRY: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawEditEntryMessageInfo: RawEditEntryMessageInfo = { type: 10, threadID: '85630', creatorID: '85435', time: 1638240110661, entryID: '86151', date: '2021-11-29', text: 'Hello universe', id: '86179', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawEditEntryMessageInfo), ), ).toStrictEqual(rawEditEntryMessageInfo); }); test('DELETE_ENTRY: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawDeleteEntryMessageInfo: RawDeleteEntryMessageInfo = { type: 11, threadID: '85630', creatorID: '85435', time: 1638240286574, entryID: '86151', date: '2021-11-29', text: 'Hello universe', id: '86189', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawDeleteEntryMessageInfo), ), ).toStrictEqual(rawDeleteEntryMessageInfo); }); test('RESTORE_ENTRY: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawRestoreEntryMessageInfo: RawRestoreEntryMessageInfo = { type: 12, threadID: '85630', creatorID: '83809', time: 1638240605195, entryID: '86151', date: '2021-11-29', text: 'Hello universe', id: '86211', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawRestoreEntryMessageInfo), ), ).toStrictEqual(rawRestoreEntryMessageInfo); }); test('IMAGES: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawImagesMessageInfo: RawImagesMessageInfo = { type: 14, threadID: '85466', creatorID: '85435', time: 1637779260087, media: [ { id: '85504', type: 'photo', uri: 'http://localhost/comm/upload/85504/ba36cea2b5a796f6', dimensions: { width: 1920, height: 1281, }, }, ], id: '85505', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawImagesMessageInfo), ), ).toStrictEqual(rawImagesMessageInfo); }); test('IMAGES (local): rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const localRawImagesMessageInfo: RawImagesMessageInfo = { type: 14, threadID: '85466', creatorID: '85435', time: 1637779260087, media: [ { id: '85504', type: 'photo', uri: 'http://localhost/comm/upload/85504/ba36cea2b5a796f6', dimensions: { width: 1920, height: 1281, }, }, ], localID: 'local123', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(localRawImagesMessageInfo), ), ).toStrictEqual(localRawImagesMessageInfo); }); test('UPDATE_RELATIONSHIP: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawUpdateRelationshipMessageInfo: RawUpdateRelationshipMessageInfo = { type: 16, id: '85651', threadID: '85630', time: 1638235869690, creatorID: '83809', targetID: '85435', operation: 'request_accepted', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo( rawUpdateRelationshipMessageInfo, ), ), ).toStrictEqual(rawUpdateRelationshipMessageInfo); }); test('SIDEBAR_SOURCE: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawSidebarSourceMessageInfo: RawSidebarSourceMessageInfo = { type: 17, threadID: '86219', creatorID: '85435', time: 1638250532831, sourceMessage: { type: 0, id: '85486', threadID: '85466', time: 1637778853216, creatorID: '256', - text: - 'as you inevitably discover bugs, have feature requests, or design suggestions, feel free to message them to me in the app.', + text: 'as you inevitably discover bugs, have feature requests, or design suggestions, feel free to message them to me in the app.', }, id: '86223', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawSidebarSourceMessageInfo), ), ).toStrictEqual(rawSidebarSourceMessageInfo); }); test('CREATE_SIDEBAR: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawCreateSidebarMessageInfo: RawCreateSidebarMessageInfo = { type: 18, threadID: '86219', creatorID: '85435', time: 1638250532831, sourceMessageAuthorID: '256', initialThreadState: { name: 'as you inevitably discover ...', parentThreadID: '85466', color: 'ffffff', memberIDs: ['256', '85435'], }, id: '86224', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawCreateSidebarMessageInfo), ), ).toStrictEqual(rawCreateSidebarMessageInfo); }); test('Test translateClientDBMediaInfosToMedia(...) with one video', () => { const clientDBMessageInfo: ClientDBMessageInfo = { id: 'local0', local_id: 'local0', thread: '90145', user: '90134', type: '15', future_type: null, time: '1665014145088', content: '[{"type":"video","uploadID":"localUpload0","thumbnailUploadID":"localUpload1"}]', media_infos: [ { id: 'localUpload0', - uri: - 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', + uri: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', type: 'video', extras: '{"dimensions":{"height":1010,"width":576},"loop":false,"local_media_selection":{"step":"video_library","dimensions":{"height":1010,"width":576},"uri":"assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov","filename":"IMG_0007.MOV","mediaNativeID":"6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2/L0/001","duration":25.866666666666667,"selectTime":1665014144968,"sendTime":1665014144968,"retries":0}}', }, { id: 'localUpload1', - uri: - 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', + uri: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', type: 'photo', extras: '{"dimensions":{"height":1010,"width":576},"loop":false}', }, ], }; const rawMessageInfo = { type: 15, threadID: '90145', creatorID: '90134', time: 1665014145088, media: [ { id: 'localUpload0', - uri: - 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', + uri: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', type: 'video', dimensions: { height: 1010, width: 576 }, localMediaSelection: { step: 'video_library', dimensions: { height: 1010, width: 576 }, - uri: - 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', + uri: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', filename: 'IMG_0007.MOV', mediaNativeID: '6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2/L0/001', duration: 25.866666666666667, selectTime: 1665014144968, sendTime: 1665014144968, retries: 0, }, loop: false, thumbnailID: 'localUpload1', thumbnailURI: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', }, ], localID: 'local0', }; expect(translateClientDBMediaInfosToMedia(clientDBMessageInfo)).toStrictEqual( rawMessageInfo.media, ); }); diff --git a/lib/utils/objects.js b/lib/utils/objects.js index e9e586fbf..2195f3f14 100644 --- a/lib/utils/objects.js +++ b/lib/utils/objects.js @@ -1,80 +1,78 @@ // @flow import stableStringify from 'fast-json-stable-stringify'; import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import _isEqual from 'lodash/fp/isEqual.js'; import stringHash from 'string-hash'; type Map = { +[key: K]: T }; function findMaximumDepth(obj: Object): ?{ path: string, depth: number } { let longestPath = null; let longestDepth = null; for (const key in obj) { const value = obj[key]; if (typeof value !== 'object' || !value) { if (!longestDepth) { longestPath = key; longestDepth = 1; } continue; } const childResult = findMaximumDepth(obj[key]); if (!childResult) { continue; } const { path, depth } = childResult; const ourDepth = depth + 1; if (longestDepth === null || ourDepth > longestDepth) { longestPath = `${key}.${path}`; longestDepth = ourDepth; } } if (!longestPath || !longestDepth) { return null; } return { path: longestPath, depth: longestDepth }; } function values(map: Map): T[] { return Object.values ? // https://github.com/facebook/flow/issues/2221 // $FlowFixMe - Object.values currently does not have good flow support Object.values(map) : Object.keys(map).map((key: K): T => map[key]); } function hash(obj: ?Object): number { if (!obj) { return -1; } return stringHash(stableStringify(obj)); } function assertObjectsAreEqual( processedObject: Map, expectedObject: Map, message: string, ) { const processedObjectKeys = Object.keys(processedObject); const expectedObjectKeys = Object.keys(expectedObject); - const inProcessedButNotExpected = _difference(processedObjectKeys)( - expectedObjectKeys, - ); - const inExpectedButNotProcessed = _difference(expectedObjectKeys)( - processedObjectKeys, - ); + const inProcessedButNotExpected = + _difference(processedObjectKeys)(expectedObjectKeys); + const inExpectedButNotProcessed = + _difference(expectedObjectKeys)(processedObjectKeys); invariant( _isEqual(processedObject)(expectedObject), `${message}: Objects should be equal.` + ` Object keys processed but not expected:` + ` ${inExpectedButNotProcessed.toString()}` + ` Object keys expected but not processed:` + ` ${inProcessedButNotExpected.toString()}`, ); } export { findMaximumDepth, values, hash, assertObjectsAreEqual }; diff --git a/lib/utils/siwe-utils.js b/lib/utils/siwe-utils.js index 35317d438..5e41037a4 100644 --- a/lib/utils/siwe-utils.js +++ b/lib/utils/siwe-utils.js @@ -1,123 +1,124 @@ // @flow import invariant from 'invariant'; import { SiweMessage } from 'siwe'; import { isDev } from './dev-utils.js'; import type { SIWEMessage } from '../types/siwe-types.js'; const siweNonceRegex: RegExp = /^[a-zA-Z0-9]{17}$/; function isValidSIWENonce(candidate: string): boolean { return siweNonceRegex.test(candidate); } const ethereumAddressRegex: RegExp = /^0x[a-fA-F0-9]{40}$/; function isValidEthereumAddress(candidate: string): boolean { return ethereumAddressRegex.test(candidate); } const primaryIdentityPublicKeyRegex: RegExp = /^[a-zA-Z0-9+/]{43}$/; function isValidPrimaryIdentityPublicKey(candidate: string): boolean { return primaryIdentityPublicKeyRegex.test(candidate); } const siweStatementWithoutPublicKey: string = 'By continuing, I accept the Comm Terms of Service: https://comm.app/terms'; function createSIWEMessage( address: string, statement: string, nonce: string, ): string { invariant(nonce, 'nonce must be present in createSiweMessage'); const domain = window.location.host; const origin = window.location.origin; const message = new SiweMessage({ domain, address, statement, uri: origin, version: '1', chainId: '1', nonce, }); return message.prepareMessage(); } function isValidSIWEDomain(candidate: string): boolean { return isDev ? candidate === 'localhost:3000' : candidate === 'comm.app' || candidate === 'web.comm.app'; } function isValidSIWEURI(candidate: string): boolean { return isDev ? candidate === 'http://localhost:3000' : candidate === 'https://comm.app' || candidate === 'https://web.comm.app'; } // Verify that the SIWEMessage is a well formed Comm SIWE Auth message. function isValidSIWEMessage(candidate: SIWEMessage): boolean { return ( (candidate.statement === siweStatementWithoutPublicKey || (candidate.statement !== null && candidate.statement !== undefined && isValidSIWEStatementWithPublicKey(candidate.statement))) && candidate.version === '1' && candidate.chainId === 1 && isValidSIWEDomain(candidate.domain) && isValidSIWEURI(candidate.uri) && isValidSIWENonce(candidate.nonce) && isValidEthereumAddress(candidate.address) ); } function getSIWEStatementForPublicKey(publicKey: string): string { invariant( isValidPrimaryIdentityPublicKey(publicKey), 'publicKey must be well formed in getSIWEStatementForPublicKey', ); return `Device IdPubKey: ${publicKey} ${siweStatementWithoutPublicKey}`; } -const siweStatementWithPublicKeyRegex = /^Device IdPubKey: [a-zA-Z0-9+/]{43} By continuing, I accept the Comm Terms of Service: https:\/\/comm.app\/terms$/; +const siweStatementWithPublicKeyRegex = + /^Device IdPubKey: [a-zA-Z0-9+/]{43} By continuing, I accept the Comm Terms of Service: https:\/\/comm.app\/terms$/; function isValidSIWEStatementWithPublicKey(candidate: string): boolean { return siweStatementWithPublicKeyRegex.test(candidate); } const publicKeyFromSIWEStatementRegex: RegExp = /[a-zA-Z0-9+/]{43}/; function getPublicKeyFromSIWEStatement(statement: string): string { invariant( isValidSIWEStatementWithPublicKey(statement), 'candidate must be well formed SIWE statement with public key', ); const publicKeyMatchArray = statement.match(publicKeyFromSIWEStatementRegex); invariant( publicKeyMatchArray !== null && publicKeyMatchArray !== undefined && publicKeyMatchArray.length === 1, 'publicKeyMatchArray should have one and only one element', ); return publicKeyMatchArray[0]; } // These are shown in the `SIWE` components on both `landing` and `web`. const siweMessageSigningExplanationStatements: string = `To complete the login process, you’ll now be ` + `asked to sign a message using your wallet. ` + `This signature will attest that your Ethereum ` + `identity is represented by your new Comm identity.`; export { siweStatementWithoutPublicKey, isValidSIWENonce, isValidEthereumAddress, primaryIdentityPublicKeyRegex, isValidPrimaryIdentityPublicKey, createSIWEMessage, isValidSIWEMessage, getSIWEStatementForPublicKey, isValidSIWEStatementWithPublicKey, getPublicKeyFromSIWEStatement, siweMessageSigningExplanationStatements, }; diff --git a/native/account/log-in-panel.react.js b/native/account/log-in-panel.react.js index 963b1e24b..f8965a41e 100644 --- a/native/account/log-in-panel.react.js +++ b/native/account/log-in-panel.react.js @@ -1,380 +1,379 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet, Alert, Keyboard, Platform } from 'react-native'; import Animated from 'react-native-reanimated'; import { logInActionTypes, logIn } from 'lib/actions/user-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { validEmailRegex, oldValidUsernameRegex, } from 'lib/shared/account-utils.js'; import { type LogInInfo, type LogInExtraInfo, type LogInResult, type LogInStartingPayload, logInActionSources, } from 'lib/types/account-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import { TextInput } from './modal-components.react.js'; import { fetchNativeCredentials, setNativeCredentials, } from './native-credentials.js'; import { PanelButton, Panel } from './panel-components.react.js'; import PasswordInput from './password-input.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { NavContext } from '../navigation/navigation-context.js'; import { useSelector } from '../redux/redux-utils.js'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js'; import type { KeyPressEvent } from '../types/react-native.js'; import type { StateContainer } from '../utils/state-container.js'; export type LogInState = { +usernameInputText: ?string, +passwordInputText: ?string, }; type BaseProps = { +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Node, +logInState: StateContainer, }; type Props = { ...BaseProps, +loadingStatus: LoadingStatus, +logInExtraInfo: () => Promise, +dispatchActionPromise: DispatchActionPromise, +logIn: (logInInfo: LogInInfo) => Promise, }; class LogInPanel extends React.PureComponent { usernameInput: ?TextInput; passwordInput: ?PasswordInput; componentDidMount() { this.attemptToFetchCredentials(); } get usernameInputText(): string { return this.props.logInState.state.usernameInputText || ''; } get passwordInputText(): string { return this.props.logInState.state.passwordInputText || ''; } async attemptToFetchCredentials() { if ( this.props.logInState.state.usernameInputText !== null && this.props.logInState.state.usernameInputText !== undefined ) { return; } const credentials = await fetchNativeCredentials(); if (!credentials) { return; } if ( this.props.logInState.state.usernameInputText !== null && this.props.logInState.state.usernameInputText !== undefined ) { return; } this.props.logInState.setState({ usernameInputText: credentials.username, passwordInputText: credentials.password, }); } render(): React.Node { return ( ); } usernameInputRef: (usernameInput: ?TextInput) => void = usernameInput => { this.usernameInput = usernameInput; if (Platform.OS === 'ios' && usernameInput) { setTimeout(() => usernameInput.focus()); } }; focusUsernameInput: () => void = () => { invariant(this.usernameInput, 'ref should be set'); this.usernameInput.focus(); }; passwordInputRef: (passwordInput: ?PasswordInput) => void = passwordInput => { this.passwordInput = passwordInput; }; focusPasswordInput: () => void = () => { invariant(this.passwordInput, 'ref should be set'); this.passwordInput.focus(); }; onChangeUsernameInputText: (text: string) => void = text => { this.props.logInState.setState({ usernameInputText: text.trim() }); }; onUsernameKeyPress: (event: KeyPressEvent) => void = event => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.passwordInputText.length === 0 ) { this.focusPasswordInput(); } }; onChangePasswordInputText: (text: string) => void = text => { this.props.logInState.setState({ passwordInputText: text }); }; onSubmit: () => Promise = async () => { this.props.setActiveAlert(true); if (this.usernameInputText.search(validEmailRegex) > -1) { Alert.alert( 'Can’t log in with email', 'You need to log in with your username now', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.usernameInputText.search(oldValidUsernameRegex) === -1) { Alert.alert( 'Invalid username', 'Alphanumeric usernames only', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); return; } Keyboard.dismiss(); const extraInfo = await this.props.logInExtraInfo(); this.props.dispatchActionPromise( logInActionTypes, this.logInAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }; async logInAction(extraInfo: LogInExtraInfo): Promise { try { const result = await this.props.logIn({ ...extraInfo, username: this.usernameInputText, password: this.passwordInputText, logInActionSource: logInActionSources.logInFromNativeForm, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.passwordInputText, }); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect username or password', "Either that user doesn't exist, or the password is incorrect", [{ text: 'OK', onPress: this.onUnsuccessfulLoginAlertAckowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'App Store', android: 'Play Store', }); Alert.alert( 'App out of date', 'Your app version is pretty old, and the server doesn’t know how ' + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } onUnsuccessfulLoginAlertAckowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', passwordInputText: '', }, this.focusUsernameInput, ); }; onUsernameAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', }, this.focusUsernameInput, ); }; onPasswordAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { passwordInputText: '', }, this.focusPasswordInput, ); }; onUnknownErrorAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', passwordInputText: '', }, this.focusUsernameInput, ); }; onAppOutOfDateAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); }; } export type InnerLogInPanel = LogInPanel; const styles = StyleSheet.create({ footer: { flexDirection: 'row', justifyContent: 'flex-end', }, icon: { bottom: 10, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, row: { marginHorizontal: 24, }, }); const loadingStatusSelector = createLoadingStatusSelector(logInActionTypes); -const ConnectedLogInPanel: React.ComponentType = React.memo( - function ConnectedLogInPanel(props: BaseProps) { +const ConnectedLogInPanel: React.ComponentType = + React.memo(function ConnectedLogInPanel(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector(state => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callLogIn = useServerCall(logIn); return ( ); - }, -); + }); export default ConnectedLogInPanel; diff --git a/native/account/register-panel.react.js b/native/account/register-panel.react.js index 6bcc70028..8c9f256b8 100644 --- a/native/account/register-panel.react.js +++ b/native/account/register-panel.react.js @@ -1,473 +1,472 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View, StyleSheet, Platform, Keyboard, Alert, Linking, } from 'react-native'; import Animated from 'react-native-reanimated'; import { registerActionTypes, register } from 'lib/actions/user-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { validUsernameRegex } from 'lib/shared/account-utils.js'; import type { RegisterInfo, LogInExtraInfo, RegisterResult, LogInStartingPayload, } from 'lib/types/account-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import { TextInput } from './modal-components.react.js'; import { setNativeCredentials } from './native-credentials.js'; import { PanelButton, Panel } from './panel-components.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { NavContext } from '../navigation/navigation-context.js'; import { useSelector } from '../redux/redux-utils.js'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js'; import type { KeyPressEvent } from '../types/react-native.js'; import { type StateContainer } from '../utils/state-container.js'; export type RegisterState = { +usernameInputText: string, +passwordInputText: string, +confirmPasswordInputText: string, }; type BaseProps = { +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Node, +registerState: StateContainer, }; type Props = { ...BaseProps, +loadingStatus: LoadingStatus, +logInExtraInfo: () => Promise, +dispatchActionPromise: DispatchActionPromise, +register: (registerInfo: RegisterInfo) => Promise, }; type State = { +confirmPasswordFocused: boolean, }; class RegisterPanel extends React.PureComponent { state: State = { confirmPasswordFocused: false, }; usernameInput: ?TextInput; passwordInput: ?TextInput; confirmPasswordInput: ?TextInput; passwordBeingAutoFilled = false; render() { let confirmPasswordTextInputExtraProps; if ( Platform.OS !== 'ios' || this.state.confirmPasswordFocused || this.props.registerState.state.confirmPasswordInputText.length > 0 ) { confirmPasswordTextInputExtraProps = { secureTextEntry: true, textContentType: 'password', }; } let onPasswordKeyPress; if (Platform.OS === 'ios') { onPasswordKeyPress = this.onPasswordKeyPress; } /* eslint-disable react-native/no-raw-text */ const privatePolicyNotice = ( By signing up, you agree to our{' '} Terms {' & '} Privacy Policy . ); /* eslint-enable react-native/no-raw-text */ return ( {privatePolicyNotice} ); } usernameInputRef = (usernameInput: ?TextInput) => { this.usernameInput = usernameInput; }; passwordInputRef = (passwordInput: ?TextInput) => { this.passwordInput = passwordInput; }; confirmPasswordInputRef = (confirmPasswordInput: ?TextInput) => { this.confirmPasswordInput = confirmPasswordInput; }; focusUsernameInput = () => { invariant(this.usernameInput, 'ref should be set'); this.usernameInput.focus(); }; focusPasswordInput = () => { invariant(this.passwordInput, 'ref should be set'); this.passwordInput.focus(); }; focusConfirmPasswordInput = () => { invariant(this.confirmPasswordInput, 'ref should be set'); this.confirmPasswordInput.focus(); }; onTermsOfUsePressed = () => { Linking.openURL('https://comm.app/terms'); }; onPrivacyPolicyPressed = () => { Linking.openURL('https://comm.app/privacy'); }; onChangeUsernameInputText = (text: string) => { this.props.registerState.setState({ usernameInputText: text }); }; onChangePasswordInputText = (text: string) => { const stateUpdate = {}; stateUpdate.passwordInputText = text; if (this.passwordBeingAutoFilled) { this.passwordBeingAutoFilled = false; stateUpdate.confirmPasswordInputText = text; } this.props.registerState.setState(stateUpdate); }; onPasswordKeyPress = (event: KeyPressEvent) => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.props.registerState.state.confirmPasswordInputText.length === 0 ) { this.passwordBeingAutoFilled = true; } }; onChangeConfirmPasswordInputText = (text: string) => { this.props.registerState.setState({ confirmPasswordInputText: text }); }; onConfirmPasswordFocus = () => { this.setState({ confirmPasswordFocused: true }); }; onSubmit = async () => { this.props.setActiveAlert(true); if (this.props.registerState.state.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if ( this.props.registerState.state.passwordInputText !== this.props.registerState.state.confirmPasswordInputText ) { Alert.alert( 'Passwords don’t match', 'Password fields must contain the same password', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if ( this.props.registerState.state.usernameInputText.search( validUsernameRegex, ) === -1 ) { Alert.alert( 'Invalid username', 'Usernames must be at least six characters long, start with either a ' + 'letter or a number, and may contain only letters, numbers, or the ' + 'characters “-” and “_”', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else { Keyboard.dismiss(); const extraInfo = await this.props.logInExtraInfo(); this.props.dispatchActionPromise( registerActionTypes, this.registerAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); } }; onPasswordAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.registerState.setState( { passwordInputText: '', confirmPasswordInputText: '', }, () => { invariant(this.passwordInput, 'ref should exist'); this.passwordInput.focus(); }, ); }; onUsernameAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.registerState.setState( { usernameInputText: '', }, () => { invariant(this.usernameInput, 'ref should exist'); this.usernameInput.focus(); }, ); }; async registerAction(extraInfo: LogInExtraInfo) { try { const result = await this.props.register({ ...extraInfo, username: this.props.registerState.state.usernameInputText, password: this.props.registerState.state.passwordInputText, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.props.registerState.state.passwordInputText, }); return result; } catch (e) { if (e.message === 'username_reserved') { Alert.alert( 'Username reserved', 'This username is currently reserved. Please contact support@' + 'comm.app if you would like to claim this account.', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'username_taken') { Alert.alert( 'Username taken', 'An account with that username already exists', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'App Store', android: 'Play Store', }); Alert.alert( 'App out of date', 'Your app version is pretty old, and the server doesn’t know how ' + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } onUnknownErrorAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.registerState.setState( { usernameInputText: '', passwordInputText: '', confirmPasswordInputText: '', }, () => { invariant(this.usernameInput, 'ref should exist'); this.usernameInput.focus(); }, ); }; onAppOutOfDateAlertAcknowledged = () => { this.props.setActiveAlert(false); }; } const styles = StyleSheet.create({ container: { zIndex: 2, }, footer: { alignItems: 'stretch', flexDirection: 'row', flexShrink: 1, justifyContent: 'space-between', paddingLeft: 24, }, hyperlinkText: { color: '#036AFF', fontWeight: 'bold', }, icon: { bottom: 10, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, notice: { alignSelf: 'center', display: 'flex', flexShrink: 1, maxWidth: 190, paddingBottom: 18, paddingRight: 8, paddingTop: 12, }, noticeText: { color: '#444', fontSize: 13, lineHeight: 20, textAlign: 'center', }, row: { marginHorizontal: 24, }, }); const loadingStatusSelector = createLoadingStatusSelector(registerActionTypes); -const ConnectedRegisterPanel: React.ComponentType = React.memo( - function ConnectedRegisterPanel(props: BaseProps) { +const ConnectedRegisterPanel: React.ComponentType = + React.memo(function ConnectedRegisterPanel(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector(state => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callRegister = useServerCall(register); return ( ); - }, -); + }); export default ConnectedRegisterPanel; diff --git a/native/account/siwe-panel.react.js b/native/account/siwe-panel.react.js index bfe371332..3d55927ee 100644 --- a/native/account/siwe-panel.react.js +++ b/native/account/siwe-panel.react.js @@ -1,268 +1,264 @@ // @flow import BottomSheet from '@gorhom/bottom-sheet'; import * as React from 'react'; import { ActivityIndicator, View, Alert } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import WebView from 'react-native-webview'; import { getSIWENonce, getSIWENonceActionTypes, siweAuth, siweAuthActionTypes, } from 'lib/actions/siwe-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LogInStartingPayload } from 'lib/types/account-types.js'; import type { SIWEWebViewMessage } from 'lib/types/siwe-types.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import type { LoggedOutMode } from './logged-out-modal.react.js'; import { commCoreModule } from '../native-modules.js'; import { NavContext } from '../navigation/navigation-context.js'; import { useSelector } from '../redux/redux-utils.js'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js'; import { defaultLandingURLPrefix } from '../utils/url-utils.js'; const commSIWE = `${defaultLandingURLPrefix}/siwe`; const getSIWENonceLoadingStatusSelector = createLoadingStatusSelector( getSIWENonceActionTypes, ); -const siweAuthLoadingStatusSelector = createLoadingStatusSelector( - siweAuthActionTypes, -); +const siweAuthLoadingStatusSelector = + createLoadingStatusSelector(siweAuthActionTypes); type Props = { +onClose: () => mixed, +nextMode: LoggedOutMode, }; function SIWEPanel(props: Props): React.Node { const navContext = React.useContext(NavContext); const dispatchActionPromise = useDispatchActionPromise(); const getSIWENonceCall = useServerCall(getSIWENonce); const siweAuthCall = useServerCall(siweAuth); const logInExtraInfo = useSelector(state => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const getSIWENonceCallFailed = useSelector( state => getSIWENonceLoadingStatusSelector(state) === 'error', ); const { onClose } = props; React.useEffect(() => { if (getSIWENonceCallFailed) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onClose }], { cancelable: false }, ); } }, [getSIWENonceCallFailed, onClose]); const siweAuthCallLoading = useSelector( state => siweAuthLoadingStatusSelector(state) === 'loading', ); const [nonce, setNonce] = React.useState(null); - const [ - primaryIdentityPublicKey, - setPrimaryIdentityPublicKey, - ] = React.useState(null); + const [primaryIdentityPublicKey, setPrimaryIdentityPublicKey] = + React.useState(null); React.useEffect(() => { (async () => { dispatchActionPromise( getSIWENonceActionTypes, (async () => { const response = await getSIWENonceCall(); setNonce(response); })(), ); await commCoreModule.initializeCryptoAccount('PLACEHOLDER'); const { ed25519 } = await commCoreModule.getUserPublicKey(); setPrimaryIdentityPublicKey(ed25519); })(); }, [dispatchActionPromise, getSIWENonceCall]); const [isLoading, setLoading] = React.useState(true); - const [isWalletConnectModalOpen, setWalletConnectModalOpen] = React.useState( - false, - ); + const [isWalletConnectModalOpen, setWalletConnectModalOpen] = + React.useState(false); const insets = useSafeAreaInsets(); const bottomInset = insets.bottom; const snapPoints = React.useMemo(() => { if (isLoading) { return [1]; } else if (isWalletConnectModalOpen) { return [bottomInset + 600]; } else { return [bottomInset + 435, bottomInset + 600]; } }, [isLoading, isWalletConnectModalOpen, bottomInset]); const bottomSheetRef = React.useRef(); const snapToIndex = bottomSheetRef.current?.snapToIndex; React.useEffect(() => { // When the snapPoints change, always reset to the first one // Without this, when we close the WalletConnect modal we don't resize snapToIndex?.(0); }, [snapToIndex, snapPoints]); const callSIWE = React.useCallback( async (message, signature, extraInfo) => { try { return await siweAuthCall({ message, signature, ...extraInfo, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onClose }], { cancelable: false }, ); throw e; } }, [onClose, siweAuthCall], ); const handleSIWE = React.useCallback( async ({ message, signature }) => { const extraInfo = await logInExtraInfo(); dispatchActionPromise( siweAuthActionTypes, callSIWE(message, signature, extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }, [logInExtraInfo, dispatchActionPromise, callSIWE], ); const closeBottomSheet = bottomSheetRef.current?.close; const { nextMode } = props; const disableOnClose = React.useRef(false); const handleMessage = React.useCallback( async event => { const data: SIWEWebViewMessage = JSON.parse(event.nativeEvent.data); if (data.type === 'siwe_success') { const { address, message, signature } = data; if (address && signature) { disableOnClose.current = true; closeBottomSheet?.(); await handleSIWE({ message, signature }); } } else if (data.type === 'siwe_closed') { onClose(); closeBottomSheet?.(); } else if (data.type === 'walletconnect_modal_update') { setWalletConnectModalOpen(data.state === 'open'); } }, [handleSIWE, onClose, closeBottomSheet], ); const prevNextModeRef = React.useRef(); React.useEffect(() => { if (nextMode === 'prompt' && prevNextModeRef.current === 'siwe') { closeBottomSheet?.(); } prevNextModeRef.current = nextMode; }, [nextMode, closeBottomSheet]); const source = React.useMemo( () => ({ uri: commSIWE, headers: { 'siwe-nonce': nonce, 'siwe-primary-identity-public-key': primaryIdentityPublicKey, }, }), [nonce, primaryIdentityPublicKey], ); const onWebViewLoaded = React.useCallback(() => { setLoading(false); }, []); const backgroundStyle = React.useMemo( () => ({ backgroundColor: '#242529', }), [], ); const bottomSheetHandleIndicatorStyle = React.useMemo( () => ({ backgroundColor: 'white', }), [], ); const onBottomSheetChange = React.useCallback( (index: number) => { if (disableOnClose.current) { disableOnClose.current = false; return; } if (index === -1) { onClose(); } }, [onClose], ); let bottomSheet; if (nonce && primaryIdentityPublicKey) { bottomSheet = ( ); } let activity; if (!getSIWENonceCallFailed && (isLoading || siweAuthCallLoading)) { activity = ; } const activityContainer = React.useMemo( () => ({ flex: 1, }), [], ); return ( <> {activity} {bottomSheet} ); } export default SIWEPanel; diff --git a/native/calendar/calendar.react.js b/native/calendar/calendar.react.js index 8c1539bcc..fd279c2d3 100644 --- a/native/calendar/calendar.react.js +++ b/native/calendar/calendar.react.js @@ -1,1098 +1,1094 @@ // @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); + 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, }); } 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, }); }; 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 }; }; 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/calendar/entry.react.js b/native/calendar/entry.react.js index 38535b190..c0c26dd6f 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,821 +1,820 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import _omit from 'lodash/fp/omit.js'; import * as React from 'react'; import { View, Text, TextInput as BaseTextInput, Platform, TouchableWithoutFeedback, Alert, LayoutAnimation, Keyboard, } from 'react-native'; import { useDispatch } from 'react-redux'; import shallowequal from 'shallowequal'; import tinycolor from 'tinycolor2'; import { createEntryActionTypes, createEntry, saveEntryActionTypes, saveEntry, deleteEntryActionTypes, deleteEntry, concurrentModificationResetActionType, } from 'lib/actions/entry-actions.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import { colorIsDark, threadHasPermission } from 'lib/shared/thread-utils.js'; import type { Shape } from 'lib/types/core.js'; import type { CreateEntryInfo, SaveEntryInfo, SaveEntryResult, SaveEntryPayload, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResult, CalendarQuery, } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ThreadInfo, type ResolvedThreadInfo, threadPermissions, } from 'lib/types/thread-types.js'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import { dateString } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { ServerError } from 'lib/utils/errors.js'; import sleep from 'lib/utils/sleep.js'; import type { EntryInfoWithHeight } from './calendar.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import Button from '../components/button.react.js'; import { SingleLine } from '../components/single-line.react.js'; import TextInput from '../components/text-input.react.js'; import Markdown from '../markdown/markdown.react.js'; import { inlineMarkdownRules } from '../markdown/rules.react.js'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { ThreadPickerModalRouteName } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { colors, useStyles } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import { waitForInteractions } from '../utils/timers.js'; function hueDistance(firstColor: string, secondColor: string): number { const firstHue = tinycolor(firstColor).toHsv().h; const secondHue = tinycolor(secondColor).toHsv().h; const distance = Math.abs(firstHue - secondHue); return distance > 180 ? 360 - distance : distance; } const omitEntryInfo = _omit(['entryInfo']); function dummyNodeForEntryHeightMeasurement( entryText: string, ): React.Element { const text = entryText === '' ? ' ' : entryText; return ( {text} ); } type SharedProps = { +navigation: TabNavigationProp<'Calendar'>, +entryInfo: EntryInfoWithHeight, +visible: boolean, +active: boolean, +makeActive: (entryKey: string, active: boolean) => void, +onEnterEditMode: (entryInfo: EntryInfoWithHeight) => void, +onConcludeEditMode: (entryInfo: EntryInfoWithHeight) => void, +onPressWhitespace: () => void, +entryRef: (entryKey: string, entry: ?InternalEntry) => void, }; type BaseProps = { ...SharedProps, +threadInfo: ThreadInfo, }; type Props = { ...SharedProps, +threadInfo: ResolvedThreadInfo, // Redux state +calendarQuery: () => CalendarQuery, +online: boolean, +styles: typeof unboundStyles, // Nav state +threadPickerActive: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, }; type State = { +editing: boolean, +text: string, +loadingStatus: LoadingStatus, +height: number, }; class InternalEntry extends React.Component { textInput: ?React.ElementRef; creating: boolean = false; needsUpdateAfterCreation: boolean = false; needsDeleteAfterCreation: boolean = false; nextSaveAttemptIndex: number = 0; mounted: boolean = false; deleted: boolean = false; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { editing: false, text: props.entryInfo.text, loadingStatus: 'inactive', height: props.entryInfo.textHeight, }; this.state = { ...this.state, editing: InternalEntry.isActive(props, this.state), }; } guardedSetState(input: Shape) { if (this.mounted) { this.setState(input); } } shouldComponentUpdate(nextProps: Props, nextState: State): boolean { return ( !shallowequal(nextState, this.state) || !shallowequal(omitEntryInfo(nextProps), omitEntryInfo(this.props)) || !_isEqual(nextProps.entryInfo)(this.props.entryInfo) ); } componentDidUpdate(prevProps: Props, prevState: State) { const wasActive = InternalEntry.isActive(prevProps, prevState); const isActive = InternalEntry.isActive(this.props, this.state); if ( !isActive && (this.props.entryInfo.text !== prevProps.entryInfo.text || this.props.entryInfo.textHeight !== prevProps.entryInfo.textHeight) && (this.props.entryInfo.text !== this.state.text || this.props.entryInfo.textHeight !== this.state.height) ) { this.guardedSetState({ text: this.props.entryInfo.text, height: this.props.entryInfo.textHeight, }); this.currentlySaving = null; } if ( !this.props.active && this.state.text === prevState.text && this.state.height !== prevState.height && this.state.height !== this.props.entryInfo.textHeight ) { const approxMeasuredHeight = Math.round(this.state.height * 1000) / 1000; const approxExpectedHeight = Math.round(this.props.entryInfo.textHeight * 1000) / 1000; console.log( `Entry height for ${entryKey(this.props.entryInfo)} was expected to ` + `be ${approxExpectedHeight} but is actually ` + `${approxMeasuredHeight}. This means Calendar's FlatList isn't ` + 'getting the right item height for some of its nodes, which is ' + 'guaranteed to cause glitchy behavior. Please investigate!!', ); } // Our parent will set the active prop to false if something else gets // pressed or if the Entry is scrolled out of view. In either of those cases // we should complete the edit process. if (!this.props.active && prevProps.active) { this.completeEdit(); } if (this.state.height !== prevState.height || isActive !== wasActive) { LayoutAnimation.easeInEaseOut(); } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } if ( this.state.editing && prevState.editing && (this.state.text.trim() === '') !== (prevState.text.trim() === '') ) { LayoutAnimation.easeInEaseOut(); } } componentDidMount() { this.mounted = true; this.props.entryRef(entryKey(this.props.entryInfo), this); } componentWillUnmount() { this.mounted = false; this.props.entryRef(entryKey(this.props.entryInfo), null); this.props.onConcludeEditMode(this.props.entryInfo); } static isActive(props: Props, state: State): boolean { return ( props.active || state.editing || !props.entryInfo.id || state.loadingStatus !== 'inactive' ); } render(): React.Node { const active = InternalEntry.isActive(this.props, this.state); const { editing } = this.state; const threadColor = `#${this.props.threadInfo.color}`; const darkColor = colorIsDark(this.props.threadInfo.color); let actionLinks = null; if (active) { const actionLinksColor = darkColor ? '#D3D3D3' : '#404040'; const actionLinksTextStyle = { color: actionLinksColor }; const { modalIosHighlightUnderlay: actionLinksUnderlayColor } = darkColor ? colors.dark : colors.light; const loadingIndicatorCanUseRed = hueDistance('red', threadColor) > 50; let editButtonContent = null; if (editing && this.state.text.trim() === '') { // nothing } else if (editing) { editButtonContent = ( SAVE ); } else { editButtonContent = ( EDIT ); } actionLinks = ( ); } const textColor = darkColor ? 'white' : 'black'; let textInput; if (editing) { const textInputStyle = { color: textColor, backgroundColor: threadColor, }; const selectionColor = darkColor ? '#129AFF' : '#036AFF'; textInput = ( ); } let rawText = this.state.text; if (rawText === '' || rawText.slice(-1) === '\n') { rawText += ' '; } const textStyle = { ...this.props.styles.text, color: textColor, opacity: textInput ? 0 : 1, }; // We use an empty View to set the height of the entry, and then position // the Text and TextInput absolutely. This allows to measure height changes // to the Text while controlling the actual height of the entry. const heightStyle = { height: this.state.height }; const entryStyle = { backgroundColor: threadColor }; const opacity = editing ? 1.0 : 0.6; const canEditEntry = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_ENTRIES, ); return ( ); } - textInputRef: ( - textInput: ?React.ElementRef, - ) => void = textInput => { - this.textInput = textInput; - if (textInput && this.state.editing) { - this.enterEditMode(); - } - }; + textInputRef: (textInput: ?React.ElementRef) => void = + textInput => { + this.textInput = textInput; + if (textInput && this.state.editing) { + this.enterEditMode(); + } + }; enterEditMode: () => Promise = async () => { this.setActive(); this.props.onEnterEditMode(this.props.entryInfo); if (Platform.OS === 'android') { // If we don't do this, the TextInput focuses // but the soft keyboard doesn't come up await waitForInteractions(); await sleep(15); } this.focus(); }; focus: () => void = () => { const { textInput } = this; if (!textInput) { return; } textInput.focus(); }; onFocus: () => void = () => { if (this.props.threadPickerActive) { this.props.navigation.goBack(); } }; setActive: () => void = () => this.makeActive(true); completeEdit: () => void = () => { // This gets called from CalendarInputBar (save button above keyboard), // onPressEdit (save button in Entry action links), and in // componentDidUpdate above when Calendar sets this Entry to inactive. // Calendar does this if something else gets pressed or the Entry is // scrolled out of view. Note that an Entry won't consider itself inactive // until it's done updating the server with its state, and if the network // requests fail it may stay "active". if (this.textInput) { this.textInput.blur(); } this.onBlur(); }; onBlur: () => void = () => { if (this.state.text.trim() === '') { this.delete(); } else if (this.props.entryInfo.text !== this.state.text) { this.save(); } this.guardedSetState({ editing: false }); this.makeActive(false); this.props.onConcludeEditMode(this.props.entryInfo); }; save: () => void = () => { this.dispatchSave(this.props.entryInfo.id, this.state.text); }; onTextContainerLayout: (event: LayoutEvent) => void = event => { this.guardedSetState({ height: Math.ceil(event.nativeEvent.layout.height), }); }; onChangeText: (newText: string) => void = newText => { this.guardedSetState({ text: newText }); }; makeActive(active: boolean) { const { threadInfo } = this.props; if (!threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES)) { return; } this.props.makeActive(entryKey(this.props.entryInfo), active); } dispatchSave(serverID: ?string, newText: string) { if (this.currentlySaving === newText) { return; } this.currentlySaving = newText; if (newText.trim() === '') { // We don't save the empty string, since as soon as the element becomes // inactive it'll get deleted return; } if (!serverID) { if (this.creating) { // We need the first save call to return so we know the ID of the entry // we're updating, so we'll need to handle this save later this.needsUpdateAfterCreation = true; return; } else { this.creating = true; } } this.guardedSetState({ loadingStatus: 'loading' }); if (!serverID) { this.props.dispatchActionPromise( createEntryActionTypes, this.createAction(newText), ); } else { this.props.dispatchActionPromise( saveEntryActionTypes, this.saveAction(serverID, newText), ); } } async createAction(text: string): Promise { const localID = this.props.entryInfo.localID; invariant(localID, "if there's no serverID, there should be a localID"); const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.createEntry({ text, timestamp: this.props.entryInfo.creationTime, date: dateString( this.props.entryInfo.year, this.props.entryInfo.month, this.props.entryInfo.day, ), threadID: this.props.entryInfo.threadID, localID, calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } this.creating = false; if (this.needsUpdateAfterCreation) { this.needsUpdateAfterCreation = false; this.dispatchSave(response.entryID, this.state.text); } if (this.needsDeleteAfterCreation) { this.needsDeleteAfterCreation = false; this.dispatchDelete(response.entryID); } return response; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; this.creating = false; throw e; } } async saveAction( entryID: string, newText: string, ): Promise { const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.saveEntry({ entryID, text: newText, prevText: this.props.entryInfo.text, timestamp: Date.now(), calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } return { ...response, threadID: this.props.entryInfo.threadID }; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; if (e instanceof ServerError && e.message === 'concurrent_modification') { const revertedText = e.payload?.db; const onRefresh = () => { this.guardedSetState({ loadingStatus: 'inactive', text: revertedText, }); this.props.dispatch({ type: concurrentModificationResetActionType, payload: { id: entryID, dbText: revertedText }, }); }; Alert.alert( 'Concurrent modification', 'It looks like somebody is attempting to modify that field at the ' + 'same time as you! Please try again.', [{ text: 'OK', onPress: onRefresh }], { cancelable: false }, ); } throw e; } } delete: () => void = () => { this.dispatchDelete(this.props.entryInfo.id); }; onPressEdit: () => void = () => { if (this.state.editing) { this.completeEdit(); } else { this.guardedSetState({ editing: true }); } }; dispatchDelete(serverID: ?string) { if (this.deleted) { return; } this.deleted = true; LayoutAnimation.easeInEaseOut(); const { localID } = this.props.entryInfo; this.props.dispatchActionPromise( deleteEntryActionTypes, this.deleteAction(serverID), undefined, { localID, serverID }, ); } async deleteAction(serverID: ?string): Promise { if (serverID) { return await this.props.deleteEntry({ entryID: serverID, prevText: this.props.entryInfo.text, calendarQuery: this.props.calendarQuery(), }); } else if (this.creating) { this.needsDeleteAfterCreation = true; } return null; } onPressThreadName: () => void = () => { Keyboard.dismiss(); this.props.navigateToThread({ threadInfo: this.props.threadInfo }); }; } const unboundStyles = { actionLinks: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', marginTop: -5, }, button: { padding: 5, }, buttonContents: { flex: 1, flexDirection: 'row', }, container: { backgroundColor: 'listBackground', }, entry: { borderRadius: 8, margin: 5, overflow: 'hidden', }, leftLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', paddingHorizontal: 5, }, leftLinksText: { fontSize: 12, fontWeight: 'bold', paddingLeft: 5, }, pencilIcon: { lineHeight: 13, paddingTop: 1, }, rightLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 5, }, rightLinksText: { fontSize: 12, fontWeight: 'bold', }, text: { fontFamily: 'System', fontSize: 16, }, textContainer: { position: 'absolute', top: 0, paddingBottom: 6, paddingLeft: 10, paddingRight: 10, paddingTop: 5, transform: (Platform.select({ ios: [{ translateY: -1 / 3 }], default: [], }): $ReadOnlyArray<{ +translateY: number }>), }, textInput: { fontFamily: 'System', fontSize: 16, left: ((Platform.OS === 'android' ? 9.8 : 10): number), margin: 0, padding: 0, position: 'absolute', right: 10, top: ((Platform.OS === 'android' ? 4.8 : 0.5): number), }, }; registerFetchKey(saveEntryActionTypes); registerFetchKey(deleteEntryActionTypes); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const Entry: React.ComponentType = React.memo( function ConnectedEntry(props: BaseProps) { const navContext = React.useContext(NavContext); const threadPickerActive = activeThreadPickerSelector(navContext); const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const online = useSelector( state => state.connection.status === 'connected', ); const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callCreateEntry = useServerCall(createEntry); const callSaveEntry = useServerCall(saveEntry); const callDeleteEntry = useServerCall(deleteEntry); const { threadInfo: unresolvedThreadInfo, ...restProps } = props; const threadInfo = useResolvedThreadInfo(unresolvedThreadInfo); return ( ); }, ); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/native/calendar/section-footer.react.js b/native/calendar/section-footer.react.js index 7a2891a62..af81a7017 100644 --- a/native/calendar/section-footer.react.js +++ b/native/calendar/section-footer.react.js @@ -1,83 +1,82 @@ // @flow import * as React from 'react'; import { View, Text, TouchableWithoutFeedback } from 'react-native'; import Button from '../components/button.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { type Colors, useStyles, useColors } from '../themes/colors.js'; type BaseProps = { +dateString: string, +onAdd: (dateString: string) => void, +onPressWhitespace: () => void, }; type Props = { ...BaseProps, +colors: Colors, +styles: typeof unboundStyles, }; class SectionFooter extends React.PureComponent { render() { return ( ); } onSubmit = () => { this.props.onAdd(this.props.dateString); }; } const unboundStyles = { actionLinksText: { color: 'listSeparatorLabel', fontWeight: 'bold', }, addButton: { backgroundColor: 'panelSecondaryForeground', borderRadius: 5, margin: 5, paddingBottom: 5, paddingLeft: 10, paddingRight: 10, paddingTop: 5, }, addButtonContents: { alignItems: 'center', flexDirection: 'row', }, addIcon: { color: 'listSeparatorLabel', fontSize: 18, paddingRight: 4, }, sectionFooter: { alignItems: 'flex-start', backgroundColor: 'listBackground', height: 40, }, }; -const ConnectedSectionFooter: React.ComponentType = React.memo( - function ConnectedSectionFooter(props: BaseProps) { +const ConnectedSectionFooter: React.ComponentType = + React.memo(function ConnectedSectionFooter(props: BaseProps) { const styles = useStyles(unboundStyles); const colors = useColors(); return ; - }, -); + }); export default ConnectedSectionFooter; diff --git a/native/chat/chat-context-provider.react.js b/native/chat/chat-context-provider.react.js index 9bf80b093..12d9421d4 100644 --- a/native/chat/chat-context-provider.react.js +++ b/native/chat/chat-context-provider.react.js @@ -1,172 +1,170 @@ // @flow import * as React from 'react'; import type { ChatMessageItem } from 'lib/selectors/chat-selectors.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { ChatContext } from './chat-context.js'; import type { SidebarAnimationType } from './chat-context.js'; import ChatItemHeightMeasurer from './chat-item-height-measurer.react.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; type Props = { +children: React.Node, }; export type MeasurementTask = { +messages: $ReadOnlyArray, +threadInfo: ThreadInfo, +onMessagesMeasured: ( messagesWithHeight: $ReadOnlyArray, measuredHeights: $ReadOnlyMap, ) => mixed, +measurerID: number, +initialMeasuredHeights: ?$ReadOnlyMap, }; function ChatContextProvider(props: Props): React.Node { const [measurements, setMeasurements] = React.useState< $ReadOnlyArray, >([]); const nextMeasurerID = React.useRef(0); const measuredHeights = React.useRef< Map>, >(new Map()); const measureMessages = React.useCallback( ( messages: ?$ReadOnlyArray, threadInfo: ?ThreadInfo, onMessagesMeasured: ($ReadOnlyArray) => mixed, measurerID: number, ) => { if (!threadInfo) { // When threadInfo is not present, we can't measure the messages: we can // determine the height, but we can't merge the result as it requires // threadInfo to be present. return; } if (!messages) { return; } const measureCallback = ( messagesWithHeight: $ReadOnlyArray, newMeasuredHeights: $ReadOnlyMap, ) => { measuredHeights.current.set(measurerID, newMeasuredHeights); onMessagesMeasured(messagesWithHeight); }; let initialMeasuredHeights = null; const isMeasurementPresent = measuredHeights.current.has(measurerID); if (!isMeasurementPresent) { const sourceMeasurerID = measurements.find( measurement => measurement.threadInfo.id === threadInfo.id, )?.measurerID; initialMeasuredHeights = sourceMeasurerID ? measuredHeights.current.get(sourceMeasurerID) : null; } const newMeasurement = { messages, threadInfo, onMessagesMeasured: measureCallback, measurerID, initialMeasuredHeights, }; setMeasurements(prevMeasurements => { const withoutCurrentMeasurement = prevMeasurements.filter( measurement => measurement.measurerID !== measurerID, ); return [...withoutCurrentMeasurement, newMeasurement]; }); }, [measurements], ); const registerMeasurer = React.useCallback(() => { const measurerID = nextMeasurerID.current++; return { measure: ( messages: ?$ReadOnlyArray, threadInfo: ?ThreadInfo, onMessagesMeasured: ( $ReadOnlyArray, ) => mixed, ) => measureMessages(messages, threadInfo, onMessagesMeasured, measurerID), unregister: () => { setMeasurements(prevMeasurements => prevMeasurements.filter( measurement => measurement.measurerID !== measurerID, ), ); measuredHeights.current.delete(measurerID); }, }; }, [measureMessages]); const [ currentTransitionSidebarSourceID, setCurrentTransitionSidebarSourceID, ] = React.useState(null); const chatInputBarHeights = React.useRef>(new Map()); const setChatInputBarHeight = React.useCallback( (threadID: string, height: number) => chatInputBarHeights.current.set(threadID, height), [], ); const deleteChatInputBarHeight = React.useCallback( (threadID: string) => chatInputBarHeights.current.delete(threadID), [], ); - const [ - sidebarAnimationType, - setSidebarAnimationType, - ] = React.useState('move_source_message'); + const [sidebarAnimationType, setSidebarAnimationType] = + React.useState('move_source_message'); const contextValue = React.useMemo( () => ({ registerMeasurer, currentTransitionSidebarSourceID, setCurrentTransitionSidebarSourceID, setChatInputBarHeight, deleteChatInputBarHeight, chatInputBarHeights: chatInputBarHeights.current, sidebarAnimationType, setSidebarAnimationType, }), [ currentTransitionSidebarSourceID, deleteChatInputBarHeight, registerMeasurer, setChatInputBarHeight, sidebarAnimationType, ], ); const heightMeasurers = React.useMemo( () => measurements.map(measurement => ( )), [measurements], ); return ( {heightMeasurers} {props.children} ); } export default ChatContextProvider; diff --git a/native/chat/chat-header.react.js b/native/chat/chat-header.react.js index 693831ac2..2e2fde1e9 100644 --- a/native/chat/chat-header.react.js +++ b/native/chat/chat-header.react.js @@ -1,21 +1,20 @@ // @flow import type { StackHeaderProps } from '@react-navigation/stack'; import * as React from 'react'; import Header from '../navigation/header.react.js'; import { createActiveTabSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { ChatRouteName } from '../navigation/route-names.js'; const activeTabSelector = createActiveTabSelector(ChatRouteName); -const ChatHeader: React.ComponentType = React.memo( - function ChatHeader(props: StackHeaderProps) { +const ChatHeader: React.ComponentType = + React.memo(function ChatHeader(props: StackHeaderProps) { const navContext = React.useContext(NavContext); const activeTab = activeTabSelector(navContext); return
; - }, -); + }); export default ChatHeader; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index 1e197556c..9cea56e35 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,1094 +1,1084 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, NativeAppEventEmitter, } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { EasingNode } from 'react-native-reanimated'; import { useDispatch } from 'react-redux'; import { moveDraftActionType, updateDraftActionType, } from 'lib/actions/draft-actions.js'; import { joinThreadActionTypes, joinThread, newThreadActionTypes, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { relativeMemberInfoSelectorForMembersOfThread, userStoreSearchIndex, } from 'lib/selectors/user-selectors.js'; import { localIDPrefix, trimMessage } from 'lib/shared/message-utils.js'; import SearchIndex from 'lib/shared/search-index.js'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, colorIsDark, } from 'lib/shared/thread-utils.js'; import { getTypeaheadUserSuggestions, getTypeaheadRegexMatches, type Selection, } from 'lib/shared/typeahead-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { PhotoPaste } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ThreadInfo, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { ChatContext } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react.js'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import Button from '../components/button.react.js'; // eslint-disable-next-line import/extensions import ClearableTextInput from '../components/clearable-text-input.react'; import type { SyncedSelectionData } from '../components/selectable-text-input.js'; // eslint-disable-next-line import/extensions import SelectableTextInput from '../components/selectable-text-input.react'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import { getKeyboardHeight } from '../keyboard/keyboard.js'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { type NavigationRoute, CameraModalRouteName, ImagePasteModalRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import { type AnimatedViewStyle, AnimatedView } from '../types/styles.js'; import { runTiming } from '../utils/animation-utils.js'; import { nativeTypeaheadRegex } from '../utils/typeahead-utils.js'; /* eslint-disable import/no-named-as-default-member */ -const { - Value, - Clock, - block, - set, - cond, - neq, - sub, - interpolateNode, - stopClock, -} = Animated; +const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock } = + Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, // connectNav +isActive: boolean, // withKeyboardState +keyboardState: ?KeyboardState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +joinThread: (request: ClientThreadJoinRequest) => Promise, // withInputState +inputState: ?InputState, +userSearchIndex: SearchIndex, +threadMembers: $ReadOnlyArray, }; type State = { +text: string, +textEdited: boolean, +buttonsExpanded: boolean, +selectionState: SyncedSelectionData, }; class ChatInputBar extends React.PureComponent { textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; selectableTextInput: ?React.ElementRef; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: AnimatedViewStyle; cameraRollIconStyle: AnimatedViewStyle; cameraIconStyle: AnimatedViewStyle; expandIconStyle: AnimatedViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: AnimatedViewStyle; constructor(props: Props) { super(props); this.state = { text: props.draft, textEdited: false, buttonsExpanded: true, selectionState: { text: props.draft, selection: { start: 0, end: 0 } }, }; this.setUpActionIconAnimations(); this.setUpSendIconAnimations(); } setUpActionIconAnimations() { this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolateNode(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [26, 66], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; } setUpSendIconAnimations() { const initialSendButtonContainerOpen = trimMessage(this.props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolateNode(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { if (this.shouldShowTextInput) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } else { this.setUpSendIconAnimations(); } } componentDidMount() { if (this.props.isActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.textEdited && this.state.text && this.props.threadInfo.id !== prevProps.threadInfo.id ) { this.props.dispatch({ type: moveDraftActionType, payload: { oldKey: draftKeyFromThreadID(prevProps.threadInfo.id), newKey: draftKeyFromThreadID(this.props.threadInfo.id), }, }); } else if (!this.state.textEdited && this.props.draft !== prevProps.draft) { this.setState({ text: this.props.draft }); } if (this.props.isActive && !prevProps.isActive) { this.addReplyListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeReplyListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); - const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing( - prevProps, - ); + const systemKeyboardWasShowing = + ChatInputBar.systemKeyboardShowing(prevProps); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } get shouldShowTextInput(): boolean { if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { return true; } // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place // and won't list the viewer as a member, // which will end up hiding the input. // In this case, we will assume that our creation action // will get translated into a join, and as long // as members are voiced, we can show the input. if (!this.props.threadCreationInProgress) { return false; } return checkIfDefaultMembersAreVoiced(this.props.threadInfo); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin && !this.props.threadCreationInProgress) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { const textStyle = colorIsDark(this.props.threadInfo.color) ? this.props.styles.joinButtonTextLight : this.props.styles.joinButtonTextDark; buttonContent = ( Join Chat ); } joinButton = ( ); } const typeaheadRegexMatches = getTypeaheadRegexMatches( this.state.selectionState.text, this.state.selectionState.selection, nativeTypeaheadRegex, ); let typeaheadTooltip = null; if (typeaheadRegexMatches) { const typeaheadMatchedStrings = { textBeforeAtSymbol: typeaheadRegexMatches[1] ?? '', usernamePrefix: typeaheadRegexMatches[4] ?? '', }; const suggestedUsers = getTypeaheadUserSuggestions( this.props.userSearchIndex, this.props.threadMembers, this.props.viewerID, typeaheadMatchedStrings.usernamePrefix, ); if (suggestedUsers.length > 0) { typeaheadTooltip = ( ); } } let content; const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); if (this.shouldShowTextInput) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( You can't send messages to a user that you've blocked. ); } else if (isMember) { content = ( You don't have permission to send messages. ); } else if (defaultMembersAreVoiced && canJoin) { content = null; } else { content = ( You don't have permission to send messages. ); } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); return ( {typeaheadTooltip} {joinButton} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; selectableTextInputRef = ( selectableTextInput: ?React.ElementRef, ) => { this.selectableTextInput = selectableTextInput; }; updateText = (text: string) => { this.setState({ text, textEdited: true }); this.saveDraft(text); }; updateSelectionState: (data: SyncedSelectionData) => void = data => { this.setState({ selectionState: data }); }; saveDraft = _throttle(text => { this.props.dispatch({ type: updateDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), text, }, }); }, 400); focusAndUpdateTextAndSelection = (text: string, selection: Selection) => { this.selectableTextInput?.prepareForSelectionMutation(text, selection); this.setState({ text, textEdited: true, selectionState: { text, selection }, }); this.saveDraft(text); this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateText = (text: string) => { const currentText = this.state.text; if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); } this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateButtonsVisibility = () => { const { textInput } = this; if (!textInput) { return; } this.immediatelyShowSendButton(); this.immediatelyHideButtons(); textInput.focus(); }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = `${localIDPrefix}${this.props.nextLocalID}`; const creatorID = this.props.viewerID; invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); this.props.inputState.sendTextMessage( { type: messageTypes.TEXT, localID, threadID: this.props.threadInfo.id, text, creatorID, time: Date.now(), }, this.props.threadInfo, ); }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, container: { backgroundColor: 'listBackground', paddingLeft: Platform.OS === 'android' ? 10 : 5, }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 13 : 11, paddingRight: 2, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { borderRadius: 8, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, }, joinButtonContainer: { flexDirection: 'row', height: 48, marginBottom: 8, }, joinButtonContent: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', }, joinButtonTextLight: { color: 'white', fontSize: 20, marginHorizontal: 4, }, joinButtonTextDark: { color: 'black', fontSize: 20, marginHorizontal: 4, }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: 4, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 6, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 12, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginRight: 4, marginTop: 6, marginBottom: 8, maxHeight: 110, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); -const createThreadLoadingStatusSelector = createLoadingStatusSelector( - newThreadActionTypes, -); +const createThreadLoadingStatusSelector = + createLoadingStatusSelector(newThreadActionTypes); type ConnectedChatInputBarBaseProps = { ...BaseProps, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, }; function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) { const navContext = React.useContext(NavContext); const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const draft = useSelector( state => state.draftStore.drafts[draftKeyFromThreadID(props.threadInfo.id)] ?? '', ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const createThreadLoadingStatus = useSelector( createThreadLoadingStatusSelector, ); const threadCreationInProgress = createThreadLoadingStatus === 'loading'; const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useSelector(state => state.nextLocalID); const userInfos = useSelector(state => state.userStore.userInfos); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); const userSearchIndex = useSelector(userStoreSearchIndex); const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(props.threadInfo.id), ); return ( ); } type DummyChatInputBarProps = { ...BaseProps, +onHeightMeasured: (height: number) => mixed, }; const noop = () => {}; function DummyChatInputBar(props: DummyChatInputBarProps): React.Node { const { onHeightMeasured, ...restProps } = props; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; onHeightMeasured(height); }, [onHeightMeasured], ); return ( ); } type ChatInputBarProps = { ...BaseProps, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; -const ConnectedChatInputBar: React.ComponentType = React.memo( - function ConnectedChatInputBar(props: ChatInputBarProps) { +const ConnectedChatInputBar: React.ComponentType = + React.memo(function ConnectedChatInputBar( + props: ChatInputBarProps, + ) { const { navigation, route, ...restProps } = props; const keyboardState = React.useContext(KeyboardContext); const { threadInfo } = props; const imagePastedCallback = React.useCallback( imagePastedEvent => { if (threadInfo.id !== imagePastedEvent.threadID) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; navigation.navigate<'ImagePasteModal'>({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, thread: threadInfo, }, }); }, [navigation, threadInfo], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); const chatContext = React.useContext(ChatContext); invariant(chatContext, 'should be set'); const { setChatInputBarHeight, deleteChatInputBarHeight } = chatContext; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; setChatInputBarHeight(threadInfo.id, height); }, [threadInfo.id, setChatInputBarHeight], ); React.useEffect(() => { return () => { deleteChatInputBarHeight(threadInfo.id); }; }, [deleteChatInputBarHeight, threadInfo.id]); const openCamera = React.useCallback(() => { keyboardState?.dismissKeyboard(); navigation.navigate<'CameraModal'>({ name: CameraModalRouteName, params: { presentedFrom: route.key, thread: threadInfo, }, }); }, [keyboardState, navigation, route.key, threadInfo]); return ( ); - }, -); + }); export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar }; diff --git a/native/chat/chat-item-height-measurer.react.js b/native/chat/chat-item-height-measurer.react.js index b4d2faf43..3e5bc06d9 100644 --- a/native/chat/chat-item-height-measurer.react.js +++ b/native/chat/chat-item-height-measurer.react.js @@ -1,180 +1,179 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { ChatMessageItem } from 'lib/selectors/chat-selectors.js'; 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 { 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: ChatMessageItem) => { if (item.itemType !== 'message') { return null; } const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return messageInfo.text; } else if (item.robotext) { const { threadID } = item.messageInfo; return entityTextToRawString(item.robotext, { threadID }); } return null; }; const heightMeasurerDummy = (item: ChatMessageItem) => { 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: ChatMessageItem, 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, ...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, }; } 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, -); +const MemoizedChatItemHeightMeasurer: React.ComponentType = + React.memo(ChatItemHeightMeasurer); export default MemoizedChatItemHeightMeasurer; diff --git a/native/chat/chat-list.react.js b/native/chat/chat-list.react.js index 013e15fbb..7c132b74c 100644 --- a/native/chat/chat-list.react.js +++ b/native/chat/chat-list.react.js @@ -1,299 +1,297 @@ // @flow import invariant from 'invariant'; import _sum from 'lodash/fp/sum.js'; import * as React from 'react'; import { Animated, Easing, StyleSheet, TouchableWithoutFeedback, View, FlatList as ReactNativeFlatList, } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { localIDPrefix } from 'lib/shared/message-utils.js'; import type { ChatNavigationProp } from './chat.react.js'; import NewMessagesPill from './new-messages-pill.react.js'; import { chatMessageItemHeight, chatMessageItemKey } from './utils.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; import type { ScrollEvent } from '../types/react-native.js'; import type { ViewStyle } from '../types/styles.js'; type FlatListElementRef = React.ElementRef; type FlatListProps = React.ElementConfig; const animationSpec = { duration: 150, useNativeDriver: true, }; type BaseProps = { ...FlatListProps, +navigation: ChatNavigationProp<'MessageList'>, +data: $ReadOnlyArray, ... }; type Props = { ...BaseProps, // Redux state +viewerID: ?string, // withKeyboardState +keyboardState: ?KeyboardState, ... }; type State = { +newMessageCount: number, }; class ChatList extends React.PureComponent { state: State = { newMessageCount: 0, }; flatList: ?FlatListElementRef; scrollPos = 0; newMessagesPillProgress = new Animated.Value(0); newMessagesPillStyle: ViewStyle; constructor(props: Props) { super(props); const sendButtonTranslateY = this.newMessagesPillProgress.interpolate({ inputRange: [0, 1], outputRange: ([10, 0]: number[]), // Flow... }); this.newMessagesPillStyle = { opacity: this.newMessagesPillProgress, transform: [{ translateY: sendButtonTranslateY }], }; } componentDidMount() { - const tabNavigation: ?TabNavigationProp< - 'Chat', - > = this.props.navigation.getParent(); + const tabNavigation: ?TabNavigationProp<'Chat'> = + this.props.navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { - const tabNavigation: ?TabNavigationProp< - 'Chat', - > = this.props.navigation.getParent(); + const tabNavigation: ?TabNavigationProp<'Chat'> = + this.props.navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { const { flatList } = this; if (!this.props.navigation.isFocused() || !flatList) { return; } if (this.scrollPos > 0) { flatList.scrollToOffset({ offset: 0 }); } else { this.props.navigation.popToTop(); } }; get scrolledToBottom() { return this.scrollPos <= 0; } componentDidUpdate(prevProps: Props) { const { flatList } = this; if (!flatList || this.props.data === prevProps.data) { return; } if (this.props.data.length < prevProps.data.length) { // This should only happen due to MessageStorePruner, // which will only prune a thread when it is off-screen flatList.scrollToOffset({ offset: 0, animated: false }); return; } const { scrollPos } = this; let curDataIndex = 0, prevDataIndex = 0, heightSoFar = 0; let adjustScrollPos = 0, newLocalMessage = false, newRemoteMessageCount = 0; while (prevDataIndex < prevProps.data.length && heightSoFar <= scrollPos) { const prevItem = prevProps.data[prevDataIndex]; invariant(prevItem, 'prevItem should exist'); const prevItemKey = chatMessageItemKey(prevItem); const prevItemHeight = chatMessageItemHeight(prevItem); let curItem = this.props.data[curDataIndex]; while (curItem) { const curItemKey = chatMessageItemKey(curItem); if (curItemKey === prevItemKey) { break; } if (curItemKey.startsWith(localIDPrefix)) { newLocalMessage = true; } else if ( curItem.itemType === 'message' && curItem.messageInfo.creator.id !== this.props.viewerID ) { newRemoteMessageCount++; } adjustScrollPos += chatMessageItemHeight(curItem); curDataIndex++; curItem = this.props.data[curDataIndex]; } if (!curItem) { // Should never happen... console.log( `items not removed from ChatList, but ${prevItemKey} now missing`, ); return; } const curItemHeight = chatMessageItemHeight(curItem); adjustScrollPos += curItemHeight - prevItemHeight; heightSoFar += prevItemHeight; prevDataIndex++; curDataIndex++; } if (adjustScrollPos === 0) { return; } flatList.scrollToOffset({ offset: scrollPos + adjustScrollPos, animated: false, }); if (newLocalMessage || scrollPos <= 0) { flatList.scrollToOffset({ offset: 0 }); } else if (newRemoteMessageCount > 0) { this.setState(prevState => ({ newMessageCount: prevState.newMessageCount + newRemoteMessageCount, })); this.toggleNewMessagesPill(true); } } render() { const { navigation, viewerID, ...rest } = this.props; const { newMessageCount } = this.state; return ( 0 ? 'auto' : 'none'} containerStyle={styles.newMessagesPillContainer} style={this.newMessagesPillStyle} /> ); } flatListRef = (flatList: any) => { this.flatList = flatList; }; static getItemLayout = ( data: ?$ReadOnlyArray, index: number, ) => { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatList.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? chatMessageItemHeight(item) : 0; return { length, offset, index }; }; static heightOfItems( data: $ReadOnlyArray, ): number { return _sum(data.map(chatMessageItemHeight)); } toggleNewMessagesPill(show: boolean) { Animated.timing(this.newMessagesPillProgress, { ...animationSpec, easing: show ? Easing.ease : Easing.out(Easing.ease), toValue: show ? 1 : 0, }).start(({ finished }) => { if (finished && !show) { this.setState({ newMessageCount: 0 }); } }); } onScroll = (event: ScrollEvent) => { this.scrollPos = event.nativeEvent.contentOffset.y; if (this.scrollPos <= 0) { this.toggleNewMessagesPill(false); } this.props.onScroll && this.props.onScroll(event); }; onPressNewMessagesPill = () => { const { flatList } = this; if (!flatList) { return; } flatList.scrollToOffset({ offset: 0 }); this.toggleNewMessagesPill(false); }; onPressBackground = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const styles = StyleSheet.create({ container: { flex: 1, }, newMessagesPillContainer: { bottom: 30, position: 'absolute', right: 30, }, }); const ConnectedChatList: React.ComponentType = React.memo( function ConnectedChatList(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); return ( ); }, ); export default ConnectedChatList; diff --git a/native/chat/chat-thread-list-see-more-sidebars.react.js b/native/chat/chat-thread-list-see-more-sidebars.react.js index 5a47196e6..111a3df31 100644 --- a/native/chat/chat-thread-list-see-more-sidebars.react.js +++ b/native/chat/chat-thread-list-see-more-sidebars.react.js @@ -1,70 +1,70 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import * as React from 'react'; import { Text } from 'react-native'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { sidebarHeight } from './sidebar-item.react.js'; import Button from '../components/button.react.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { +threadInfo: ThreadInfo, +unread: boolean, +onPress: (threadInfo: ThreadInfo) => void, }; function ChatThreadListSeeMoreSidebars(props: Props): React.Node { const { onPress, threadInfo, unread } = props; - const onPressButton = React.useCallback(() => onPress(threadInfo), [ - onPress, - threadInfo, - ]); + const onPressButton = React.useCallback( + () => onPress(threadInfo), + [onPress, threadInfo], + ); const colors = useColors(); const styles = useStyles(unboundStyles); const unreadStyle = unread ? styles.unread : null; return ( ); } const unboundStyles = { unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, button: { height: sidebarHeight, flexDirection: 'row', display: 'flex', paddingLeft: 28, paddingRight: 18, alignItems: 'center', backgroundColor: 'listBackground', }, icon: { paddingLeft: 5, color: 'listForegroundSecondaryLabel', width: 35, }, text: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingLeft: 3, paddingBottom: 2, }, }; export default ChatThreadListSeeMoreSidebars; diff --git a/native/chat/chat-thread-list-sidebar.react.js b/native/chat/chat-thread-list-sidebar.react.js index 7e0dc41af..7c8e618a7 100644 --- a/native/chat/chat-thread-list-sidebar.react.js +++ b/native/chat/chat-thread-list-sidebar.react.js @@ -1,117 +1,117 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types.js'; import { SidebarItem, sidebarHeight } from './sidebar-item.react.js'; import SwipeableThread from './swipeable-thread.react.js'; import ExtendedArrow from '../components/arrow-extended.react.js'; import Arrow from '../components/arrow.react.js'; import Button from '../components/button.react.js'; import UnreadDot from '../components/unread-dot.react.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { +sidebarInfo: SidebarInfo, +onPressItem: (threadInfo: ThreadInfo) => void, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId: string, +extendArrow: boolean, }; function ChatThreadListSidebar(props: Props): React.Node { const colors = useColors(); const styles = useStyles(unboundStyles); const { sidebarInfo, onSwipeableWillOpen, currentlyOpenedSwipeableId, onPressItem, extendArrow = false, } = props; let arrow; if (extendArrow) { arrow = ( ); } else { arrow = ( ); } const { threadInfo } = sidebarInfo; - const onPress = React.useCallback(() => onPressItem(threadInfo), [ - threadInfo, - onPressItem, - ]); + const onPress = React.useCallback( + () => onPressItem(threadInfo), + [threadInfo, onPressItem], + ); return ( ); } const unboundStyles = { arrow: { left: 28, position: 'absolute', top: -12, }, extendedArrow: { left: 28, position: 'absolute', top: -6, }, sidebar: { alignItems: 'center', flexDirection: 'row', width: '100%', height: sidebarHeight, paddingLeft: 6, paddingRight: 18, backgroundColor: 'listBackground', }, swipeableThreadContainer: { flex: 1, height: '100%', }, unreadIndicatorContainer: { alignItems: 'center', flexDirection: 'row', justifyContent: 'flex-start', paddingLeft: 6, width: 56, }, }; export default ChatThreadListSidebar; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index 58aa7a1eb..81bf4b425 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,647 +1,642 @@ // @flow import IonIcon from '@expo/vector-icons/Ionicons.js'; import invariant from 'invariant'; import _sum from 'lodash/fp/sum.js'; import * as React from 'react'; import { View, FlatList, Platform, TextInput, TouchableWithoutFeedback, BackHandler, } from 'react-native'; import { FloatingAction } from 'react-native-floating-action'; import Animated from 'react-native-reanimated'; import { createSelector } from 'reselect'; import { searchUsers } from 'lib/actions/user-actions.js'; import { type ChatThreadItem, useFlattenedChatListData, } from 'lib/selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from 'lib/selectors/nav-selectors.js'; import { usersWithPersonalThreadSelector } from 'lib/selectors/user-selectors.js'; import SearchIndex from 'lib/shared/search-index.js'; import { createPendingThread, getThreadListSearchResults, } from 'lib/shared/thread-utils.js'; import type { UserSearchResult } from 'lib/types/search-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { threadTypes } from 'lib/types/thread-types.js'; import type { GlobalAccountUserInfo, UserInfo } from 'lib/types/user-types.js'; import { useServerCall } from 'lib/utils/action-utils.js'; import { ChatThreadListItem, chatThreadListItemHeight, spacerHeight, } from './chat-thread-list-item.react.js'; import type { ChatTopTabsNavigationProp, ChatNavigationProp, } from './chat.react.js'; import { type MessageListParams, useNavigateToThread, } from './message-list-types.js'; import { sidebarHeight } from './sidebar-item.react.js'; import Button from '../components/button.react.js'; import Search from '../components/search.react.js'; import { SidebarListModalRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, 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 IndicatorStyle, indicatorStyleSelector, useStyles, } from '../themes/colors.js'; import type { ScrollEvent } from '../types/react-native.js'; import { AnimatedView, type AnimatedStyleObj } from '../types/styles.js'; import { animateTowards } from '../utils/animation-utils.js'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; /* eslint-disable import/no-named-as-default-member */ const { Value, Node, interpolateNode } = Animated; /* eslint-enable import/no-named-as-default-member */ type Item = | ChatThreadItem | { +type: 'search', +searchText: string } | { +type: 'empty', +emptyItem: React.ComponentType<{}> }; type BaseProps = { +navigation: | ChatTopTabsNavigationProp<'HomeChatThreadList'> | ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, +route: | NavigationRoute<'HomeChatThreadList'> | NavigationRoute<'BackgroundChatThreadList'>, +filterThreads: (threadItem: ThreadInfo) => boolean, +emptyItem?: React.ComponentType<{}>, }; type Props = { ...BaseProps, // Redux state +chatListData: $ReadOnlyArray, +viewerID: ?string, +threadSearchIndex: SearchIndex, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, +usersWithPersonalThread: $ReadOnlySet, +navigateToThread: (params: MessageListParams) => void, // async functions that hit server APIs +searchUsers: (usernamePrefix: string) => Promise, }; type SearchStatus = 'inactive' | 'activating' | 'active'; type State = { +searchStatus: SearchStatus, +searchText: string, +threadsSearchResults: Set, +usersSearchResults: $ReadOnlyArray, +openedSwipeableId: string, +numItemsToDisplay: number, }; type PropsAndState = { ...Props, ...State }; class ChatThreadList extends React.PureComponent { state: State = { searchStatus: 'inactive', searchText: '', threadsSearchResults: new Set(), usersSearchResults: [], openedSwipeableId: '', numItemsToDisplay: 25, }; searchInput: ?React.ElementRef; flatList: ?FlatList; scrollPos = 0; clearNavigationBlurListener: ?() => mixed; searchCancelButtonOpen: Value = new Value(0); searchCancelButtonProgress: Node; searchCancelButtonOffset: Node; constructor(props: Props) { super(props); this.searchCancelButtonProgress = animateTowards( this.searchCancelButtonOpen, 100, ); this.searchCancelButtonOffset = interpolateNode( this.searchCancelButtonProgress, { inputRange: [0, 1], outputRange: [0, 56] }, ); } componentDidMount() { this.clearNavigationBlurListener = this.props.navigation.addListener( 'blur', () => { this.setState({ numItemsToDisplay: 25 }); }, ); - const chatNavigation: ?ChatNavigationProp< - 'ChatThreadList', - > = this.props.navigation.getParent(); + const chatNavigation: ?ChatNavigationProp<'ChatThreadList'> = + this.props.navigation.getParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); - const tabNavigation: ?TabNavigationProp< - 'Chat', - > = chatNavigation.getParent(); + const tabNavigation: ?TabNavigationProp<'Chat'> = + chatNavigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); BackHandler.addEventListener('hardwareBackPress', this.hardwareBack); } componentWillUnmount() { this.clearNavigationBlurListener && this.clearNavigationBlurListener(); - const chatNavigation: ?ChatNavigationProp< - 'ChatThreadList', - > = this.props.navigation.getParent(); + const chatNavigation: ?ChatNavigationProp<'ChatThreadList'> = + this.props.navigation.getParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); - const tabNavigation: ?TabNavigationProp< - 'Chat', - > = chatNavigation.getParent(); + const tabNavigation: ?TabNavigationProp<'Chat'> = + chatNavigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); BackHandler.removeEventListener('hardwareBackPress', this.hardwareBack); } hardwareBack = () => { if (!this.props.navigation.isFocused()) { return false; } const { searchStatus } = this.state; const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; if (!isActiveOrActivating) { return false; } this.onSearchCancel(); return true; }; componentDidUpdate(prevProps: Props, prevState: State) { const { searchStatus } = this.state; const prevSearchStatus = prevState.searchStatus; const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; const wasActiveOrActivating = prevSearchStatus === 'active' || prevSearchStatus === 'activating'; if (isActiveOrActivating && !wasActiveOrActivating) { this.searchCancelButtonOpen.setValue(1); } else if (!isActiveOrActivating && wasActiveOrActivating) { this.searchCancelButtonOpen.setValue(0); } const { flatList } = this; if (!flatList) { return; } if (this.state.searchText !== prevState.searchText) { flatList.scrollToOffset({ offset: 0, animated: false }); return; } if (searchStatus === 'activating' && prevSearchStatus === 'inactive') { flatList.scrollToOffset({ offset: 0, animated: true }); } } onTabPress = () => { if (!this.props.navigation.isFocused()) { return; } if (this.scrollPos > 0 && this.flatList) { this.flatList.scrollToOffset({ offset: 0, animated: true }); } else if (this.props.route.name === BackgroundChatThreadListRouteName) { this.props.navigation.navigate({ name: HomeChatThreadListRouteName }); } }; onSearchFocus = () => { if (this.state.searchStatus !== 'inactive') { return; } if (this.scrollPos === 0) { this.setState({ searchStatus: 'active' }); } else { this.setState({ searchStatus: 'activating' }); } }; clearSearch() { const { flatList } = this; flatList && flatList.scrollToOffset({ offset: 0, animated: false }); this.setState({ searchStatus: 'inactive' }); } onSearchBlur = () => { if (this.state.searchStatus !== 'active') { return; } this.clearSearch(); }; onSearchCancel = () => { this.onChangeSearchText(''); this.clearSearch(); }; renderSearch(additionalProps?: $Shape>) { const animatedSearchBoxStyle: AnimatedStyleObj = { marginRight: this.searchCancelButtonOffset, }; const searchBoxStyle = [ this.props.styles.searchBox, animatedSearchBoxStyle, ]; const buttonStyle = [ this.props.styles.cancelSearchButtonText, { opacity: this.searchCancelButtonProgress }, ]; return ( ); } searchInputRef = (searchInput: ?React.ElementRef) => { this.searchInput = searchInput; }; renderItem = (row: { item: Item, ... }) => { const item = row.item; if (item.type === 'search') { return ( {this.renderSearch({ active: false })} ); } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }; static keyExtractor = (item: Item) => { if (item.type === 'chatThreadItem') { return item.threadInfo.id; } else if (item.type === 'empty') { return 'empty'; } else { return 'search'; } }; static getItemLayout = (data: ?$ReadOnlyArray, index: number) => { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatThreadList.heightOfItems( data.filter((_, i) => i < index), ); const item = data[index]; const length = item ? ChatThreadList.itemHeight(item) : 0; return { length, offset, index }; }; static itemHeight = (item: Item) => { if (item.type === 'search') { return Platform.OS === 'ios' ? 54.5 : 55; } // itemHeight for emptyItem might be wrong because of line wrapping // but we don't care because we'll only ever be rendering this item // by itself and it should always be on-screen if (item.type === 'empty') { return 123; } let height = chatThreadListItemHeight; height += item.sidebars.length * sidebarHeight; if (item.sidebars.length > 0) { height += spacerHeight; } return height; }; static heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(ChatThreadList.itemHeight)); } listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.chatListData, (propsAndState: PropsAndState) => propsAndState.searchStatus, (propsAndState: PropsAndState) => propsAndState.searchText, (propsAndState: PropsAndState) => propsAndState.threadsSearchResults, (propsAndState: PropsAndState) => propsAndState.emptyItem, (propsAndState: PropsAndState) => propsAndState.usersSearchResults, (propsAndState: PropsAndState) => propsAndState.filterThreads, (propsAndState: PropsAndState) => propsAndState.viewerID, ( reduxChatListData: $ReadOnlyArray, searchStatus: SearchStatus, searchText: string, threadsSearchResults: Set, emptyItem?: React.ComponentType<{}>, usersSearchResults: $ReadOnlyArray, filterThreads: ThreadInfo => boolean, viewerID: ?string, ): $ReadOnlyArray => { const chatThreadItems = getThreadListSearchResults( reduxChatListData, searchText, filterThreads, threadsSearchResults, usersSearchResults, viewerID, ); const chatItems: Item[] = [...chatThreadItems]; if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } if (searchStatus === 'inactive' || searchStatus === 'activating') { chatItems.unshift({ type: 'search', searchText }); } return chatItems; }, ); partialListDataSelector = createSelector( this.listDataSelector, (propsAndState: PropsAndState) => propsAndState.numItemsToDisplay, (items: $ReadOnlyArray, numItemsToDisplay: number) => items.slice(0, numItemsToDisplay), ); get fullListData() { return this.listDataSelector({ ...this.props, ...this.state }); } get listData() { return this.partialListDataSelector({ ...this.props, ...this.state }); } onEndReached = () => { if (this.listData.length === this.fullListData.length) { return; } this.setState(prevState => ({ numItemsToDisplay: prevState.numItemsToDisplay + 25, })); }; render() { let floatingAction; if (Platform.OS === 'android') { floatingAction = ( ); } let fixedSearch; const { searchStatus } = this.state; if (searchStatus === 'active') { fixedSearch = this.renderSearch({ autoFocus: true }); } const scrollEnabled = searchStatus === 'inactive' || searchStatus === 'active'; // this.props.viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem return ( {fixedSearch} {floatingAction} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onScroll = (event: ScrollEvent) => { const oldScrollPos = this.scrollPos; this.scrollPos = event.nativeEvent.contentOffset.y; if (this.scrollPos !== 0 || oldScrollPos === 0) { return; } if (this.state.searchStatus === 'activating') { this.setState({ searchStatus: 'active' }); } }; async searchUsers(usernamePrefix: string) { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await this.props.searchUsers(usernamePrefix); return userInfos.filter( info => !this.props.usersWithPersonalThread.has(info.id) && info.id !== this.props.viewerID, ); } onChangeSearchText = async (searchText: string) => { const results = this.props.threadSearchIndex.getSearchResults(searchText); this.setState({ searchText, threadsSearchResults: new Set(results), numItemsToDisplay: 25, }); const usersSearchResults = await this.searchUsers(searchText); this.setState({ usersSearchResults }); }; onPressItem = ( threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo, ) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigateToThread({ threadInfo, pendingPersonalThreadUserInfo }); }; onPressSeeMoreSidebars = (threadInfo: ThreadInfo) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate<'SidebarListModal'>({ name: SidebarListModalRouteName, params: { threadInfo }, }); }; onSwipeableWillOpen = (threadInfo: ThreadInfo) => { this.setState(state => ({ ...state, openedSwipeableId: threadInfo.id })); }; composeThread = () => { if (!this.props.viewerID) { return; } const threadInfo = createPendingThread({ viewerID: this.props.viewerID, threadType: threadTypes.PRIVATE, }); this.props.navigateToThread({ threadInfo, searching: true }); }; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, searchContainer: { backgroundColor: 'listBackground', display: 'flex', justifyContent: 'center', flexDirection: 'row', }, searchBox: { flex: 1, }, search: { marginBottom: 8, marginHorizontal: 18, marginTop: 16, }, cancelSearchButton: { position: 'absolute', right: 0, top: 0, bottom: 0, display: 'flex', justifyContent: 'center', }, cancelSearchButtonText: { color: 'link', fontSize: 16, paddingHorizontal: 16, paddingTop: 8, }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; -const ConnectedChatThreadList: React.ComponentType = React.memo( - function ConnectedChatThreadList(props: BaseProps) { +const ConnectedChatThreadList: React.ComponentType = + React.memo(function ConnectedChatThreadList(props: BaseProps) { const boundChatListData = useFlattenedChatListData(); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const threadSearchIndex = useGlobalThreadSearchIndex(); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); const callSearchUsers = useServerCall(searchUsers); const usersWithPersonalThread = useSelector( usersWithPersonalThreadSelector, ); const navigateToThread = useNavigateToThread(); return ( ); - }, -); + }); export default ConnectedChatThreadList; diff --git a/native/chat/compose-subchannel.react.js b/native/chat/compose-subchannel.react.js index 0996837d3..892477faa 100644 --- a/native/chat/compose-subchannel.react.js +++ b/native/chat/compose-subchannel.react.js @@ -1,388 +1,386 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _sortBy from 'lodash/fp/sortBy.js'; import * as React from 'react'; import { View, Text, Alert } from 'react-native'; import { newThreadActionTypes, newThread } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.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 { threadInFilterList, userIsMember } from 'lib/shared/thread-utils.js'; import { type ThreadInfo, type ThreadType, threadTypes, } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import type { ChatNavigationProp } from './chat.react.js'; import { useNavigateToThread } from './message-list-types.js'; import ParentThreadHeader from './parent-thread-header.react.js'; import LinkButton from '../components/link-button.react.js'; import { createTagInput } from '../components/tag-input.react.js'; import ThreadList from '../components/thread-list.react.js'; import UserList from '../components/user-list.react.js'; import { useCalendarQuery } from '../navigation/nav-selectors.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; export type ComposeSubchannelParams = { +threadType: ThreadType, +parentThreadInfo: ThreadInfo, }; type Props = { +navigation: ChatNavigationProp<'ComposeSubchannel'>, +route: NavigationRoute<'ComposeSubchannel'>, }; function ComposeSubchannel(props: Props): React.Node { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); - const [createButtonEnabled, setCreateButtonEnabled] = React.useState( - true, - ); + const [createButtonEnabled, setCreateButtonEnabled] = + React.useState(true); const tagInputRef = React.useRef(); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setUsernameInputText(''); invariant(tagInputRef.current, 'tagInput should be set'); tagInputRef.current.focus(); }, []); const waitingOnThreadIDRef = React.useRef(); const { threadType, parentThreadInfo } = props.route.params; const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); const callNewThread = useServerCall(newThread); const calendarQuery = useCalendarQuery(); const newChatThreadAction = React.useCallback(async () => { try { const assumedThreadType = threadType ?? threadTypes.COMMUNITY_SECRET_SUBTHREAD; const query = calendarQuery(); invariant( assumedThreadType === 3 || assumedThreadType === 4 || assumedThreadType === 6 || assumedThreadType === 7, "Sidebars and communities can't be created from the thread composer", ); const result = await callNewThread({ type: assumedThreadType, parentThreadID: parentThreadInfo.id, initialMemberIDs: userInfoInputIDs, color: parentThreadInfo.color, calendarQuery: query, }); waitingOnThreadIDRef.current = result.newThreadID; return result; } catch (e) { setCreateButtonEnabled(true); Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ threadType, userInfoInputIDs, calendarQuery, parentThreadInfo, callNewThread, onUnknownErrorAlertAcknowledged, ]); const dispatchActionPromise = useDispatchActionPromise(); const dispatchNewChatThreadAction = React.useCallback(() => { setCreateButtonEnabled(false); dispatchActionPromise(newThreadActionTypes, newChatThreadAction()); }, [dispatchActionPromise, newChatThreadAction]); const userInfoInputArrayEmpty = userInfoInputArray.length === 0; const onPressCreateThread = React.useCallback(() => { if (!createButtonEnabled) { return; } if (userInfoInputArrayEmpty) { Alert.alert( 'Chatting to yourself?', 'Are you sure you want to create a channel containing only yourself?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Confirm', onPress: dispatchNewChatThreadAction }, ], { cancelable: true }, ); } else { dispatchNewChatThreadAction(); } }, [ createButtonEnabled, userInfoInputArrayEmpty, dispatchNewChatThreadAction, ]); const { navigation } = props; const { setOptions } = navigation; React.useEffect(() => { setOptions({ // eslint-disable-next-line react/display-name headerRight: () => ( ), }); }, [setOptions, onPressCreateThread, createButtonEnabled]); const { setParams } = navigation; const parentThreadInfoID = parentThreadInfo.id; const reduxParentThreadInfo = useSelector( state => threadInfoSelector(state)[parentThreadInfoID], ); React.useEffect(() => { if (reduxParentThreadInfo) { setParams({ parentThreadInfo: reduxParentThreadInfo }); } }, [reduxParentThreadInfo, setParams]); const threadInfos = useSelector(threadInfoSelector); const newlyCreatedThreadInfo = waitingOnThreadIDRef.current ? threadInfos[waitingOnThreadIDRef.current] : null; const { pushNewThread } = navigation; React.useEffect(() => { if (!newlyCreatedThreadInfo) { return; } const waitingOnThreadID = waitingOnThreadIDRef.current; if (waitingOnThreadID === null || waitingOnThreadID === undefined) { return; } waitingOnThreadIDRef.current = undefined; pushNewThread(newlyCreatedThreadInfo); }, [newlyCreatedThreadInfo, pushNewThread]); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const { community } = parentThreadInfo; const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const userSearchResults = React.useMemo( () => getPotentialMemberItems( usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs, parentThreadInfo, communityThreadInfo, threadType, ), [ usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs, parentThreadInfo, communityThreadInfo, threadType, ], ); const existingThreads = React.useMemo(() => { if (userInfoInputIDs.length === 0) { return []; } return _flow( _filter( (threadInfo: ThreadInfo) => threadInFilterList(threadInfo) && threadInfo.parentThreadID === parentThreadInfo.id && userInfoInputIDs.every(userID => userIsMember(threadInfo, userID)), ), _sortBy( ([ 'members.length', (threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0), ]: $ReadOnlyArray mixed)>), ), )(threadInfos); }, [userInfoInputIDs, threadInfos, parentThreadInfo]); const navigateToThread = useNavigateToThread(); const onSelectExistingThread = React.useCallback( (threadID: string) => { const threadInfo = threadInfos[threadID]; navigateToThread({ threadInfo }); }, [threadInfos, navigateToThread], ); const onUserSelect = React.useCallback( (userID: string) => { if (userInfoInputIDs.some(existingUserID => userID === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[userID], ]); setUsernameInputText(''); }, [userInfoInputIDs, otherUserInfos], ); const styles = useStyles(unboundStyles); let existingThreadsSection = null; if (existingThreads.length > 0) { existingThreadsSection = ( Existing channels ); } const inputProps = React.useMemo( () => ({ ...tagInputProps, onSubmitEditing: onPressCreateThread, }), [onPressCreateThread], ); const userSearchResultWithENSNames = useENSNames(userSearchResults); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); return ( To: {existingThreadsSection} ); } const unboundStyles = { container: { flex: 1, }, existingThreadList: { backgroundColor: 'modalBackground', flex: 1, paddingRight: 12, }, existingThreads: { flex: 1, }, existingThreadsLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, textAlign: 'center', }, existingThreadsRow: { backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', borderTopWidth: 1, paddingVertical: 6, }, listItem: { color: 'modalForegroundLabel', }, tagInputContainer: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, }, userList: { backgroundColor: 'modalBackground', flex: 1, paddingLeft: 35, paddingRight: 12, }, userSelectionRow: { alignItems: 'center', backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; -const MemoizedComposeSubchannel: React.ComponentType = React.memo( - ComposeSubchannel, -); +const MemoizedComposeSubchannel: React.ComponentType = + React.memo(ComposeSubchannel); export default MemoizedComposeSubchannel; diff --git a/native/chat/compose-thread-button.react.js b/native/chat/compose-thread-button.react.js index 2f4efe049..34af346c6 100644 --- a/native/chat/compose-thread-button.react.js +++ b/native/chat/compose-thread-button.react.js @@ -1,74 +1,73 @@ // @flow import * as React from 'react'; import { StyleSheet } from 'react-native'; import { createPendingThread } from 'lib/shared/thread-utils.js'; import { threadTypes } from 'lib/types/thread-types.js'; import type { ChatNavigationProp } from './chat.react.js'; import Button from '../components/button.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { MessageListRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors } from '../themes/colors.js'; type BaseProps = { +navigate: $PropertyType, 'navigate'>, }; type Props = { ...BaseProps, +colors: Colors, +viewerID: ?string, }; class ComposeThreadButton extends React.PureComponent { render() { const { listForegroundSecondaryLabel } = this.props.colors; return ( ); } onPress = () => { if (this.props.viewerID) { this.props.navigate<'MessageList'>({ name: MessageListRouteName, params: { threadInfo: createPendingThread({ viewerID: this.props.viewerID, threadType: threadTypes.PRIVATE, }), searching: true, }, }); } }; } const styles = StyleSheet.create({ composeButton: { marginRight: 16, }, }); -const ConnectedComposeThreadButton: React.ComponentType = React.memo( - function ConnectedComposeThreadButton(props) { +const ConnectedComposeThreadButton: React.ComponentType = + React.memo(function ConnectedComposeThreadButton(props) { const colors = useColors(); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); return ( ); - }, -); + }); export default ConnectedComposeThreadButton; diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index 930b1e3c0..27f9bccdc 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,247 +1,246 @@ // @flow import Icon from '@expo/vector-icons/Feather.js'; import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; import { createMessageReply } from 'lib/shared/message-utils.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; import { clusterEndHeight, inlineEngagementStyle, inlineEngagementLeftStyle, inlineEngagementRightStyle, composedMessageStyle, } from './chat-constants.js'; import { useComposedMessageMaxWidth } from './composed-message-width.js'; import { FailedSend } from './failed-send.react.js'; import { InlineEngagement } from './inline-engagement.react.js'; import { MessageHeader } from './message-header.react.js'; import { useNavigateToSidebar } from './sidebar-navigation.js'; import SwipeableMessage from './swipeable-message.react.js'; import { useContentAndHeaderOpacity, useDeliveryIconOpacity } from './utils.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { type Colors, useColors } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; import { type AnimatedStyleObj, AnimatedView } from '../types/styles.js'; /* eslint-disable import/no-named-as-default-member */ const { Node } = Animated; /* eslint-enable import/no-named-as-default-member */ type SwipeOptions = 'reply' | 'sidebar' | 'both' | 'none'; type BaseProps = { ...React.ElementConfig, +item: ChatMessageInfoItemWithHeight, +sendFailed: boolean, +focused: boolean, +swipeOptions: SwipeOptions, +children: React.Node, }; type Props = { ...BaseProps, // Redux state +composedMessageMaxWidth: number, +colors: Colors, +contentAndHeaderOpacity: number | Node, +deliveryIconOpacity: number | Node, // withInputState +inputState: ?InputState, +navigateToSidebar: () => mixed, }; class ComposedMessage extends React.PureComponent { render() { assertComposableMessageType(this.props.item.messageInfo.type); const { item, sendFailed, focused, swipeOptions, children, composedMessageMaxWidth, colors, inputState, navigateToSidebar, contentAndHeaderOpacity, deliveryIconOpacity, ...viewProps } = this.props; const { id, creator } = item.messageInfo; const { isViewer } = creator; const alignStyle = isViewer ? styles.rightChatBubble : styles.leftChatBubble; let containerMarginBottom = 5; if (item.endsCluster) { containerMarginBottom += clusterEndHeight; } const containerStyle = [ styles.alignment, { marginBottom: containerMarginBottom }, ]; const messageBoxStyle = { maxWidth: composedMessageMaxWidth }; let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconName; let deliveryIconColor = `#${item.threadInfo.color}`; if (id !== null && id !== undefined) { deliveryIconName = 'check-circle'; } else if (sendFailed) { deliveryIconName = 'x-circle'; deliveryIconColor = colors.redText; failedSendInfo = ; } else { deliveryIconName = 'circle'; } const animatedStyle: AnimatedStyleObj = { opacity: deliveryIconOpacity }; deliveryIcon = ( ); } const triggerReply = swipeOptions === 'reply' || swipeOptions === 'both' ? this.reply : undefined; const triggerSidebar = swipeOptions === 'sidebar' || swipeOptions === 'both' ? navigateToSidebar : undefined; const messageBox = ( {children} ); let inlineEngagement = null; if (item.threadCreatedFromMessage || item.reactions.size > 0) { const positioning = isViewer ? 'right' : 'left'; const inlineEngagementPositionStyle = positioning === 'left' ? styles.leftInlineEngagement : styles.rightInlineEngagement; inlineEngagement = ( ); } return ( {deliveryIcon} {messageBox} {failedSendInfo} {inlineEngagement} ); } reply = () => { const { inputState, item } = this.props; invariant(inputState, 'inputState should be set in reply'); invariant(item.messageInfo.text, 'text should be set in reply'); inputState.addReply(createMessageReply(item.messageInfo.text)); }; } const styles = StyleSheet.create({ alignment: { marginLeft: composedMessageStyle.marginLeft, marginRight: composedMessageStyle.marginRight, }, content: { alignItems: 'center', flexDirection: 'row-reverse', }, icon: { fontSize: 16, textAlign: 'center', }, iconContainer: { marginLeft: 2, width: 16, }, inlineEngagement: { marginBottom: inlineEngagementStyle.marginBottom, marginTop: inlineEngagementStyle.marginTop, }, leftChatBubble: { justifyContent: 'flex-end', }, leftInlineEngagement: { justifyContent: 'flex-start', position: 'relative', top: inlineEngagementLeftStyle.topOffset, }, messageBox: { marginRight: 5, }, rightChatBubble: { justifyContent: 'flex-start', }, rightInlineEngagement: { alignSelf: 'flex-end', position: 'relative', right: inlineEngagementRightStyle.marginRight, top: inlineEngagementRightStyle.topOffset, }, }); -const ConnectedComposedMessage: React.ComponentType = React.memo( - function ConnectedComposedMessage(props: BaseProps) { +const ConnectedComposedMessage: React.ComponentType = + React.memo(function ConnectedComposedMessage(props: BaseProps) { const composedMessageMaxWidth = useComposedMessageMaxWidth(); const colors = useColors(); const inputState = React.useContext(InputStateContext); const navigateToSidebar = useNavigateToSidebar(props.item); const contentAndHeaderOpacity = useContentAndHeaderOpacity(props.item); const deliveryIconOpacity = useDeliveryIconOpacity(props.item); return ( ); - }, -); + }); export default ConnectedComposedMessage; diff --git a/native/chat/failed-send.react.js b/native/chat/failed-send.react.js index 51572b442..6442c3af4 100644 --- a/native/chat/failed-send.react.js +++ b/native/chat/failed-send.react.js @@ -1,170 +1,169 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { messageID } from 'lib/shared/message-utils.js'; import { assertComposableRawMessage, messageTypes, } from 'lib/types/message-types.js'; import type { RawComposableMessageInfo } from 'lib/types/message-types.js'; import { multimediaMessageSendFailed } from './multimedia-message-utils.js'; import textMessageSendFailed from './text-message-send-failed.js'; import Button from '../components/button.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; const failedSendHeight = 22; type BaseProps = { +item: ChatMessageInfoItemWithHeight, }; type Props = { ...BaseProps, // Redux state +rawMessageInfo: ?RawComposableMessageInfo, +styles: typeof unboundStyles, // withInputState +inputState: ?InputState, }; class FailedSend extends React.PureComponent { retryingText = false; retryingMedia = false; componentDidUpdate(prevProps: Props) { const newItem = this.props.item; const prevItem = prevProps.item; if ( newItem.messageShapeType === 'multimedia' && prevItem.messageShapeType === 'multimedia' ) { const isFailed = multimediaMessageSendFailed(newItem); const wasFailed = multimediaMessageSendFailed(prevItem); const isDone = newItem.messageInfo.id !== null && newItem.messageInfo.id !== undefined; const wasDone = prevItem.messageInfo.id !== null && prevItem.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingMedia = false; } } else if ( newItem.messageShapeType === 'text' && prevItem.messageShapeType === 'text' ) { const isFailed = textMessageSendFailed(newItem); const wasFailed = textMessageSendFailed(prevItem); const isDone = newItem.messageInfo.id !== null && newItem.messageInfo.id !== undefined; const wasDone = prevItem.messageInfo.id !== null && prevItem.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingText = false; } } } render() { if (!this.props.rawMessageInfo) { return null; } const threadColor = { color: `#${this.props.item.threadInfo.color}`, }; return ( DELIVERY FAILED. ); } retrySend = () => { const { rawMessageInfo } = this.props; if (!rawMessageInfo) { return; } if (rawMessageInfo.type === messageTypes.TEXT) { if (this.retryingText) { return; } this.retryingText = true; } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { if (this.retryingMedia) { return; } this.retryingMedia = true; } const { inputState } = this.props; invariant( inputState, 'inputState should be initialized before user can hit retry', ); const { localID } = rawMessageInfo; invariant(localID, 'failed RawMessageInfo should have localID'); inputState.retryMessage(localID, this.props.item.threadInfo); }; } const unboundStyles = { deliveryFailed: { color: 'listSeparatorLabel', paddingHorizontal: 3, }, failedSendInfo: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', marginRight: 20, paddingTop: 5, }, retrySend: { paddingHorizontal: 3, }, }; -const ConnectedFailedSend: React.ComponentType = React.memo( - function ConnectedFailedSend(props: BaseProps) { +const ConnectedFailedSend: React.ComponentType = + React.memo(function ConnectedFailedSend(props: BaseProps) { const id = messageID(props.item.messageInfo); const rawMessageInfo = useSelector(state => { const message = state.messageStore.messages[id]; return message ? assertComposableRawMessage(message) : null; }); const styles = useStyles(unboundStyles); const inputState = React.useContext(InputStateContext); return ( ); - }, -); + }); export { ConnectedFailedSend as FailedSend, failedSendHeight }; diff --git a/native/chat/inline-engagement.react.js b/native/chat/inline-engagement.react.js index ebf8e6a2d..a57c83b81 100644 --- a/native/chat/inline-engagement.react.js +++ b/native/chat/inline-engagement.react.js @@ -1,247 +1,247 @@ // @flow import { useNavigation } from '@react-navigation/native'; 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 { MessageReactionInfo } from 'lib/selectors/chat-selectors.js'; import { stringForReactionList } from 'lib/shared/reaction-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { inlineEngagementStyle, inlineEngagementCenterStyle, inlineEngagementRightStyle, composedMessageStyle, } 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?: $ReadOnlyMap, +disabled?: boolean, }; function InlineEngagement(props: Props): React.Node { const { disabled = false, reactions, threadInfo } = 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 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 || reactions.size === 0) { return null; } const reactionText = stringForReactionList(reactions); const reactionItems = {reactionText}; return ( {reactionItems} ); }, [ marginLeft, onPressReactions, reactions, styles.reaction, styles.reactionsContainer, ]); return ( {sidebarItem} {reactionList} ); } const unboundStyles = { container: { flexDirection: 'row', height: inlineEngagementStyle.height, borderRadius: 16, backgroundColor: 'inlineEngagementBackground', alignSelf: 'baseline', alignItems: 'center', padding: 8, }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, sidebar: { flexDirection: 'row', 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', }, }; 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; const inlineEngagementStyles = React.useMemo(() => { if (positioning === 'left') { return { position: 'absolute', top: inlineEngagementStyle.marginTop + inlineEngagementRightStyle.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, }; } }, [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/inner-robotext-message.react.js b/native/chat/inner-robotext-message.react.js index 6db7290c9..19f38aafb 100644 --- a/native/chat/inner-robotext-message.react.js +++ b/native/chat/inner-robotext-message.react.js @@ -1,147 +1,146 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, TouchableWithoutFeedback, View } from 'react-native'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { entityTextToReact, entityTextToRawString, useENSNamesForEntityText, type EntityText, } from 'lib/utils/entity-text.js'; import { useNavigateToThread } from './message-list-types.js'; import Markdown from '../markdown/markdown.react.js'; import { inlineMarkdownRules } from '../markdown/rules.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOverlayStyles } from '../themes/colors.js'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types.js'; function dummyNodeForRobotextMessageHeightMeasurement( robotext: EntityText, threadID: string, ): React.Element { return ( {entityTextToRawString(robotext, { threadID })} ); } type InnerRobotextMessageProps = { +item: ChatRobotextMessageInfoItemWithHeight, +onPress: () => void, +onLongPress?: () => void, }; function InnerRobotextMessage(props: InnerRobotextMessageProps): React.Node { const { item, onLongPress, onPress } = props; const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const styles = useOverlayStyles(unboundStyles); const { messageInfo, robotext } = item; const { threadID } = messageInfo; const robotextWithENSNames = useENSNamesForEntityText(robotext); invariant( robotextWithENSNames, 'useENSNamesForEntityText only returns falsey when passed falsey', ); const textParts = React.useMemo(() => { const darkColor = activeTheme === 'dark'; return entityTextToReact(robotextWithENSNames, threadID, { // eslint-disable-next-line react/display-name renderText: ({ text }) => ( {text} ), // eslint-disable-next-line react/display-name renderThread: ({ id, name }) => , // eslint-disable-next-line react/display-name renderColor: ({ hex }) => , }); }, [robotextWithENSNames, activeTheme, threadID, styles.robotext]); const viewStyle = [styles.robotextContainer]; if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct viewStyle.push({ height: item.contentHeight }); } return ( {textParts} ); } type ThreadEntityProps = { +id: string, +name: string, }; function ThreadEntity(props: ThreadEntityProps) { const threadID = props.id; const threadInfo = useSelector(state => threadInfoSelector(state)[threadID]); const styles = useOverlayStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const onPressThread = React.useCallback(() => { invariant(threadInfo, 'onPressThread should have threadInfo'); navigateToThread({ threadInfo }); }, [threadInfo, navigateToThread]); if (!threadInfo) { return {props.name}; } return ( {props.name} ); } function ColorEntity(props: { +color: string }) { const colorStyle = { color: props.color }; return {props.color}; } const unboundStyles = { link: { color: 'link', }, robotextContainer: { paddingTop: 6, paddingBottom: 11, paddingHorizontal: 24, }, robotext: { color: 'listForegroundSecondaryLabel', fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, dummyRobotext: { fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, }; -const MemoizedInnerRobotextMessage: React.ComponentType = React.memo( - InnerRobotextMessage, -); +const MemoizedInnerRobotextMessage: React.ComponentType = + React.memo(InnerRobotextMessage); export { dummyNodeForRobotextMessageHeightMeasurement, MemoizedInnerRobotextMessage as InnerRobotextMessage, }; diff --git a/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js index ac346ed00..cf13c9328 100644 --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -1,363 +1,363 @@ // @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 { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors.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 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 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 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 { 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 inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState should be set in MessageListContainer'); const hideSearch = React.useCallback(() => { setBaseThreadInfo(threadInfo); setParams({ searching: false }); }, [setParams, threadInfo]); React.useEffect(() => { if (!isSearching) { return; } 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 { addReply } = inputState; const resolveToUser = React.useCallback( (user: AccountUserInfo) => { const resolvedThreadInfo = existingThreadInfoFinder({ searching: true, userInfoInputArray: [user], }); invariant( resolvedThreadInfo, 'resolvedThreadInfo must be specified in messageListContainer', ); addReply(''); setBaseThreadInfo(resolvedThreadInfo); setParams({ searching: false, threadInfo: resolvedThreadInfo }); }, [setParams, existingThreadInfoFinder, addReply], ); const threadID = threadInfo.id; const messageListData = useMessageListData({ 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/message-list-header-title.react.js b/native/chat/message-list-header-title.react.js index 04797519e..68e437fd4 100644 --- a/native/chat/message-list-header-title.react.js +++ b/native/chat/message-list-header-title.react.js @@ -1,118 +1,119 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import { HeaderTitle, type HeaderTitleInputProps, } from '@react-navigation/elements'; import * as React from 'react'; import { View, Platform } from 'react-native'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { firstLine } from 'lib/utils/string-utils.js'; import type { ChatNavigationProp } from './chat.react.js'; import Button from '../components/button.react.js'; import { ThreadSettingsRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type BaseProps = { +threadInfo: ThreadInfo, +navigate: $PropertyType, 'navigate'>, +isSearchEmpty: boolean, +areSettingsEnabled: boolean, ...HeaderTitleInputProps, }; type Props = { ...BaseProps, +styles: typeof unboundStyles, +title: string, }; class MessageListHeaderTitle extends React.PureComponent { render() { const { threadInfo, navigate, isSearchEmpty, areSettingsEnabled, styles, title, ...rest } = this.props; let icon, fakeIcon; if (Platform.OS === 'ios' && areSettingsEnabled) { icon = ( ); fakeIcon = ( ); } return ( ); } onPress = () => { const { threadInfo } = this.props; this.props.navigate<'ThreadSettings'>({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { button: { flex: 1, }, container: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: Platform.OS === 'android' ? 'flex-start' : 'center', }, fakeIcon: { paddingRight: 3, paddingTop: 3, flex: 1, minWidth: 25, opacity: 0, }, forwardIcon: { paddingLeft: 3, paddingTop: 3, color: 'headerChevron', flex: 1, minWidth: 25, }, }; -const ConnectedMessageListHeaderTitle: React.ComponentType = React.memo( - function ConnectedMessageListHeaderTitle(props: BaseProps) { +const ConnectedMessageListHeaderTitle: React.ComponentType = + React.memo(function ConnectedMessageListHeaderTitle( + props: BaseProps, + ) { const styles = useStyles(unboundStyles); const { uiName } = useResolvedThreadInfo(props.threadInfo); const { isSearchEmpty } = props; const title = isSearchEmpty ? 'New Message' : uiName; return ; - }, -); + }); export default ConnectedMessageListHeaderTitle; diff --git a/native/chat/message-list-types.js b/native/chat/message-list-types.js index 62cc125d6..edf559505 100644 --- a/native/chat/message-list-types.js +++ b/native/chat/message-list-types.js @@ -1,85 +1,86 @@ // @flow import { useNavigation } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { type UserInfo } from 'lib/types/user-types.js'; import type { MarkdownRules } from '../markdown/rules.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; import { MessageListRouteName } from '../navigation/route-names.js'; export type MessageListParams = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, +searching?: boolean, }; export type MessageListContextType = { +getTextMessageMarkdownRules: (useDarkStyle: boolean) => MarkdownRules, }; -const MessageListContext: React.Context = React.createContext(); +const MessageListContext: React.Context = + React.createContext(); function useMessageListContext(threadID: ?string) { const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadID); return React.useMemo( () => ({ getTextMessageMarkdownRules, }), [getTextMessageMarkdownRules], ); } type Props = { +children: React.Node, +threadID: ?string, }; function MessageListContextProvider(props: Props): React.Node { const context = useMessageListContext(props.threadID); return ( {props.children} ); } type NavigateToThreadAction = { +name: typeof MessageListRouteName, +params: MessageListParams, +key: string, }; function createNavigateToThreadAction( params: MessageListParams, ): NavigateToThreadAction { return { name: MessageListRouteName, params, key: `${MessageListRouteName}${params.threadInfo.id}`, }; } function useNavigateToThread(): (params: MessageListParams) => void { const { navigate } = useNavigation(); return React.useCallback( (params: MessageListParams) => { navigate<'MessageList'>(createNavigateToThreadAction(params)); }, [navigate], ); } function useTextMessageMarkdownRules(useDarkStyle: boolean): MarkdownRules { const messageListContext = React.useContext(MessageListContext); invariant(messageListContext, 'DummyTextNode should have MessageListContext'); return messageListContext.getTextMessageMarkdownRules(useDarkStyle); } export { MessageListContextProvider, createNavigateToThreadAction, useNavigateToThread, useTextMessageMarkdownRules, }; diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js index 017cb40b2..2edb398a1 100644 --- a/native/chat/message-list.react.js +++ b/native/chat/message-list.react.js @@ -1,384 +1,379 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import * as React from 'react'; import { View, TouchableWithoutFeedback } from 'react-native'; import { createSelector } from 'reselect'; import { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from 'lib/actions/message-actions.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { useWatchThread } from 'lib/shared/thread-utils.js'; import type { FetchMessageInfosPayload } from 'lib/types/message-types.js'; import { type ThreadInfo, threadTypes } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import ChatList from './chat-list.react.js'; import type { ChatNavigationProp } from './chat.react.js'; import { Message } from './message.react.js'; import RelationshipPrompt from './relationship-prompt.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.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 type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight, ChatMessageItemWithHeight, } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; import type { ViewableItemsChange } from '../types/react-native.js'; type BaseProps = { +threadInfo: ThreadInfo, +messageListData: $ReadOnlyArray, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, // Redux state +startReached: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +fetchMessagesBeforeCursor: ( threadID: string, beforeMessageID: string, ) => Promise, +fetchMostRecentMessages: ( threadID: string, ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, }; type State = { +focusedMessageKey: ?string, +messageListVerticalBounds: ?VerticalBounds, +loadingFromScroll: boolean, }; type PropsAndState = { ...Props, ...State, }; type FlatListExtraData = { messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, }; class MessageList extends React.PureComponent { state: State = { focusedMessageKey: null, messageListVerticalBounds: null, loadingFromScroll: false, }; flatListContainer: ?React.ElementRef; flatListExtraDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.messageListVerticalBounds, (propsAndState: PropsAndState) => propsAndState.focusedMessageKey, (propsAndState: PropsAndState) => propsAndState.navigation, (propsAndState: PropsAndState) => propsAndState.route, ( messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, ) => ({ messageListVerticalBounds, focusedMessageKey, navigation, route, }), ); get flatListExtraData(): FlatListExtraData { return this.flatListExtraDataSelector({ ...this.props, ...this.state }); } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'MessageList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } static modalOpen(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus === 'open'; } componentDidUpdate(prevProps: Props) { const newListData = this.props.messageListData; const oldListData = prevProps.messageListData; if ( this.state.loadingFromScroll && (newListData.length > oldListData.length || this.props.startReached) ) { this.setState({ loadingFromScroll: false }); } const modalIsOpen = MessageList.modalOpen(this.props); const modalWasOpen = MessageList.modalOpen(prevProps); if (!modalIsOpen && modalWasOpen) { this.setState({ focusedMessageKey: null }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = MessageList.scrollDisabled(this.props); const scrollWasDisabled = MessageList.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; renderItem = (row: { item: ChatMessageItemWithHeight, ... }) => { if (row.item.itemType === 'loader') { return ( ); } const messageInfoItem: ChatMessageInfoItemWithHeight = row.item; - const { - messageListVerticalBounds, - focusedMessageKey, - navigation, - route, - } = this.flatListExtraData; + const { messageListVerticalBounds, focusedMessageKey, navigation, route } = + this.flatListExtraData; const focused = messageKey(messageInfoItem.messageInfo) === focusedMessageKey; return ( ); }; toggleMessageFocus = (inMessageKey: string) => { if (this.state.focusedMessageKey === inMessageKey) { this.setState({ focusedMessageKey: null }); } else { this.setState({ focusedMessageKey: inMessageKey }); } }; // Actually header, it's just that our FlatList is inverted ListFooterComponent = () => ; render() { const { messageListData, startReached } = this.props; const footer = startReached ? this.ListFooterComponent : undefined; let relationshipPrompt = null; if (this.props.threadInfo.type === threadTypes.PERSONAL) { relationshipPrompt = ( ); } return ( {relationshipPrompt} ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ messageListVerticalBounds: { height, y: pageY } }); }); }; onViewableItemsChanged = (info: ViewableItemsChange) => { if (this.state.focusedMessageKey) { let focusedMessageVisible = false; for (const token of info.viewableItems) { if ( token.item.itemType === 'message' && messageKey(token.item.messageInfo) === this.state.focusedMessageKey ) { focusedMessageVisible = true; break; } } if (!focusedMessageVisible) { this.setState({ focusedMessageKey: null }); } } const loader = _find({ key: 'loader' })(info.viewableItems); if (!loader || this.state.loadingFromScroll) { return; } this.setState({ loadingFromScroll: true }); const oldestMessageServerID = this.oldestMessageServerID(); const threadID = this.props.threadInfo.id; if (oldestMessageServerID) { this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } else { this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages(threadID), ); } }; oldestMessageServerID(): ?string { const data = this.props.messageListData; for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return null; } } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, header: { height: 12, }, listLoadingIndicator: { flex: 1, }, }; registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); -const ConnectedMessageList: React.ComponentType = React.memo( - function ConnectedMessageList(props: BaseProps) { +const ConnectedMessageList: React.ComponentType = + React.memo(function ConnectedMessageList(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const threadID = props.threadInfo.id; const startReached = useSelector( state => !!( state.messageStore.threads[threadID] && state.messageStore.threads[threadID].startReached ), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useServerCall( fetchMessagesBeforeCursor, ); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); useWatchThread(props.threadInfo); return ( ); - }, -); + }); export default ConnectedMessageList; diff --git a/native/chat/message-press-responder-context.js b/native/chat/message-press-responder-context.js index a40a90c30..f918849fa 100644 --- a/native/chat/message-press-responder-context.js +++ b/native/chat/message-press-responder-context.js @@ -1,13 +1,12 @@ // @flow import * as React from 'react'; export type MessagePressResponderContextType = { +onPressMessage: () => void, }; -const MessagePressResponderContext: React.Context = React.createContext( - null, -); +const MessagePressResponderContext: React.Context = + React.createContext(null); export { MessagePressResponderContext }; diff --git a/native/chat/multimedia-message-multimedia.react.js b/native/chat/multimedia-message-multimedia.react.js index 9c2842725..ae626f5dc 100644 --- a/native/chat/multimedia-message-multimedia.react.js +++ b/native/chat/multimedia-message-multimedia.react.js @@ -1,239 +1,239 @@ // @flow import { type LeafRoute, useRoute } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import Animated from 'react-native-reanimated'; import { type MediaInfo } from 'lib/types/media-types.js'; import InlineMultimedia from './inline-multimedia.react.js'; import { getMediaKey } from './multimedia-message-utils.js'; import { type PendingMultimediaUpload } from '../input/input-state.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import { ImageModalRouteName } from '../navigation/route-names.js'; import { type Colors, useColors } from '../themes/colors.js'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js'; import type { VerticalBounds, LayoutCoordinates, } from '../types/layout-types.js'; import { type ViewStyle, type AnimatedStyleObj, AnimatedView, } from '../types/styles.js'; /* eslint-disable import/no-named-as-default-member */ const { Node, sub, interpolateNode, Extrapolate } = Animated; /* eslint-enable import/no-named-as-default-member */ type BaseProps = { +mediaInfo: MediaInfo, +item: ChatMultimediaMessageInfoItem, +verticalBounds: ?VerticalBounds, +style: ViewStyle, +postInProgress: boolean, +pendingUpload: ?PendingMultimediaUpload, +onPressMultimedia?: ( mediaInfo: MediaInfo, initialCoordinates: LayoutCoordinates, ) => void, +clickable: boolean, +setClickable: boolean => void, }; type Props = { ...BaseProps, +route: LeafRoute<>, // Redux state +colors: Colors, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, }; type State = { +opacity: number | Node, }; class MultimediaMessageMultimedia extends React.PureComponent { view: ?React.ElementRef; constructor(props: Props) { super(props); this.state = { opacity: this.getOpacity(), }; } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant( overlayContext, 'MultimediaMessageMultimedia should have OverlayContext', ); return overlayContext; } static getModalOverlayPosition(props: Props) { const overlayContext = MultimediaMessageMultimedia.getOverlayContext(props); const { visibleOverlays } = overlayContext; for (const overlay of visibleOverlays) { if ( overlay.routeName === ImageModalRouteName && overlay.presentedFrom === props.route.key && overlay.routeKey === getMediaKey(props.item, props.mediaInfo) ) { return overlay.position; } } return undefined; } getOpacity() { const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( this.props, ); if (!overlayPosition) { return 1; } return sub( 1, interpolateNode(overlayPosition, { inputRange: [0.1, 0.11], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), ); } componentDidUpdate(prevProps: Props) { const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( this.props, ); - const prevOverlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( - prevProps, - ); + const prevOverlayPosition = + MultimediaMessageMultimedia.getModalOverlayPosition(prevProps); if (overlayPosition !== prevOverlayPosition) { this.setState({ opacity: this.getOpacity() }); } const scrollIsDisabled = MultimediaMessageMultimedia.getOverlayContext(this.props) .scrollBlockingModalStatus !== 'closed'; const scrollWasDisabled = MultimediaMessageMultimedia.getOverlayContext(prevProps) .scrollBlockingModalStatus !== 'closed'; if (!scrollIsDisabled && scrollWasDisabled) { this.props.setClickable(true); } } render() { const { opacity } = this.state; const animatedWrapperStyle: AnimatedStyleObj = { opacity }; const wrapperStyles = [ styles.container, animatedWrapperStyle, this.props.style, ]; const { mediaInfo, pendingUpload, postInProgress } = this.props; return ( ); } onLayout = () => {}; viewRef = (view: ?React.ElementRef) => { this.view = view; }; onPress = () => { if (!this.props.clickable) { return; } if (this.dismissKeyboardIfShowing()) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } const measureCallback = this.props.onPressMultimedia; if (!measureCallback) { return; } this.props.setClickable(false); const overlayContext = MultimediaMessageMultimedia.getOverlayContext( this.props, ); overlayContext.setScrollBlockingModalStatus('open'); view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; measureCallback(this.props.mediaInfo, coordinates); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const styles = StyleSheet.create({ container: { flex: 1, overflow: 'hidden', }, expand: { flex: 1, }, }); -const ConnectedMultimediaMessageMultimedia: React.ComponentType = React.memo( - function ConnectedMultimediaMessageMultimedia(props: BaseProps) { +const ConnectedMultimediaMessageMultimedia: React.ComponentType = + React.memo(function ConnectedMultimediaMessageMultimedia( + props: BaseProps, + ) { const colors = useColors(); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const route = useRoute(); return ( ); - }, -); + }); export default ConnectedMultimediaMessageMultimedia; diff --git a/native/chat/multimedia-message-tooltip-button.react.js b/native/chat/multimedia-message-tooltip-button.react.js index 051a830f0..e5650a6a3 100644 --- a/native/chat/multimedia-message-tooltip-button.react.js +++ b/native/chat/multimedia-message-tooltip-button.react.js @@ -1,192 +1,190 @@ // @flow import * as React from 'react'; import Animated from 'react-native-reanimated'; import EmojiPicker from 'rn-emoji-keyboard'; import { localIDPrefix } from 'lib/shared/message-utils.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { TooltipInlineEngagement } from './inline-engagement.react.js'; import { InnerMultimediaMessage } from './inner-multimedia-message.react.js'; import { MessageHeader } from './message-header.react.js'; import { useSendReaction, useReactionSelectionPopoverPosition, } from './reaction-message-utils.js'; import ReactionSelectionPopover from './reaction-selection-popover.react.js'; import SidebarInputBarHeightMeasurer from './sidebar-input-bar-height-measurer.react.js'; import { useAnimatedMessageTooltipButton } from './utils.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useTooltipActions } from '../tooltip/tooltip-hooks.js'; import type { TooltipRoute } from '../tooltip/tooltip.react.js'; /* eslint-disable import/no-named-as-default-member */ const { Node, Extrapolate, interpolateNode } = Animated; /* eslint-enable import/no-named-as-default-member */ function noop() {} type Props = { +navigation: AppNavigationProp<'MultimediaMessageTooltipModal'>, +route: TooltipRoute<'MultimediaMessageTooltipModal'>, +progress: Node, +isOpeningSidebar: boolean, }; function MultimediaMessageTooltipButton(props: Props): React.Node { const { navigation, route, progress, isOpeningSidebar } = props; const windowWidth = useSelector(state => state.dimensions.width); - const [ - sidebarInputBarHeight, - setSidebarInputBarHeight, - ] = React.useState(null); + const [sidebarInputBarHeight, setSidebarInputBarHeight] = + React.useState(null); const onInputBarMeasured = React.useCallback((height: number) => { setSidebarInputBarHeight(height); }, []); const { item, verticalBounds, initialCoordinates, margin } = route.params; const { style: messageContainerStyle } = useAnimatedMessageTooltipButton({ sourceMessage: item, initialCoordinates, messageListVerticalBounds: verticalBounds, progress, targetInputBarHeight: sidebarInputBarHeight, }); const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; const opacity = interpolateNode(progress, { inputRange: [0, 0.05], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); return { opacity, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [initialCoordinates.height, initialCoordinates.x, progress, windowWidth]); const inlineEngagement = React.useMemo(() => { if (!item.threadCreatedFromMessage) { return null; } return ( ); }, [initialCoordinates, isOpeningSidebar, item, progress, windowWidth]); const innerMultimediaMessage = React.useMemo( () => ( ), [item, navigation.goBackOnce, verticalBounds], ); const { messageInfo, threadInfo, reactions } = item; const nextLocalID = useSelector(state => state.nextLocalID); const localID = `${localIDPrefix}${nextLocalID}`; const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); const sendReaction = useSendReaction( messageInfo.id, localID, threadInfo.id, reactions, ); const reactionSelectionPopoverPosition = useReactionSelectionPopoverPosition({ initialCoordinates, verticalBounds, margin, }); const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); const openEmojiPicker = React.useCallback(() => { setEmojiPickerOpen(true); }, []); const reactionSelectionPopover = React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } return ( ); }, [ navigation, route, openEmojiPicker, canCreateReactionFromMessage, reactionSelectionPopoverPosition, sendReaction, ]); const tooltipRouteKey = route.key; const { dismissTooltip } = useTooltipActions(navigation, tooltipRouteKey); const onEmojiSelected = React.useCallback( emoji => { sendReaction(emoji.emoji); dismissTooltip(); }, [sendReaction, dismissTooltip], ); return ( <> {reactionSelectionPopover} {innerMultimediaMessage} {inlineEngagement} ); } export default MultimediaMessageTooltipButton; diff --git a/native/chat/multimedia-message.react.js b/native/chat/multimedia-message.react.js index 4d2325f8c..df5bb17d2 100644 --- a/native/chat/multimedia-message.react.js +++ b/native/chat/multimedia-message.react.js @@ -1,252 +1,251 @@ // @flow import type { LeafRoute, NavigationProp, ParamListBase, } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native'; import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils.js'; import { useCanCreateSidebarFromMessage } from 'lib/shared/thread-utils.js'; import type { MediaInfo } from 'lib/types/media-types.js'; import ComposedMessage from './composed-message.react.js'; import { InnerMultimediaMessage } from './inner-multimedia-message.react.js'; import { getMediaKey, multimediaMessageSendFailed, } from './multimedia-message-utils.js'; import { getMessageTooltipKey } from './utils.js'; import { ChatContext, type ChatContextType } from '../chat/chat-context.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { OverlayContextType } from '../navigation/overlay-context.js'; import { ImageModalRouteName, MultimediaMessageTooltipModalRouteName, VideoPlaybackModalRouteName, } from '../navigation/route-names.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js'; import type { VerticalBounds, LayoutCoordinates, } from '../types/layout-types.js'; type BaseProps = { ...React.ElementConfig, +item: ChatMultimediaMessageInfoItem, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, }; type Props = { ...BaseProps, +navigation: NavigationProp, +route: LeafRoute<>, +overlayContext: ?OverlayContextType, +chatContext: ?ChatContextType, +canCreateSidebarFromMessage: boolean, }; type State = { +clickable: boolean, }; class MultimediaMessage extends React.PureComponent { state: State = { clickable: true, }; view: ?React.ElementRef; setClickable = (clickable: boolean) => { this.setState({ clickable }); }; onPressMultimedia = ( mediaInfo: MediaInfo, initialCoordinates: LayoutCoordinates, ) => { const { navigation, item, route, verticalBounds } = this.props; navigation.navigate<'VideoPlaybackModal' | 'ImageModal'>({ name: mediaInfo.type === 'video' ? VideoPlaybackModalRouteName : ImageModalRouteName, key: getMediaKey(item, mediaInfo), params: { presentedFrom: route.key, mediaInfo, item, initialCoordinates, verticalBounds, }, }); }; visibleEntryIDs() { const result = []; if ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ) { result.push('sidebar'); } if (!this.props.item.messageInfo.creator.isViewer) { result.push('report'); } return result; } onLayout = () => {}; viewRef = (view: ?React.ElementRef) => { this.view = view; }; onLongPress = () => { const visibleEntryIDs = this.visibleEntryIDs(); if (visibleEntryIDs.length === 0) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.state.clickable) { return; } this.setClickable(false); const { item } = this.props; if (!this.props.focused) { this.props.toggleFocus(messageKey(item.messageInfo)); } this.props.overlayContext?.setScrollBlockingModalStatus('open'); view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const multimediaTop = pageY; const multimediaBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = belowMargin; if ( multimediaBottom + belowSpace > boundsBottom && multimediaTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = this.props.chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; this.props.navigation.navigate<'MultimediaMessageTooltipModal'>({ name: MultimediaMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, item, initialCoordinates: coordinates, verticalBounds, tooltipLocation: 'fixed', margin, visibleEntryIDs, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }); }; canNavigateToSidebar() { return ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } render() { const { item, focused, toggleFocus, verticalBounds, navigation, route, overlayContext, chatContext, canCreateSidebarFromMessage, ...viewProps } = this.props; return ( ); } } const styles = StyleSheet.create({ expand: { flex: 1, }, }); -const ConnectedMultimediaMessage: React.ComponentType = React.memo( - function ConnectedMultimediaMessage(props: BaseProps) { +const ConnectedMultimediaMessage: React.ComponentType = + React.memo(function ConnectedMultimediaMessage(props: BaseProps) { const navigation = useNavigation(); const route = useRoute(); const overlayContext = React.useContext(OverlayContext); const chatContext = React.useContext(ChatContext); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( props.item.threadInfo, props.item.messageInfo, ); return ( ); - }, -); + }); export default ConnectedMultimediaMessage; diff --git a/native/chat/new-messages-pill.react.js b/native/chat/new-messages-pill.react.js index 95f4c367d..874aeaa72 100644 --- a/native/chat/new-messages-pill.react.js +++ b/native/chat/new-messages-pill.react.js @@ -1,73 +1,68 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import * as React from 'react'; import { TouchableOpacity, View, Text, Platform, Animated } from 'react-native'; import { useStyles } from '../themes/colors.js'; import type { ViewStyle } from '../types/styles.js'; type Props = { +onPress: () => mixed, +newMessageCount: number, +containerStyle?: ViewStyle, +style?: ViewStyle, ...React.ElementConfig, }; function NewMessagesPill(props: Props): React.Node { - const { - onPress, - newMessageCount, - containerStyle, - style, - ...containerProps - } = props; + const { onPress, newMessageCount, containerStyle, style, ...containerProps } = + props; const styles = useStyles(unboundStyles); return ( {newMessageCount} ); } const unboundStyles = { countBubble: { alignItems: 'center', backgroundColor: 'vibrantGreenButton', borderRadius: 25, height: 25, justifyContent: 'center', paddingBottom: Platform.OS === 'android' ? 2 : 0, paddingLeft: 1, position: 'absolute', right: -8, top: -8, width: 25, }, countText: { color: 'white', textAlign: 'center', }, button: { backgroundColor: 'floatingButtonBackground', borderColor: 'floatingButtonLabel', borderRadius: 30, borderWidth: 4, paddingHorizontal: 12, paddingVertical: 6, }, icon: { color: 'floatingButtonLabel', fontSize: 32, fontWeight: 'bold', }, }; export default NewMessagesPill; diff --git a/native/chat/robotext-message-tooltip-button.react.js b/native/chat/robotext-message-tooltip-button.react.js index bdd62f630..43e72240f 100644 --- a/native/chat/robotext-message-tooltip-button.react.js +++ b/native/chat/robotext-message-tooltip-button.react.js @@ -1,176 +1,174 @@ // @flow import * as React from 'react'; import Animated from 'react-native-reanimated'; import EmojiPicker from 'rn-emoji-keyboard'; import { localIDPrefix } from 'lib/shared/message-utils.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { TooltipInlineEngagement } from './inline-engagement.react.js'; import { InnerRobotextMessage } from './inner-robotext-message.react.js'; import { useSendReaction, useReactionSelectionPopoverPosition, } from './reaction-message-utils.js'; import ReactionSelectionPopover from './reaction-selection-popover.react.js'; import SidebarInputBarHeightMeasurer from './sidebar-input-bar-height-measurer.react.js'; import { Timestamp } from './timestamp.react.js'; import { useAnimatedMessageTooltipButton } from './utils.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useTooltipActions } from '../tooltip/tooltip-hooks.js'; import type { TooltipRoute } from '../tooltip/tooltip.react.js'; /* eslint-disable import/no-named-as-default-member */ const { Node, interpolateNode, Extrapolate } = Animated; /* eslint-enable import/no-named-as-default-member */ type Props = { +navigation: AppNavigationProp<'RobotextMessageTooltipModal'>, +route: TooltipRoute<'RobotextMessageTooltipModal'>, +progress: Node, +isOpeningSidebar: boolean, }; function RobotextMessageTooltipButton(props: Props): React.Node { const { navigation, route, progress, isOpeningSidebar } = props; const windowWidth = useSelector(state => state.dimensions.width); - const [ - sidebarInputBarHeight, - setSidebarInputBarHeight, - ] = React.useState(null); + const [sidebarInputBarHeight, setSidebarInputBarHeight] = + React.useState(null); const onInputBarMeasured = React.useCallback((height: number) => { setSidebarInputBarHeight(height); }, []); const { item, verticalBounds, initialCoordinates, margin } = route.params; const { style: messageContainerStyle } = useAnimatedMessageTooltipButton({ sourceMessage: item, initialCoordinates, messageListVerticalBounds: verticalBounds, progress, targetInputBarHeight: sidebarInputBarHeight, }); const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; const opacity = interpolateNode(progress, { inputRange: [0, 0.05], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); return { opacity, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [initialCoordinates.height, initialCoordinates.x, progress, windowWidth]); const inlineEngagement = React.useMemo(() => { if (!item.threadCreatedFromMessage) { return null; } return ( ); }, [initialCoordinates, isOpeningSidebar, item, progress, windowWidth]); const { messageInfo, threadInfo, reactions } = item; const nextLocalID = useSelector(state => state.nextLocalID); const localID = `${localIDPrefix}${nextLocalID}`; const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); const sendReaction = useSendReaction( messageInfo.id, localID, threadInfo.id, reactions, ); const reactionSelectionPopoverPosition = useReactionSelectionPopoverPosition({ initialCoordinates, verticalBounds, margin, }); const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); const openEmojiPicker = React.useCallback(() => { setEmojiPickerOpen(true); }, []); const reactionSelectionPopover = React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } return ( ); }, [ navigation, route, openEmojiPicker, canCreateReactionFromMessage, reactionSelectionPopoverPosition, sendReaction, ]); const tooltipRouteKey = route.key; const { dismissTooltip } = useTooltipActions(navigation, tooltipRouteKey); const onEmojiSelected = React.useCallback( emoji => { sendReaction(emoji.emoji); dismissTooltip(); }, [sendReaction, dismissTooltip], ); return ( <> {reactionSelectionPopover} {inlineEngagement} ); } export default RobotextMessageTooltipButton; diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index b90a4258a..3f16f3909 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,301 +1,300 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, ActivityIndicator, Alert } from 'react-native'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.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 { threadActualMembers } from 'lib/shared/thread-utils.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import Button from '../../components/button.react.js'; import Modal from '../../components/modal.react.js'; import { createTagInput } from '../../components/tag-input.react.js'; import UserList from '../../components/user-list.react.js'; import type { RootNavigationProp } from '../../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; export type AddUsersModalParams = { +presentedFrom: string, +threadInfo: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'AddUsersModal'>, +route: NavigationRoute<'AddUsersModal'>, }; function AddUsersModal(props: Props): React.Node { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const tagInputRef = React.useRef(); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setUsernameInputText(''); setUserInfoInputArray([]); invariant(tagInputRef.current, 'tagInput should be set'); tagInputRef.current.focus(); }, []); const { navigation } = props; const { goBackOnce } = navigation; const close = React.useCallback(() => { goBackOnce(); }, [goBackOnce]); const callChangeThreadSettings = useServerCall(changeThreadSettings); const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); const { route } = props; const { threadInfo } = route.params; const threadID = threadInfo.id; const addUsersToThread = React.useCallback(async () => { try { const result = await callChangeThreadSettings({ threadID: threadID, changes: { newMemberIDs: userInfoInputIDs }, }); close(); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ callChangeThreadSettings, threadID, userInfoInputIDs, close, onUnknownErrorAlertAcknowledged, ]); const inputLength = userInfoInputArray.length; const dispatchActionPromise = useDispatchActionPromise(); const userInfoInputArrayEmpty = inputLength === 0; const onPressAdd = React.useCallback(() => { if (userInfoInputArrayEmpty) { return; } dispatchActionPromise(changeThreadSettingsActionTypes, addUsersToThread()); }, [userInfoInputArrayEmpty, dispatchActionPromise, addUsersToThread]); const changeThreadSettingsLoadingStatus = useSelector( createLoadingStatusSelector(changeThreadSettingsActionTypes), ); const isLoading = changeThreadSettingsLoadingStatus === 'loading'; const styles = useStyles(unboundStyles); let addButton = null; if (inputLength > 0) { let activityIndicator = null; if (isLoading) { activityIndicator = ( ); } const addButtonText = `Add (${inputLength})`; addButton = ( ); } let cancelButton; if (!isLoading) { cancelButton = ( ); } else { cancelButton = ; } const threadMemberIDs = React.useMemo( () => threadActualMembers(threadInfo.members), [threadInfo.members], ); const excludeUserIDs = React.useMemo( () => userInfoInputIDs.concat(threadMemberIDs), [userInfoInputIDs, threadMemberIDs], ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const { parentThreadID, community } = props.route.params.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const userSearchResults = React.useMemo( () => getPotentialMemberItems( usernameInputText, otherUserInfos, userSearchIndex, excludeUserIDs, parentThreadInfo, communityThreadInfo, threadInfo.type, ), [ usernameInputText, otherUserInfos, userSearchIndex, excludeUserIDs, parentThreadInfo, communityThreadInfo, threadInfo.type, ], ); const onChangeTagInput = React.useCallback( (newUserInfoInputArray: $ReadOnlyArray) => { if (!isLoading) { setUserInfoInputArray(newUserInfoInputArray); } }, [isLoading], ); const onChangeTagInputText = React.useCallback( (text: string) => { if (!isLoading) { setUsernameInputText(text); } }, [isLoading], ); const onUserSelect = React.useCallback( (userID: string) => { if (isLoading) { return; } if (userInfoInputIDs.some(existingUserID => userID === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[userID], ]); setUsernameInputText(''); }, [isLoading, userInfoInputIDs, otherUserInfos], ); const inputProps = React.useMemo( () => ({ ...tagInputProps, onSubmitEditing: onPressAdd, }), [onPressAdd], ); const userSearchResultWithENSNames = useENSNames(userSearchResults); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); return ( {cancelButton} {addButton} ); } const unboundStyles = { activityIndicator: { paddingRight: 6, }, addButton: { backgroundColor: 'vibrantGreenButton', borderRadius: 3, flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 4, }, addText: { color: 'white', fontSize: 18, }, buttons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, }, cancelButton: { backgroundColor: 'modalButton', borderRadius: 3, paddingHorizontal: 10, paddingVertical: 4, }, cancelText: { color: 'modalButtonLabel', fontSize: 18, }, }; -const MemoizedAddUsersModal: React.ComponentType = React.memo( - AddUsersModal, -); +const MemoizedAddUsersModal: React.ComponentType = + React.memo(AddUsersModal); export default MemoizedAddUsersModal; diff --git a/native/chat/settings/color-selector-modal.react.js b/native/chat/settings/color-selector-modal.react.js index 16575609a..0299acf57 100644 --- a/native/chat/settings/color-selector-modal.react.js +++ b/native/chat/settings/color-selector-modal.react.js @@ -1,183 +1,182 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import * as React from 'react'; import { TouchableHighlight, Alert } from 'react-native'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions.js'; import { type ThreadInfo, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types.js'; import type { DispatchActionPromise } from 'lib/utils/action-utils.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import ColorSelector from '../../components/color-selector.react.js'; import Modal from '../../components/modal.react.js'; import type { RootNavigationProp } from '../../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../../themes/colors.js'; export type ColorSelectorModalParams = { +presentedFrom: string, +color: string, +threadInfo: ThreadInfo, +setColor: (color: string) => void, }; type BaseProps = { +navigation: RootNavigationProp<'ColorSelectorModal'>, +route: NavigationRoute<'ColorSelectorModal'>, }; type Props = { ...BaseProps, // Redux state +colors: Colors, +styles: typeof unboundStyles, +windowWidth: number, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, }; function ColorSelectorModal(props: Props): React.Node { const { changeThreadSettings: updateThreadSettings, dispatchActionPromise, windowWidth, } = props; const { threadInfo, setColor } = props.route.params; const close = props.navigation.goBackOnce; const onErrorAcknowledged = React.useCallback(() => { setColor(threadInfo.color); }, [setColor, threadInfo.color]); const editColor = React.useCallback( async (newColor: string) => { const threadID = threadInfo.id; try { return await updateThreadSettings({ threadID, changes: { color: newColor }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onErrorAcknowledged }], { cancelable: false }, ); throw e; } }, [onErrorAcknowledged, threadInfo.id, updateThreadSettings], ); const onColorSelected = React.useCallback( (color: string) => { const colorEditValue = color.substr(1); setColor(colorEditValue); close(); dispatchActionPromise( changeThreadSettingsActionTypes, editColor(colorEditValue), { customKeyName: `${changeThreadSettingsActionTypes.started}:color` }, ); }, [setColor, close, dispatchActionPromise, editColor], ); const { colorSelectorContainer, closeButton, closeButtonIcon } = props.styles; // Based on the assumption we are always in portrait, // and consequently width is the lowest dimensions const modalStyle = React.useMemo( () => [colorSelectorContainer, { height: 0.75 * windowWidth }], [colorSelectorContainer, windowWidth], ); const { modalIosHighlightUnderlay } = props.colors; const { color } = props.route.params; return ( ); } const unboundStyles = { closeButton: { borderRadius: 3, height: 18, position: 'absolute', right: 5, top: 5, width: 18, }, closeButtonIcon: { color: 'modalBackgroundSecondaryLabel', left: 3, position: 'absolute', }, colorSelector: { bottom: 10, left: 10, position: 'absolute', right: 10, top: 10, }, colorSelectorContainer: { backgroundColor: 'modalBackground', borderRadius: 5, flex: 0, marginHorizontal: 15, marginVertical: 20, }, }; -const ConnectedColorSelectorModal: React.ComponentType = React.memo( - function ConnectedColorSelectorModal(props: BaseProps) { +const ConnectedColorSelectorModal: React.ComponentType = + React.memo(function ConnectedColorSelectorModal(props: BaseProps) { const styles = useStyles(unboundStyles); const colors = useColors(); const windowWidth = useSelector(state => state.dimensions.width); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); - }, -); + }); export default ConnectedColorSelectorModal; diff --git a/native/chat/settings/compose-subchannel-modal.react.js b/native/chat/settings/compose-subchannel-modal.react.js index 9ca738662..07d32dfe0 100644 --- a/native/chat/settings/compose-subchannel-modal.react.js +++ b/native/chat/settings/compose-subchannel-modal.react.js @@ -1,150 +1,151 @@ // @flow import IonIcon from '@expo/vector-icons/Ionicons.js'; import * as React from 'react'; import { Text } from 'react-native'; import { threadTypeDescriptions } from 'lib/shared/thread-utils.js'; import { type ThreadInfo, threadTypes } from 'lib/types/thread-types.js'; import Button from '../../components/button.react.js'; import Modal from '../../components/modal.react.js'; import SWMansionIcon from '../../components/swmansion-icon.react.js'; import type { RootNavigationProp } from '../../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { ComposeSubchannelRouteName } from '../../navigation/route-names.js'; import { type Colors, useStyles, useColors } from '../../themes/colors.js'; export type ComposeSubchannelModalParams = { +presentedFrom: string, +threadInfo: ThreadInfo, }; type BaseProps = { +navigation: RootNavigationProp<'ComposeSubchannelModal'>, +route: NavigationRoute<'ComposeSubchannelModal'>, }; type Props = { ...BaseProps, +colors: Colors, +styles: typeof unboundStyles, }; class ComposeSubchannelModal extends React.PureComponent { render() { return ( Chat type ); } onPressOpen = () => { const threadInfo = this.props.route.params.threadInfo; this.props.navigation.navigate<'ComposeSubchannel'>({ name: ComposeSubchannelRouteName, params: { threadType: threadTypes.COMMUNITY_OPEN_SUBTHREAD, parentThreadInfo: threadInfo, }, key: `${ComposeSubchannelRouteName}|` + `${threadInfo.id}|${threadTypes.COMMUNITY_OPEN_SUBTHREAD}`, }); }; onPressSecret = () => { const threadInfo = this.props.route.params.threadInfo; this.props.navigation.navigate<'ComposeSubchannel'>({ name: ComposeSubchannelRouteName, params: { threadType: threadTypes.COMMUNITY_SECRET_SUBTHREAD, parentThreadInfo: threadInfo, }, key: `${ComposeSubchannelRouteName}|` + `${threadInfo.id}|${threadTypes.COMMUNITY_SECRET_SUBTHREAD}`, }); }; } const unboundStyles = { forwardIcon: { color: 'modalForegroundSecondaryLabel', paddingLeft: 10, }, modal: { flex: 0, }, option: { alignItems: 'center', flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 20, }, optionExplanation: { color: 'modalBackgroundLabel', flex: 1, fontSize: 14, paddingLeft: 20, textAlign: 'center', }, optionText: { color: 'modalBackgroundLabel', fontSize: 20, paddingLeft: 5, }, visibility: { color: 'modalBackgroundLabel', fontSize: 24, textAlign: 'center', }, visibilityIcon: { color: 'modalBackgroundLabel', paddingRight: 3, }, }; -const ConnectedComposeSubchannelModal: React.ComponentType = React.memo( - function ConnectedComposeSubchannelModal(props: BaseProps) { +const ConnectedComposeSubchannelModal: React.ComponentType = + React.memo(function ConnectedComposeSubchannelModal( + props: BaseProps, + ) { const styles = useStyles(unboundStyles); const colors = useColors(); return ( ); - }, -); + }); export default ConnectedComposeSubchannelModal; diff --git a/native/chat/settings/delete-thread.react.js b/native/chat/settings/delete-thread.react.js index 716c604f2..ca0889d5a 100644 --- a/native/chat/settings/delete-thread.react.js +++ b/native/chat/settings/delete-thread.react.js @@ -1,271 +1,270 @@ // @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, }); } } } } 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 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-color.react.js b/native/chat/settings/thread-settings-color.react.js index 89600bc5b..37a5ae353 100644 --- a/native/chat/settings/thread-settings-color.react.js +++ b/native/chat/settings/thread-settings-color.react.js @@ -1,123 +1,124 @@ // @flow import * as React from 'react'; import { Text, ActivityIndicator, View, Platform } from 'react-native'; import { changeThreadSettingsActionTypes } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import type { ThreadSettingsNavigate } from './thread-settings.react.js'; import ColorSplotch from '../../components/color-splotch.react.js'; import EditSettingButton from '../../components/edit-setting-button.react.js'; import { ColorSelectorModalRouteName } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; type BaseProps = { +threadInfo: ThreadInfo, +colorEditValue: string, +setColorEditValue: (color: string) => void, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, }; class ThreadSettingsColor extends React.PureComponent { render() { let colorButton; if (this.props.loadingStatus !== 'loading') { colorButton = ( ); } else { colorButton = ( ); } return ( Color {colorButton} ); } onPressEditColor = () => { this.props.navigate<'ColorSelectorModal'>({ name: ColorSelectorModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, color: this.props.colorEditValue, threadInfo: this.props.threadInfo, setColor: this.props.setColorEditValue, }, }); }; } const unboundStyles = { colorLine: { lineHeight: Platform.select({ android: 22, default: 25 }), }, colorRow: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingBottom: 8, paddingHorizontal: 24, paddingTop: 4, }, currentValue: { flex: 1, paddingLeft: 4, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); -const ConnectedThreadSettingsColor: React.ComponentType = React.memo( - function ConnectedThreadSettingsColor(props: BaseProps) { +const ConnectedThreadSettingsColor: React.ComponentType = + React.memo(function ConnectedThreadSettingsColor( + props: BaseProps, + ) { const loadingStatus = useSelector(loadingStatusSelector); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); - }, -); + }); export default ConnectedThreadSettingsColor; diff --git a/native/chat/settings/thread-settings-description.react.js b/native/chat/settings/thread-settings-description.react.js index 2879984bb..109f5a8be 100644 --- a/native/chat/settings/thread-settings-description.react.js +++ b/native/chat/settings/thread-settings-description.react.js @@ -1,311 +1,312 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, Alert, ActivityIndicator, TextInput as BaseTextInput, View, } from 'react-native'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { type ThreadInfo, threadPermissions, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import SaveSettingButton from './save-setting-button.react.js'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react.js'; import Button from '../../components/button.react.js'; import EditSettingButton from '../../components/edit-setting-button.react.js'; import SWMansionIcon from '../../components/swmansion-icon.react.js'; import TextInput from '../../components/text-input.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../../themes/colors.js'; import type { LayoutEvent, ContentSizeChangeEvent, } from '../../types/react-native.js'; type BaseProps = { +threadInfo: ThreadInfo, +descriptionEditValue: ?string, +setDescriptionEditValue: (value: ?string, callback?: () => void) => void, +descriptionTextHeight: ?number, +setDescriptionTextHeight: (number: number) => void, +canChangeSettings: boolean, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, }; class ThreadSettingsDescription extends React.PureComponent { textInput: ?React.ElementRef; render() { if ( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined ) { let button; if (this.props.loadingStatus !== 'loading') { button = ; } else { button = ( ); } const textInputStyle = {}; if ( this.props.descriptionTextHeight !== undefined && this.props.descriptionTextHeight !== null ) { textInputStyle.height = this.props.descriptionTextHeight; } return ( {button} ); } if (this.props.threadInfo.description) { return ( {this.props.threadInfo.description} ); } const canEditThreadDescription = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const { panelIosHighlightUnderlay } = this.props.colors; if (canEditThreadDescription) { return ( ); } return null; } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; onLayoutText = (event: LayoutEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.layout.height); }; onTextInputContentSizeChange = (event: ContentSizeChangeEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.contentSize.height); }; onPressEdit = () => { this.props.setDescriptionEditValue(this.props.threadInfo.description); }; onSubmit = () => { invariant( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined, 'should be set', ); const description = this.props.descriptionEditValue.trim(); if (description === this.props.threadInfo.description) { this.props.setDescriptionEditValue(null); return; } const editDescriptionPromise = this.editDescription(description); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editDescriptionPromise, { customKeyName: `${changeThreadSettingsActionTypes.started}:description`, }, ); editDescriptionPromise.then(() => { this.props.setDescriptionEditValue(null); }); }; async editDescription(newDescription: string) { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { description: newDescription }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setDescriptionEditValue( this.props.threadInfo.description, () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }, ); }; } const unboundStyles = { addDescriptionButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, }, addDescriptionText: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 16, }, editIcon: { color: 'panelForegroundTertiaryLabel', paddingLeft: 10, textAlign: 'right', }, outlineCategory: { backgroundColor: 'panelForeground', borderColor: 'panelForegroundBorder', borderRadius: 1, borderStyle: 'dashed', borderWidth: 1, marginLeft: -1, marginRight: -1, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 4, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, padding: 0, borderBottomColor: 'transparent', }, }; const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); -const ConnectedThreadSettingsDescription: React.ComponentType = React.memo( - function ConnectedThreadSettingsDescription(props: BaseProps) { +const ConnectedThreadSettingsDescription: React.ComponentType = + React.memo(function ConnectedThreadSettingsDescription( + props: BaseProps, + ) { const loadingStatus = useSelector(loadingStatusSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); - }, -); + }); export default ConnectedThreadSettingsDescription; diff --git a/native/chat/settings/thread-settings-edit-relationship.react.js b/native/chat/settings/thread-settings-edit-relationship.react.js index 07e2cb67a..ca3e70a5c 100644 --- a/native/chat/settings/thread-settings-edit-relationship.react.js +++ b/native/chat/settings/thread-settings-edit-relationship.react.js @@ -1,129 +1,128 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Alert, Text, View } from 'react-native'; import { updateRelationships as serverUpdateRelationships, updateRelationshipsActionTypes, } from 'lib/actions/relationship-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { getRelationshipActionText, getRelationshipDispatchAction, } from 'lib/shared/relationship-utils.js'; import { getSingleOtherUser } from 'lib/shared/thread-utils.js'; import { type RelationshipAction, type RelationshipButton, } from 'lib/types/relationship-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import Button from '../../components/button.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles, useColors } from '../../themes/colors.js'; import type { ViewStyle } from '../../types/styles.js'; type Props = { +threadInfo: ThreadInfo, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, }; -const ThreadSettingsEditRelationship: React.ComponentType = React.memo( - function ThreadSettingsEditRelationship(props: Props) { +const ThreadSettingsEditRelationship: React.ComponentType = + React.memo(function ThreadSettingsEditRelationship(props: Props) { const otherUserInfoFromRedux = useSelector(state => { const currentUserID = state.currentUserInfo?.id; const otherUserID = getSingleOtherUser(props.threadInfo, currentUserID); invariant(otherUserID, 'Other user should be specified'); const { userInfos } = state.userStore; return userInfos[otherUserID]; }); invariant(otherUserInfoFromRedux, 'Other user info should be specified'); const [otherUserInfo] = useENSNames([otherUserInfoFromRedux]); const callUpdateRelationships = useServerCall(serverUpdateRelationships); const updateRelationship = React.useCallback( async (action: RelationshipAction) => { try { return await callUpdateRelationships({ action, userIDs: [otherUserInfo.id], }); } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: true, }); throw e; } }, [callUpdateRelationships, otherUserInfo], ); const { relationshipButton } = props; const relationshipAction = React.useMemo( () => getRelationshipDispatchAction(relationshipButton), [relationshipButton], ); const dispatchActionPromise = useDispatchActionPromise(); const onButtonPress = React.useCallback(() => { dispatchActionPromise( updateRelationshipsActionTypes, updateRelationship(relationshipAction), ); }, [dispatchActionPromise, relationshipAction, updateRelationship]); const colors = useColors(); const { panelIosHighlightUnderlay } = colors; const styles = useStyles(unboundStyles); const otherUserInfoUsername = otherUserInfo.username; invariant(otherUserInfoUsername, 'Other user username should be specified'); const relationshipButtonText = React.useMemo( () => getRelationshipActionText(relationshipButton, otherUserInfoUsername), [otherUserInfoUsername, relationshipButton], ); return ( ); - }, -); + }); const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'redText', flex: 1, fontSize: 16, }, }; export default ThreadSettingsEditRelationship; diff --git a/native/chat/settings/thread-settings-home-notifs.react.js b/native/chat/settings/thread-settings-home-notifs.react.js index b10ec0b2c..3cc19c55a 100644 --- a/native/chat/settings/thread-settings-home-notifs.react.js +++ b/native/chat/settings/thread-settings-home-notifs.react.js @@ -1,118 +1,119 @@ // @flow import * as React from 'react'; import { View, Switch } from 'react-native'; import { updateSubscriptionActionTypes, updateSubscription, } from 'lib/actions/user-actions.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import type { DispatchActionPromise } from 'lib/utils/action-utils.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { SingleLine } from '../../components/single-line.react.js'; import { useStyles } from '../../themes/colors.js'; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, }; type State = { +currentValue: boolean, }; class ThreadSettingsHomeNotifs extends React.PureComponent { constructor(props: Props) { super(props); this.state = { currentValue: !props.threadInfo.currentUser.subscription.home, }; } render() { const componentLabel = 'Background'; return ( {componentLabel} ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { home: !value, }, }), ); }; } const unboundStyles = { currentValue: { alignItems: 'flex-end', margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, flex: 1, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, }; -const ConnectedThreadSettingsHomeNotifs: React.ComponentType = React.memo( - function ConnectedThreadSettingsHomeNotifs(props: BaseProps) { +const ConnectedThreadSettingsHomeNotifs: React.ComponentType = + React.memo(function ConnectedThreadSettingsHomeNotifs( + props: BaseProps, + ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useServerCall(updateSubscription); return ( ); - }, -); + }); export default ConnectedThreadSettingsHomeNotifs; diff --git a/native/chat/settings/thread-settings-leave-thread.react.js b/native/chat/settings/thread-settings-leave-thread.react.js index 67b8e87cd..395bb5386 100644 --- a/native/chat/settings/thread-settings-leave-thread.react.js +++ b/native/chat/settings/thread-settings-leave-thread.react.js @@ -1,178 +1,177 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, Alert, ActivityIndicator, View } from 'react-native'; import { leaveThreadActionTypes, leaveThread, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { otherUsersButNoOtherAdmins } 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, LeaveThreadPayload } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import Button from '../../components/button.react.js'; import { clearThreadsActionType } from '../../navigation/action-types.js'; import { NavContext, type NavContextType, } from '../../navigation/navigation-context.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { ViewStyle } from '../../types/styles.js'; type BaseProps = { +threadInfo: ThreadInfo, +buttonStyle: ViewStyle, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +otherUsersButNoOtherAdmins: boolean, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +leaveThread: (threadID: string) => Promise, // withNavContext +navContext: ?NavContextType, }; class ThreadSettingsLeaveThread extends React.PureComponent { render() { - const { - panelIosHighlightUnderlay, - panelForegroundSecondaryLabel, - } = this.props.colors; + const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel } = + this.props.colors; const loadingIndicator = this.props.loadingStatus === 'loading' ? ( ) : null; return ( ); } onPress = () => { if (this.props.otherUsersButNoOtherAdmins) { Alert.alert( 'Need another admin', 'Make somebody else an admin before you leave!', undefined, { cancelable: true }, ); return; } Alert.alert( 'Confirm action', 'Are you sure you want to leave this chat?', [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: this.onConfirmLeaveThread }, ], { cancelable: true }, ); }; onConfirmLeaveThread = () => { this.props.dispatchActionPromise( leaveThreadActionTypes, this.leaveThread(), ); }; async leaveThread() { const threadID = this.props.threadInfo.id; const { navContext } = this.props; invariant(navContext, 'navContext should exist in leaveThread'); navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadID] }, }); try { const result = await this.props.leaveThread(threadID); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', undefined, { cancelable: true, }); throw e; } } } const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'redText', flex: 1, fontSize: 16, }, }; const loadingStatusSelector = createLoadingStatusSelector( leaveThreadActionTypes, ); -const ConnectedThreadSettingsLeaveThread: React.ComponentType = React.memo( - function ConnectedThreadSettingsLeaveThread(props: BaseProps) { +const ConnectedThreadSettingsLeaveThread: React.ComponentType = + React.memo(function ConnectedThreadSettingsLeaveThread( + props: BaseProps, + ) { const loadingStatus = useSelector(loadingStatusSelector); const otherUsersButNoOtherAdminsValue = useSelector( otherUsersButNoOtherAdmins(props.threadInfo.id), ); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callLeaveThread = useServerCall(leaveThread); const navContext = React.useContext(NavContext); return ( ); - }, -); + }); export default ConnectedThreadSettingsLeaveThread; diff --git a/native/chat/settings/thread-settings-member.react.js b/native/chat/settings/thread-settings-member.react.js index 54e846c0f..12a08c6a6 100644 --- a/native/chat/settings/thread-settings-member.react.js +++ b/native/chat/settings/thread-settings-member.react.js @@ -1,289 +1,290 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, Platform, ActivityIndicator, TouchableOpacity, } from 'react-native'; import { removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { memberIsAdmin, memberHasAdminPowers, getAvailableThreadMemberActions, } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { type ThreadInfo, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import type { ThreadSettingsNavigate } from './thread-settings.react.js'; import PencilIcon from '../../components/pencil-icon.react.js'; import { SingleLine } from '../../components/single-line.react.js'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state.js'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context.js'; import { ThreadSettingsMemberTooltipModalRouteName } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { VerticalBounds } from '../../types/layout-types.js'; type BaseProps = { +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, }; type Props = { ...BaseProps, // Redux state +removeUserLoadingStatus: LoadingStatus, +changeRoleLoadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, }; class ThreadSettingsMember extends React.PureComponent { editButton: ?React.ElementRef; render() { const userText = stringForUser(this.props.memberInfo); let userInfo = null; if (this.props.memberInfo.username) { userInfo = ( {userText} ); } else { userInfo = ( {userText} ); } let editButton = null; if ( this.props.removeUserLoadingStatus === 'loading' || this.props.changeRoleLoadingStatus === 'loading' ) { editButton = ( ); } else if ( getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ).length !== 0 ) { editButton = ( ); } let roleInfo = null; if (memberIsAdmin(this.props.memberInfo, this.props.threadInfo)) { roleInfo = ( admin ); } else if (memberHasAdminPowers(this.props.memberInfo)) { roleInfo = ( parent admin ); } const firstItem = this.props.firstListItem ? null : this.props.styles.topBorder; const lastItem = this.props.lastListItem ? this.props.styles.lastInnerContainer : null; return ( {userInfo} {editButton} {roleInfo} ); } editButtonRef = (editButton: ?React.ElementRef) => { this.editButton = editButton; }; onEditButtonLayout = () => {}; onPressEdit = () => { if (this.dismissKeyboardIfShowing()) { return; } const { editButton, props: { verticalBounds }, } = this; if (!editButton || !verticalBounds) { return; } const { overlayContext } = this.props; invariant( overlayContext, 'ThreadSettingsMember should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); editButton.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigate<'ThreadSettingsMemberTooltipModal'>({ name: ThreadSettingsMemberTooltipModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ), memberInfo: this.props.memberInfo, threadInfo: this.props.threadInfo, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const unboundStyles = { anonymous: { color: 'panelForegroundTertiaryLabel', fontStyle: 'italic', }, container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 12, }, editButton: { paddingLeft: 10, }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, innerContainer: { flex: 1, paddingHorizontal: 12, paddingVertical: 8, }, lastInnerContainer: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, }, role: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 14, paddingTop: 4, }, row: { flex: 1, flexDirection: 'row', }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, }, }; -const ConnectedThreadSettingsMember: React.ComponentType = React.memo( - function ConnectedThreadSettingsMember(props: BaseProps) { +const ConnectedThreadSettingsMember: React.ComponentType = + React.memo(function ConnectedThreadSettingsMember( + props: BaseProps, + ) { const memberID = props.memberInfo.id; const removeUserLoadingStatus = useSelector(state => createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${memberID}`, )(state), ); const changeRoleLoadingStatus = useSelector(state => createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${memberID}`, )(state), ); const [memberInfo] = useENSNames([props.memberInfo]); const colors = useColors(); const styles = useStyles(unboundStyles); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); return ( ); - }, -); + }); export default ConnectedThreadSettingsMember; diff --git a/native/chat/settings/thread-settings-name.react.js b/native/chat/settings/thread-settings-name.react.js index e8d7dc466..36c133e8b 100644 --- a/native/chat/settings/thread-settings-name.react.js +++ b/native/chat/settings/thread-settings-name.react.js @@ -1,234 +1,233 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, Alert, ActivityIndicator, TextInput as BaseTextInput, View, } from 'react-native'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { chatNameMaxLength } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { type ResolvedThreadInfo, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { firstLine } from 'lib/utils/string-utils.js'; import SaveSettingButton from './save-setting-button.react.js'; import EditSettingButton from '../../components/edit-setting-button.react.js'; import { SingleLine } from '../../components/single-line.react.js'; import TextInput from '../../components/text-input.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../../themes/colors.js'; type BaseProps = { +threadInfo: ResolvedThreadInfo, +nameEditValue: ?string, +setNameEditValue: (value: ?string, callback?: () => void) => void, +canChangeSettings: boolean, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, }; class ThreadSettingsName extends React.PureComponent { textInput: ?React.ElementRef; render() { return ( Name {this.renderContent()} ); } renderContent() { if ( this.props.nameEditValue === null || this.props.nameEditValue === undefined ) { return ( {this.props.threadInfo.uiName} ); } let button; if (this.props.loadingStatus !== 'loading') { button = ; } else { button = ( ); } return ( {button} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; threadEditName() { return firstLine( this.props.threadInfo.name ? this.props.threadInfo.name : '', ); } onPressEdit = () => { this.props.setNameEditValue(this.threadEditName()); }; onSubmit = () => { invariant( this.props.nameEditValue !== null && this.props.nameEditValue !== undefined, 'should be set', ); const name = firstLine(this.props.nameEditValue); if (name === this.threadEditName()) { this.props.setNameEditValue(null); return; } const editNamePromise = this.editName(name); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editNamePromise, { customKeyName: `${changeThreadSettingsActionTypes.started}:name` }, ); editNamePromise.then(() => { this.props.setNameEditValue(null); }); }; async editName(newName: string) { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { name: newName }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setNameEditValue(this.threadEditName(), () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }); }; } const unboundStyles = { currentValue: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, borderBottomColor: 'transparent', }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 8, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); -const ConnectedThreadSettingsName: React.ComponentType = React.memo( - function ConnectedThreadSettingsName(props: BaseProps) { +const ConnectedThreadSettingsName: React.ComponentType = + React.memo(function ConnectedThreadSettingsName(props: BaseProps) { const styles = useStyles(unboundStyles); const colors = useColors(); const loadingStatus = useSelector(loadingStatusSelector); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); - }, -); + }); export default ConnectedThreadSettingsName; diff --git a/native/chat/settings/thread-settings-parent.react.js b/native/chat/settings/thread-settings-parent.react.js index fce3ba3b8..d90835daa 100644 --- a/native/chat/settings/thread-settings-parent.react.js +++ b/native/chat/settings/thread-settings-parent.react.js @@ -1,98 +1,97 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import Button from '../../components/button.react.js'; import ThreadPill from '../../components/thread-pill.react.js'; import { useStyles } from '../../themes/colors.js'; import { useNavigateToThread } from '../message-list-types.js'; type Props = { +threadInfo: ThreadInfo, +parentThreadInfo: ?ThreadInfo, }; function ThreadSettingsParent(props: Props): React.Node { const { threadInfo, parentThreadInfo } = props; const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const onPressParentThread = React.useCallback(() => { invariant(parentThreadInfo, 'should be set'); navigateToThread({ threadInfo: parentThreadInfo }); }, [parentThreadInfo, navigateToThread]); let parent; if (parentThreadInfo) { parent = ( ); } else if (threadInfo.parentThreadID) { parent = ( Secret parent ); } else { parent = ( No parent ); } return ( Parent {parent} ); } const unboundStyles = { currentValue: { flex: 1, }, currentValueText: { color: 'panelForegroundSecondaryLabel', fontFamily: 'Arial', fontSize: 16, margin: 0, paddingRight: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, noParent: { fontStyle: 'italic', paddingLeft: 2, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 4, alignItems: 'center', }, }; -const ConnectedThreadSettingsParent: React.ComponentType = React.memo( - ThreadSettingsParent, -); +const ConnectedThreadSettingsParent: React.ComponentType = + React.memo(ThreadSettingsParent); export default ConnectedThreadSettingsParent; diff --git a/native/chat/settings/thread-settings-promote-sidebar.react.js b/native/chat/settings/thread-settings-promote-sidebar.react.js index 20cd09cd4..f63f45036 100644 --- a/native/chat/settings/thread-settings-promote-sidebar.react.js +++ b/native/chat/settings/thread-settings-promote-sidebar.react.js @@ -1,114 +1,113 @@ // @flow import * as React from 'react'; import { Text, ActivityIndicator, View, Alert } from 'react-native'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import Button from '../../components/button.react.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { ViewStyle } from '../../types/styles.js'; type BaseProps = { +threadInfo: ThreadInfo, +buttonStyle: ViewStyle, }; type Props = { ...BaseProps, +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, +promoteSidebar: () => mixed, }; class ThreadSettingsPromoteSidebar extends React.PureComponent { onClick = () => { Alert.alert( 'Are you sure?', 'Promoting a thread to a channel cannot be undone.', [ { text: 'Cancel', style: 'cancel', }, { text: 'Yes', onPress: this.props.promoteSidebar, }, ], ); }; render() { - const { - panelIosHighlightUnderlay, - panelForegroundSecondaryLabel, - } = this.props.colors; + const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel } = + this.props.colors; const loadingIndicator = this.props.loadingStatus === 'loading' ? ( ) : null; return ( ); } } const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, }, }; const onError = () => { Alert.alert('Unknown error', 'Uhh... try again?', undefined, { cancelable: true, }); }; -const ConnectedThreadSettingsPromoteSidebar: React.ComponentType = React.memo( - function ConnectedThreadSettingsPromoteSidebar(props: BaseProps) { +const ConnectedThreadSettingsPromoteSidebar: React.ComponentType = + React.memo(function ConnectedThreadSettingsPromoteSidebar( + props: BaseProps, + ) { const { threadInfo } = props; const colors = useColors(); const styles = useStyles(unboundStyles); const { onPromoteSidebar, loading } = usePromoteSidebar( threadInfo, onError, ); return ( ); - }, -); + }); export default ConnectedThreadSettingsPromoteSidebar; diff --git a/native/chat/settings/thread-settings-push-notifs.react.js b/native/chat/settings/thread-settings-push-notifs.react.js index b0017dceb..40860be54 100644 --- a/native/chat/settings/thread-settings-push-notifs.react.js +++ b/native/chat/settings/thread-settings-push-notifs.react.js @@ -1,178 +1,179 @@ // @flow import * as React from 'react'; import { View, Switch, TouchableOpacity, Platform } from 'react-native'; import Alert from 'react-native/Libraries/Alert/Alert.js'; import Linking from 'react-native/Libraries/Linking/Linking.js'; import { updateSubscriptionActionTypes, updateSubscription, } from 'lib/actions/user-actions.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import type { DispatchActionPromise } from 'lib/utils/action-utils.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { SingleLine } from '../../components/single-line.react.js'; import SWMansionIcon from '../../components/swmansion-icon.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +hasPushPermissions: boolean, +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, }; type State = { +currentValue: boolean, }; class ThreadSettingsPushNotifs extends React.PureComponent { constructor(props: Props) { super(props); this.state = { currentValue: props.threadInfo.currentUser.subscription.pushNotifs, }; } render() { const componentLabel = 'Push notifs'; let notificationsSettingsLinkingIcon = undefined; if (!this.props.hasPushPermissions) { notificationsSettingsLinkingIcon = ( ); } return ( {componentLabel} {notificationsSettingsLinkingIcon} ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { pushNotifs: value, }, }), ); }; onNotificationsSettingsLinkingIconPress = () => { const alertTitle = Platform.OS === 'ios' ? 'Need notif permissions' : 'Unable to initialize notifs'; let alertMessage; if (Platform.OS === 'ios' && this.state.currentValue) { alertMessage = 'Notifs for this chat are enabled, but cannot be delivered ' + 'to this device because you haven’t granted notif permissions to Comm. ' + 'Please enable them in Settings App → Notifications → Comm'; } else if (Platform.OS === 'ios') { alertMessage = 'In order to enable push notifs for this chat, ' + 'you need to first grant notif permissions to Comm. ' + 'Please enable them in Settings App → Notifications → Comm'; } else { alertMessage = 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.'; } Alert.alert(alertTitle, alertMessage, [ { text: 'Go to settings', onPress: () => Linking.openSettings(), }, { text: 'Cancel', style: 'cancel', }, ]); }; } const unboundStyles = { currentValue: { alignItems: 'flex-end', margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, flex: 1, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, infoIcon: { paddingRight: 20, }, }; -const ConnectedThreadSettingsPushNotifs: React.ComponentType = React.memo( - function ConnectedThreadSettingsPushNotifs(props: BaseProps) { +const ConnectedThreadSettingsPushNotifs: React.ComponentType = + React.memo(function ConnectedThreadSettingsPushNotifs( + props: BaseProps, + ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useServerCall(updateSubscription); const hasPushPermissions = useSelector( state => state.deviceToken !== null && state.deviceToken !== undefined, ); return ( ); - }, -); + }); export default ConnectedThreadSettingsPushNotifs; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index 35a1698f5..2931fb24b 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1138 +1,1135 @@ // @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 type { CategoryType } from './thread-settings-category.react.js'; import { ThreadSettingsCategoryHeader, 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 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, } 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 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: 'footer', +key: string, +categoryType: CategoryType, } | { +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: '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, }; 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 canEditThreadName = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const canEditThreadDescription = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const canEditThreadColor = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_COLOR, ); const canChangeName = canEditThreadName && canStartEditing; const canChangeDescription = canEditThreadDescription && canStartEditing; const canChangeColor = canEditThreadColor && canStartEditing; const listData: ChatSettingsItem[] = []; 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; }, ); 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, - ); + 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.actionsListDataSelector, ( threadBasicsListData: ChatSettingsItem[], subchannelsListData: ChatSettingsItem[], sidebarsListData: ChatSettingsItem[], threadMembersListData: ChatSettingsItem[], actionsListData: ChatSettingsItem[], ) => [ ...threadBasicsListData, ...subchannelsListData, ...sidebarsListData, ...threadMembersListData, ...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 } }); }); }; renderItem = (row: { item: ChatSettingsItem, ... }) => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'footer') { 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 === '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, })); }; } const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; const editNameLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); const editColorLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); const editDescriptionLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); const leaveThreadLoadingStatusSelector = createLoadingStatusSelector( leaveThreadActionTypes, ); const somethingIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { if ( editNameLoadingStatusSelector(state) === 'loading' || editColorLoadingStatusSelector(state) === 'loading' || editDescriptionLoadingStatusSelector(state) === 'loading' || leaveThreadLoadingStatusSelector(state) === 'loading' ) { return true; } 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 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 resolvedParentThreadInfo = + useResolvedOptionalThreadInfo(parentThreadInfo); const threadMembers = threadInfo.members; const boundChildThreadInfos = useSelector( state => childThreadInfos(state)[threadID], ); const resolvedChildThreadInfos = useResolvedOptionalThreadInfos( boundChildThreadInfos, ); const boundSomethingIsSaving = useSelector(state => somethingIsSaving(state, threadMembers), ); const { navigation } = props; React.useEffect(() => { const tabNavigation: ?TabNavigationProp<'Chat'> = navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); const onTabPress = () => { if (navigation.isFocused() && !boundSomethingIsSaving) { navigation.popToTop(); } }; tabNavigation.addListener('tabPress', onTabPress); return () => tabNavigation.removeListener('tabPress', onTabPress); }, [navigation, boundSomethingIsSaving]); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const { canPromoteSidebar } = usePromoteSidebar(threadInfo); return ( ); - }, -); + }); export default ConnectedThreadSettings; diff --git a/native/chat/sidebar-list-modal.react.js b/native/chat/sidebar-list-modal.react.js index 2f6f2601a..337fc2993 100644 --- a/native/chat/sidebar-list-modal.react.js +++ b/native/chat/sidebar-list-modal.react.js @@ -1,146 +1,141 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; import { useSearchSidebars } from 'lib/hooks/search-threads.js'; import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types.js'; import { SidebarItem } from './sidebar-item.react.js'; import ThreadListModal from './thread-list-modal.react.js'; import ExtendedArrow from '../components/arrow-extended.react.js'; import Arrow from '../components/arrow.react.js'; import Button from '../components/button.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useColors, useStyles } from '../themes/colors.js'; export type SidebarListModalParams = { +threadInfo: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'SidebarListModal'>, +route: NavigationRoute<'SidebarListModal'>, }; function SidebarListModal(props: Props): React.Node { - const { - listData, - searchState, - setSearchState, - onChangeSearchInputText, - } = useSearchSidebars(props.route.params.threadInfo); + const { listData, searchState, setSearchState, onChangeSearchInputText } = + useSearchSidebars(props.route.params.threadInfo); const numOfSidebarsWithExtendedArrow = listData.length - 1; const createRenderItem = React.useCallback( - ( - onPressItem: (threadInfo: ThreadInfo) => void, + (onPressItem: (threadInfo: ThreadInfo) => void) => // eslint-disable-next-line react/display-name - ) => (row: { +item: SidebarInfo, +index: number, ... }) => { - let extendArrow: boolean = false; - if (row.index < numOfSidebarsWithExtendedArrow) { - extendArrow = true; - } - return ( - - ); - }, + (row: { +item: SidebarInfo, +index: number, ... }) => { + let extendArrow: boolean = false; + if (row.index < numOfSidebarsWithExtendedArrow) { + extendArrow = true; + } + return ( + + ); + }, [numOfSidebarsWithExtendedArrow], ); return ( ); } function Item(props: { item: SidebarInfo, onPressItem: (threadInfo: ThreadInfo) => void, extendArrow: boolean, }): React.Node { const { item, onPressItem, extendArrow } = props; const { threadInfo } = item; - const onPressButton = React.useCallback(() => onPressItem(threadInfo), [ - onPressItem, - threadInfo, - ]); + const onPressButton = React.useCallback( + () => onPressItem(threadInfo), + [onPressItem, threadInfo], + ); const colors = useColors(); const styles = useStyles(unboundStyles); let arrow; if (extendArrow) { arrow = ( ); } else { arrow = ( ); } return ( ); } const unboundStyles = { arrow: { position: 'absolute', top: -12, }, extendedArrow: { position: 'absolute', top: -6, }, sidebar: { paddingLeft: 0, paddingRight: 5, height: 38, }, sidebarItemContainer: { flex: 1, }, sidebarRowContainer: { flex: 1, flexDirection: 'row', }, spacer: { width: 30, }, }; export default SidebarListModal; diff --git a/native/chat/subchannel-item.react.js b/native/chat/subchannel-item.react.js index 38e2d0540..eed20aaa7 100644 --- a/native/chat/subchannel-item.react.js +++ b/native/chat/subchannel-item.react.js @@ -1,112 +1,109 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import MessagePreview from './message-preview.react.js'; import { SingleLine } from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { +subchannelInfo: ChatThreadItem, }; function SubchannelItem(props: Props): React.Node { - const { - lastUpdatedTime, - threadInfo, - mostRecentMessageInfo, - } = props.subchannelInfo; + const { lastUpdatedTime, threadInfo, mostRecentMessageInfo } = + props.subchannelInfo; const { uiName } = useResolvedThreadInfo(threadInfo); const lastActivity = shortAbsoluteDate(lastUpdatedTime); const styles = useStyles(unboundStyles); const unreadStyle = threadInfo.currentUser.unread ? styles.unread : null; const lastMessage = React.useMemo(() => { if (!mostRecentMessageInfo) { return ( No messages ); } return ( ); }, [mostRecentMessageInfo, threadInfo, styles]); return ( {uiName} {lastMessage} {lastActivity} ); } const fontSize = 14; const unboundStyles = { outerView: { flex: 1, flexDirection: 'column', justifyContent: 'center', paddingVertical: 8, paddingHorizontal: 8, height: 60, }, itemRowContainer: { flexDirection: 'row', alignItems: 'center', }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, name: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingBottom: 8, }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize, marginLeft: 10, }, iconWrapper: { marginRight: 8, alignItems: 'center', }, icon: { fontSize: 22, color: 'listForegroundSecondaryLabel', alignItems: 'center', height: 24, }, noMessages: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize, fontStyle: 'italic', }, }; export default SubchannelItem; diff --git a/native/chat/subchannels-list-modal.react.js b/native/chat/subchannels-list-modal.react.js index 1b98b7ded..4626fb8b9 100644 --- a/native/chat/subchannels-list-modal.react.js +++ b/native/chat/subchannels-list-modal.react.js @@ -1,100 +1,96 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; import { useSearchSubchannels } from 'lib/hooks/search-threads.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import SubchannelItem from './subchannel-item.react.js'; import ThreadListModal from './thread-list-modal.react.js'; import Button from '../components/button.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useColors, useStyles } from '../themes/colors.js'; export type SubchannelListModalParams = { +threadInfo: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'SubchannelsListModal'>, +route: NavigationRoute<'SubchannelsListModal'>, }; function SubchannelListModal(props: Props): React.Node { - const { - listData, - searchState, - setSearchState, - onChangeSearchInputText, - } = useSearchSubchannels(props.route.params.threadInfo); + const { listData, searchState, setSearchState, onChangeSearchInputText } = + useSearchSubchannels(props.route.params.threadInfo); return ( ); } -const createRenderItem = ( - onPressItem: (threadInfo: ThreadInfo) => void, +const createRenderItem = + (onPressItem: (threadInfo: ThreadInfo) => void) => // eslint-disable-next-line react/display-name -) => (row: { +item: ChatThreadItem, +index: number, ... }) => { - return ; -}; + (row: { +item: ChatThreadItem, +index: number, ... }) => { + return ; + }; function Item(props: { onPressItem: (threadInfo: ThreadInfo) => void, subchannelInfo: ChatThreadItem, }): React.Node { const { onPressItem, subchannelInfo } = props; const { threadInfo } = subchannelInfo; - const onPressButton = React.useCallback(() => onPressItem(threadInfo), [ - onPressItem, - threadInfo, - ]); + const onPressButton = React.useCallback( + () => onPressItem(threadInfo), + [onPressItem, threadInfo], + ); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); } const unboundStyles = { subchannel: { paddingLeft: 0, paddingRight: 5, }, subchannelItemContainer: { flex: 1, }, subchannelRowContainer: { flex: 1, flexDirection: 'row', }, }; export default SubchannelListModal; diff --git a/native/chat/swipeable-message.react.js b/native/chat/swipeable-message.react.js index 006b32fae..36b98a7e5 100644 --- a/native/chat/swipeable-message.react.js +++ b/native/chat/swipeable-message.react.js @@ -1,362 +1,358 @@ // @flow import type { IconProps } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; import * as React from 'react'; import { View } from 'react-native'; import { PanGestureHandler } from 'react-native-gesture-handler'; import Animated, { useAnimatedGestureHandler, useSharedValue, useAnimatedStyle, runOnJS, withSpring, interpolate, cancelAnimation, Extrapolate, type SharedValue, } from 'react-native-reanimated'; import tinycolor from 'tinycolor2'; import { useMessageListScreenWidth } from './composed-message-width.js'; import CommIcon from '../components/comm-icon.react.js'; import { colors } from '../themes/colors.js'; import type { ViewStyle } from '../types/styles.js'; const primaryThreshold = 40; const secondaryThreshold = 120; function dividePastDistance(value, distance, factor) { 'worklet'; const absValue = Math.abs(value); if (absValue < distance) { return value; } const absFactor = value >= 0 ? 1 : -1; return absFactor * (distance + (absValue - distance) / factor); } function makeSpringConfig(velocity) { 'worklet'; return { stiffness: 257.1370588235294, damping: 19.003038357561845, mass: 1, overshootClamping: true, restDisplacementThreshold: 0.001, restSpeedThreshold: 0.001, velocity, }; } function interpolateOpacityForViewerPrimarySnake(translateX) { 'worklet'; return interpolate(translateX, [-20, -5], [1, 0], Extrapolate.CLAMP); } function interpolateOpacityForNonViewerPrimarySnake(translateX) { 'worklet'; return interpolate(translateX, [5, 20], [0, 1], Extrapolate.CLAMP); } function interpolateTranslateXForViewerSecondarySnake(translateX) { 'worklet'; return interpolate(translateX, [-130, -120, -60, 0], [-130, -120, -5, 20]); } function interpolateTranslateXForNonViewerSecondarySnake(translateX) { 'worklet'; return interpolate(translateX, [0, 80, 120, 130], [0, 30, 120, 130]); } type SwipeSnakeProps = { +isViewer: boolean, +translateX: SharedValue, +color: string, +children: React.Element>>, +opacityInterpolator?: number => number, // must be worklet +translateXInterpolator?: number => number, // must be worklet }; function SwipeSnake( props: SwipeSnakeProps, ): React.Node { - const { - translateX, - isViewer, - opacityInterpolator, - translateXInterpolator, - } = props; + const { translateX, isViewer, opacityInterpolator, translateXInterpolator } = + props; const transformStyle = useAnimatedStyle(() => { const opacity = opacityInterpolator ? opacityInterpolator(translateX.value) : undefined; const translate = translateXInterpolator ? translateXInterpolator(translateX.value) : translateX.value; return { transform: [ { translateX: translate, }, ], opacity, }; }, [isViewer, translateXInterpolator, opacityInterpolator]); const animationPosition = isViewer ? styles.right0 : styles.left0; const animationContainerStyle = React.useMemo(() => { return [styles.animationContainer, animationPosition]; }, [animationPosition]); const iconPosition = isViewer ? styles.left0 : styles.right0; const swipeSnakeContainerStyle = React.useMemo(() => { return [styles.swipeSnakeContainer, transformStyle, iconPosition]; }, [transformStyle, iconPosition]); const iconAlign = isViewer ? styles.alignStart : styles.alignEnd; const screenWidth = useMessageListScreenWidth(); const { color } = props; const swipeSnakeStyle = React.useMemo(() => { return [ styles.swipeSnake, iconAlign, { width: screenWidth, backgroundColor: color, }, ]; }, [iconAlign, screenWidth, color]); const { children } = props; const iconColor = tinycolor(color).isDark() ? colors.dark.listForegroundLabel : colors.light.listForegroundLabel; const coloredIcon = React.useMemo( () => React.cloneElement(children, { color: iconColor, }), [children, iconColor], ); return ( {coloredIcon} ); } type Props = { +triggerReply?: () => mixed, +triggerSidebar?: () => mixed, +isViewer: boolean, +messageBoxStyle: ViewStyle, +threadColor: string, +children: React.Node, }; function SwipeableMessage(props: Props): React.Node { const { isViewer, triggerReply, triggerSidebar } = props; const secondaryActionExists = triggerReply && triggerSidebar; const onPassPrimaryThreshold = React.useCallback(() => { const impactStrength = secondaryActionExists ? Haptics.ImpactFeedbackStyle.Medium : Haptics.ImpactFeedbackStyle.Heavy; Haptics.impactAsync(impactStrength); }, [secondaryActionExists]); const onPassSecondaryThreshold = React.useCallback(() => { if (secondaryActionExists) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); } }, [secondaryActionExists]); const primaryAction = React.useCallback(() => { if (triggerReply) { triggerReply(); } else if (triggerSidebar) { triggerSidebar(); } }, [triggerReply, triggerSidebar]); const secondaryAction = React.useCallback(() => { if (triggerReply && triggerSidebar) { triggerSidebar(); } }, [triggerReply, triggerSidebar]); const translateX = useSharedValue(0); const swipeEvent = useAnimatedGestureHandler( { onStart: (event, ctx) => { ctx.translationAtStart = translateX.value; cancelAnimation(translateX.value); }, onActive: (event, ctx) => { const translationX = ctx.translationAtStart + event.translationX; const baseActiveTranslation = isViewer ? Math.min(translationX, 0) : Math.max(translationX, 0); translateX.value = dividePastDistance( baseActiveTranslation, primaryThreshold, 2, ); const absValue = Math.abs(translateX.value); const pastPrimaryThreshold = absValue >= primaryThreshold; if (pastPrimaryThreshold && !ctx.prevPastPrimaryThreshold) { runOnJS(onPassPrimaryThreshold)(); } ctx.prevPastPrimaryThreshold = pastPrimaryThreshold; const pastSecondaryThreshold = absValue >= secondaryThreshold; if (pastSecondaryThreshold && !ctx.prevPastSecondaryThreshold) { runOnJS(onPassSecondaryThreshold)(); } ctx.prevPastSecondaryThreshold = pastSecondaryThreshold; }, onEnd: event => { const absValue = Math.abs(translateX.value); if (absValue >= secondaryThreshold && secondaryActionExists) { runOnJS(secondaryAction)(); } else if (absValue >= primaryThreshold) { runOnJS(primaryAction)(); } translateX.value = withSpring(0, makeSpringConfig(event.velocityX)); }, }, [ isViewer, onPassPrimaryThreshold, onPassSecondaryThreshold, primaryAction, secondaryAction, secondaryActionExists, ], ); const transformMessageBoxStyle = useAnimatedStyle( () => ({ transform: [{ translateX: translateX.value }], }), [], ); const { messageBoxStyle, children } = props; if (!triggerReply && !triggerSidebar) { return ( {children} ); } const threadColor = `#${props.threadColor}`; const tinyThreadColor = tinycolor(threadColor); const snakes = []; if (triggerReply) { const replySnakeOpacityInterpolator = isViewer ? interpolateOpacityForViewerPrimarySnake : interpolateOpacityForNonViewerPrimarySnake; snakes.push( , ); } if (triggerReply && triggerSidebar) { const sidebarSnakeTranslateXInterpolator = isViewer ? interpolateTranslateXForViewerSecondarySnake : interpolateTranslateXForNonViewerSecondarySnake; const darkerThreadColor = tinyThreadColor .darken(tinyThreadColor.isDark() ? 10 : 20) .toString(); snakes.push( , ); } else if (triggerSidebar) { const sidebarSnakeOpacityInterpolator = isViewer ? interpolateOpacityForViewerPrimarySnake : interpolateOpacityForNonViewerPrimarySnake; snakes.push( , ); } snakes.push( {children} , ); return snakes; } const styles = { swipeSnakeContainer: { marginHorizontal: 20, justifyContent: 'center', position: 'absolute', top: 0, bottom: 0, }, animationContainer: { position: 'absolute', top: 0, bottom: 0, }, swipeSnake: { paddingHorizontal: 15, flex: 1, borderRadius: 25, height: 30, justifyContent: 'center', maxHeight: 50, }, left0: { left: 0, }, right0: { right: 0, }, alignStart: { alignItems: 'flex-start', }, alignEnd: { alignItems: 'flex-end', }, }; export default SwipeableMessage; diff --git a/native/chat/text-message-markdown-context.js b/native/chat/text-message-markdown-context.js index 36f453196..5563d04ed 100644 --- a/native/chat/text-message-markdown-context.js +++ b/native/chat/text-message-markdown-context.js @@ -1,62 +1,61 @@ // @flow import * as React from 'react'; import * as SimpleMarkdown from 'simple-markdown'; import type { ASTNode, SingleASTNode } from 'lib/shared/markdown.js'; import { messageKey } from 'lib/shared/message-utils.js'; import type { TextMessageInfo } from 'lib/types/messages/text.js'; import { useTextMessageMarkdownRules } from '../chat/message-list-types.js'; export type TextMessageMarkdownContextType = { +messageKey: string, +markdownAST: $ReadOnlyArray, +markdownHasPressable: boolean, }; -const TextMessageMarkdownContext: React.Context = React.createContext( - null, -); +const TextMessageMarkdownContext: React.Context = + React.createContext(null); const pressableMarkdownTypes = new Set(['link', 'spoiler']); const markdownASTHasPressable = (node: ASTNode): boolean => { if (Array.isArray(node)) { return node.some(markdownASTHasPressable); } const { type, content, items } = node; if (pressableMarkdownTypes.has(type)) { return true; } else if (items) { return markdownASTHasPressable(items); } else if (content) { return markdownASTHasPressable(content); } return false; }; function useTextMessageMarkdown( messageInfo: TextMessageInfo, ): TextMessageMarkdownContextType { // useDarkStyle doesn't affect the AST (only the styles), // so we can safely just set it to false here const rules = useTextMessageMarkdownRules(false); const { simpleMarkdownRules, container } = rules; const { text } = messageInfo; const ast = React.useMemo(() => { const parser = SimpleMarkdown.parserFor(simpleMarkdownRules); return parser(text, { disableAutoBlockNewlines: true, container }); }, [simpleMarkdownRules, text, container]); const key = messageKey(messageInfo); return React.useMemo( () => ({ messageKey: key, markdownAST: ast, markdownHasPressable: markdownASTHasPressable(ast), }), [key, ast], ); } export { TextMessageMarkdownContext, useTextMessageMarkdown }; diff --git a/native/chat/text-message-tooltip-button.react.js b/native/chat/text-message-tooltip-button.react.js index 88523c5ec..bba360180 100644 --- a/native/chat/text-message-tooltip-button.react.js +++ b/native/chat/text-message-tooltip-button.react.js @@ -1,200 +1,198 @@ // @flow import * as React from 'react'; import Animated from 'react-native-reanimated'; import EmojiPicker from 'rn-emoji-keyboard'; import { localIDPrefix } from 'lib/shared/message-utils.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { TooltipInlineEngagement } from './inline-engagement.react.js'; import { InnerTextMessage } from './inner-text-message.react.js'; import { MessageHeader } from './message-header.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import { MessagePressResponderContext } from './message-press-responder-context.js'; import { useSendReaction, useReactionSelectionPopoverPosition, } from './reaction-message-utils.js'; import ReactionSelectionPopover from './reaction-selection-popover.react.js'; import SidebarInputBarHeightMeasurer from './sidebar-input-bar-height-measurer.react.js'; import { useAnimatedMessageTooltipButton } from './utils.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useTooltipActions } from '../tooltip/tooltip-hooks.js'; import type { TooltipRoute } from '../tooltip/tooltip.react.js'; /* eslint-disable import/no-named-as-default-member */ const { Node, interpolateNode, Extrapolate } = Animated; /* eslint-enable import/no-named-as-default-member */ type Props = { +navigation: AppNavigationProp<'TextMessageTooltipModal'>, +route: TooltipRoute<'TextMessageTooltipModal'>, +progress: Node, +isOpeningSidebar: boolean, }; function TextMessageTooltipButton(props: Props): React.Node { const { navigation, route, progress, isOpeningSidebar } = props; const windowWidth = useSelector(state => state.dimensions.width); - const [ - sidebarInputBarHeight, - setSidebarInputBarHeight, - ] = React.useState(null); + const [sidebarInputBarHeight, setSidebarInputBarHeight] = + React.useState(null); const onInputBarMeasured = React.useCallback((height: number) => { setSidebarInputBarHeight(height); }, []); const { item, verticalBounds, initialCoordinates, margin } = route.params; const { style: messageContainerStyle, threadColorOverride, isThreadColorDarkOverride, } = useAnimatedMessageTooltipButton({ sourceMessage: item, initialCoordinates, messageListVerticalBounds: verticalBounds, progress, targetInputBarHeight: sidebarInputBarHeight, }); const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; const opacity = interpolateNode(progress, { inputRange: [0, 0.05], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); return { opacity, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [initialCoordinates.height, initialCoordinates.x, progress, windowWidth]); const threadID = item.threadInfo.id; const messagePressResponderContext = React.useMemo( () => ({ onPressMessage: navigation.goBackOnce, }), [navigation.goBackOnce], ); const inlineEngagement = React.useMemo(() => { if (!item.threadCreatedFromMessage) { return null; } return ( ); }, [initialCoordinates, isOpeningSidebar, item, progress, windowWidth]); const { messageInfo, threadInfo, reactions } = item; const nextLocalID = useSelector(state => state.nextLocalID); const localID = `${localIDPrefix}${nextLocalID}`; const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); const sendReaction = useSendReaction( messageInfo.id, localID, threadInfo.id, reactions, ); const reactionSelectionPopoverPosition = useReactionSelectionPopoverPosition({ initialCoordinates, verticalBounds, margin, }); const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); const openEmojiPicker = React.useCallback(() => { setEmojiPickerOpen(true); }, []); const reactionSelectionPopover = React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } return ( ); }, [ navigation, route, openEmojiPicker, canCreateReactionFromMessage, reactionSelectionPopoverPosition, sendReaction, ]); const tooltipRouteKey = route.key; const { dismissTooltip } = useTooltipActions(navigation, tooltipRouteKey); const onEmojiSelected = React.useCallback( emoji => { sendReaction(emoji.emoji); dismissTooltip(); }, [sendReaction, dismissTooltip], ); return ( {reactionSelectionPopover} {inlineEngagement} ); } export default TextMessageTooltipButton; diff --git a/native/chat/text-message.react.js b/native/chat/text-message.react.js index dcdee9b88..01c5244c5 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,256 +1,255 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils.js'; import { threadHasPermission, useCanCreateSidebarFromMessage, } from 'lib/shared/thread-utils.js'; import { threadPermissions } from 'lib/types/thread-types.js'; import type { ChatNavigationProp } from './chat.react.js'; import ComposedMessage from './composed-message.react.js'; import { InnerTextMessage } from './inner-text-message.react.js'; import { MessagePressResponderContext, type MessagePressResponderContextType, } from './message-press-responder-context.js'; import textMessageSendFailed from './text-message-send-failed.js'; import { getMessageTooltipKey } from './utils.js'; import { ChatContext, type ChatContextType } from '../chat/chat-context.js'; import { MarkdownContext } from '../markdown/markdown-context.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { TextMessageTooltipModalRouteName } from '../navigation/route-names.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatTextMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; type BaseProps = { ...React.ElementConfig, +item: ChatTextMessageInfoItemWithHeight, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, }; type Props = { ...BaseProps, // Redux state +canCreateSidebarFromMessage: boolean, // withOverlayContext +overlayContext: ?OverlayContextType, // ChatContext +chatContext: ?ChatContextType, // MarkdownContext +isLinkModalActive: boolean, }; class TextMessage extends React.PureComponent { message: ?React.ElementRef; messagePressResponderContext: MessagePressResponderContextType; constructor(props: Props) { super(props); this.messagePressResponderContext = { onPressMessage: this.onPress, }; } render() { const { item, navigation, route, focused, toggleFocus, verticalBounds, overlayContext, chatContext, isLinkModalActive, canCreateSidebarFromMessage, ...viewProps } = this.props; let swipeOptions = 'none'; const canReply = this.canReply(); const canNavigateToSidebar = this.canNavigateToSidebar(); if (isLinkModalActive) { swipeOptions = 'none'; } else if (canReply && canNavigateToSidebar) { swipeOptions = 'both'; } else if (canReply) { swipeOptions = 'reply'; } else if (canNavigateToSidebar) { swipeOptions = 'sidebar'; } return ( ); } messageRef = (message: ?React.ElementRef) => { this.message = message; }; canReply() { return threadHasPermission( this.props.item.threadInfo, threadPermissions.VOICED, ); } canNavigateToSidebar() { return ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } visibleEntryIDs() { const result = ['copy']; if (this.canReply()) { result.push('reply'); } if ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ) { result.push('sidebar'); } if (!this.props.item.messageInfo.creator.isViewer) { result.push('report'); } return result; } onPress = () => { const visibleEntryIDs = this.visibleEntryIDs(); if (visibleEntryIDs.length === 0) { return; } const { message, props: { verticalBounds, isLinkModalActive }, } = this; if (!message || !verticalBounds || isLinkModalActive) { return; } const { focused, toggleFocus, item } = this.props; if (!focused) { toggleFocus(messageKey(item.messageInfo)); } const { overlayContext } = this.props; invariant(overlayContext, 'TextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); message.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = belowMargin; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = this.props.chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; this.props.navigation.navigate<'TextMessageTooltipModal'>({ name: TextMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs, tooltipLocation: 'fixed', margin, item, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }); }; } -const ConnectedTextMessage: React.ComponentType = React.memo( - function ConnectedTextMessage(props: BaseProps) { +const ConnectedTextMessage: React.ComponentType = + React.memo(function ConnectedTextMessage(props: BaseProps) { const overlayContext = React.useContext(OverlayContext); const chatContext = React.useContext(ChatContext); const markdownContext = React.useContext(MarkdownContext); invariant(markdownContext, 'markdownContext should be set'); const { linkModalActive, clearMarkdownContextData } = markdownContext; const key = messageKey(props.item.messageInfo); // We check if there is an key in the object - if not, we // default to false. The likely situation where the former statement // evaluates to null is when the thread is opened for the first time. const isLinkModalActive = linkModalActive[key] ?? false; const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( props.item.threadInfo, props.item.messageInfo, ); React.useEffect(() => clearMarkdownContextData, [clearMarkdownContextData]); return ( ); - }, -); + }); export { ConnectedTextMessage as TextMessage }; diff --git a/native/chat/thread-list-modal.react.js b/native/chat/thread-list-modal.react.js index 6d74b03a6..14c4d3ae6 100644 --- a/native/chat/thread-list-modal.react.js +++ b/native/chat/thread-list-modal.react.js @@ -1,199 +1,197 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { Text, TextInput, FlatList, View, TouchableOpacity, } from 'react-native'; import type { ThreadSearchState } from 'lib/hooks/search-threads.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import type { SetState } from 'lib/types/hook-types.js'; import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types.js'; import { useNavigateToThread } from './message-list-types.js'; import Modal from '../components/modal.react.js'; import Search from '../components/search.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import ThreadPill from '../components/thread-pill.react.js'; import { useIndicatorStyle, useStyles } from '../themes/colors.js'; import { waitForModalInputFocus } from '../utils/timers.js'; function keyExtractor(sidebarInfo: SidebarInfo | ChatThreadItem) { return sidebarInfo.threadInfo.id; } function getItemLayout( data: ?$ReadOnlyArray, index: number, ) { return { length: 24, offset: 24 * index, index }; } type Props = { +threadInfo: ThreadInfo, - +createRenderItem: ( - onPressItem: (threadInfo: ThreadInfo) => void, - ) => (row: { + +createRenderItem: (onPressItem: (threadInfo: ThreadInfo) => void) => (row: { +item: U, +index: number, ... }) => React.Node, +listData: $ReadOnlyArray, +searchState: ThreadSearchState, +setSearchState: SetState, +onChangeSearchInputText: (text: string) => mixed, +searchPlaceholder?: string, +modalTitle: string, }; function ThreadListModal( props: Props, ): React.Node { const { threadInfo: parentThreadInfo, searchState, setSearchState, onChangeSearchInputText, listData, createRenderItem, searchPlaceholder, modalTitle, } = props; const searchTextInputRef = React.useRef(); const setSearchTextInputRef = React.useCallback( async (textInput: ?React.ElementRef) => { searchTextInputRef.current = textInput; if (!textInput) { return; } await waitForModalInputFocus(); if (searchTextInputRef.current) { searchTextInputRef.current.focus(); } }, [], ); const navigateToThread = useNavigateToThread(); const onPressItem = React.useCallback( (threadInfo: ThreadInfo) => { setSearchState({ text: '', results: new Set(), }); if (searchTextInputRef.current) { searchTextInputRef.current.blur(); } navigateToThread({ threadInfo }); }, [navigateToThread, setSearchState], ); - const renderItem = React.useMemo(() => createRenderItem(onPressItem), [ - createRenderItem, - onPressItem, - ]); + const renderItem = React.useMemo( + () => createRenderItem(onPressItem), + [createRenderItem, onPressItem], + ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const navigation = useNavigation(); return ( {modalTitle} ); } const unboundStyles = { parentNameWrapper: { alignItems: 'flex-start', }, body: { paddingHorizontal: 16, flex: 1, }, headerTopRow: { flexDirection: 'row', justifyContent: 'space-between', height: 32, alignItems: 'center', }, header: { borderBottomColor: 'subthreadsModalSearch', borderBottomWidth: 1, height: 94, padding: 16, justifyContent: 'space-between', }, modal: { borderRadius: 8, paddingHorizontal: 0, backgroundColor: 'subthreadsModalBackground', paddingTop: 0, justifyContent: 'flex-start', }, search: { height: 40, marginVertical: 16, backgroundColor: 'subthreadsModalSearch', }, title: { color: 'listForegroundLabel', fontSize: 20, fontWeight: '500', lineHeight: 26, alignSelf: 'center', marginLeft: 2, }, closeIcon: { color: 'subthreadsModalClose', }, closeButton: { marginRight: 2, height: 40, alignItems: 'center', justifyContent: 'center', }, }; export default ThreadListModal; diff --git a/native/chat/thread-settings-button.react.js b/native/chat/thread-settings-button.react.js index b6ba07b50..a25ac334c 100644 --- a/native/chat/thread-settings-button.react.js +++ b/native/chat/thread-settings-button.react.js @@ -1,56 +1,57 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import * as React from 'react'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import type { ChatNavigationProp } from './chat.react.js'; import Button from '../components/button.react.js'; import { ThreadSettingsRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type BaseProps = { +threadInfo: ThreadInfo, +navigate: $PropertyType, 'navigate'>, }; type Props = { ...BaseProps, +styles: typeof unboundStyles, }; class ThreadSettingsButton extends React.PureComponent { render() { return ( ); } onPress = () => { const threadInfo = this.props.threadInfo; this.props.navigate<'ThreadSettings'>({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { button: { color: 'link', paddingHorizontal: 10, }, }; -const ConnectedThreadSettingsButton: React.ComponentType = React.memo( - function ConnectedThreadSettingsButton(props: BaseProps) { +const ConnectedThreadSettingsButton: React.ComponentType = + React.memo(function ConnectedThreadSettingsButton( + props: BaseProps, + ) { const styles = useStyles(unboundStyles); return ; - }, -); + }); export default ConnectedThreadSettingsButton; diff --git a/native/chat/thread-settings-header-title.react.js b/native/chat/thread-settings-header-title.react.js index 5cee692a6..4363867d7 100644 --- a/native/chat/thread-settings-header-title.react.js +++ b/native/chat/thread-settings-header-title.react.js @@ -1,27 +1,26 @@ // @flow import { HeaderTitle, type HeaderTitleInputProps, } from '@react-navigation/elements'; import * as React from 'react'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { firstLine } from 'lib/utils/string-utils.js'; type Props = { +threadInfo: ThreadInfo, ...HeaderTitleInputProps, }; function ThreadSettingsHeaderTitle(props: Props): React.Node { const { threadInfo, ...rest } = props; const { uiName } = useResolvedThreadInfo(threadInfo); return {firstLine(uiName)}; } -const MemoizedThreadSettingsHeaderTitle: React.ComponentType = React.memo( - ThreadSettingsHeaderTitle, -); +const MemoizedThreadSettingsHeaderTitle: React.ComponentType = + React.memo(ThreadSettingsHeaderTitle); export default MemoizedThreadSettingsHeaderTitle; diff --git a/native/chat/utils.js b/native/chat/utils.js index d4bd9b86e..19036a6b1 100644 --- a/native/chat/utils.js +++ b/native/chat/utils.js @@ -1,417 +1,411 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import Animated from 'react-native-reanimated'; import { useMessageListData } from 'lib/selectors/chat-selectors.js'; import type { ChatMessageItem } from 'lib/selectors/chat-selectors.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { colorIsDark, viewerIsMember } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { clusterEndHeight, inlineEngagementStyle } from './chat-constants.js'; import { ChatContext, useHeightMeasurer } from './chat-context.js'; import { failedSendHeight } from './failed-send.react.js'; import { authorNameHeight } from './message-header.react.js'; import { multimediaMessageItemHeight } from './multimedia-message-utils.js'; import { getUnresolvedSidebarThreadInfo } from './sidebar-navigation.js'; import textMessageSendFailed from './text-message-send-failed.js'; import { timestampHeight } from './timestamp.react.js'; import { KeyboardContext } from '../keyboard/keyboard-state.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import { MultimediaMessageTooltipModalRouteName, RobotextMessageTooltipModalRouteName, TextMessageTooltipModalRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import type { ChatMessageInfoItemWithHeight, ChatMessageItemWithHeight, ChatRobotextMessageInfoItemWithHeight, ChatTextMessageInfoItemWithHeight, } from '../types/chat-types.js'; import type { LayoutCoordinates, VerticalBounds, } from '../types/layout-types.js'; import type { AnimatedViewStyle } from '../types/styles.js'; /* eslint-disable import/no-named-as-default-member */ const { Node, Extrapolate, interpolateNode, interpolateColors, block, call, eq, cond, sub, } = Animated; /* eslint-enable import/no-named-as-default-member */ function textMessageItemHeight( item: ChatTextMessageInfoItemWithHeight, ): number { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { isViewer } = messageInfo.creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (textMessageSendFailed(item)) { height += failedSendHeight; } if (item.threadCreatedFromMessage || item.reactions.size > 0) { height += inlineEngagementStyle.height + inlineEngagementStyle.marginTop + inlineEngagementStyle.marginBottom; } return height; } function robotextMessageItemHeight( item: ChatRobotextMessageInfoItemWithHeight, ): number { if (item.threadCreatedFromMessage || item.reactions.size > 0) { return item.contentHeight + inlineEngagementStyle.height; } return item.contentHeight; } function messageItemHeight(item: ChatMessageInfoItemWithHeight): number { let height = 0; if (item.messageShapeType === 'text') { height += textMessageItemHeight(item); } else if (item.messageShapeType === 'multimedia') { height += multimediaMessageItemHeight(item); } else { height += robotextMessageItemHeight(item); } if (item.startsConversation) { height += timestampHeight; } return height; } function chatMessageItemHeight(item: ChatMessageItemWithHeight): number { if (item.itemType === 'loader') { return 56; } return messageItemHeight(item); } function useMessageTargetParameters( sourceMessage: ChatMessageInfoItemWithHeight, initialCoordinates: LayoutCoordinates, messageListVerticalBounds: VerticalBounds, currentInputBarHeight: number, targetInputBarHeight: number, sidebarThreadInfo: ?ThreadInfo, ): { +position: number, +color: string, } { const messageListData = useMessageListData({ searching: false, userInfoInputArray: [], threadInfo: sidebarThreadInfo, }); - const [ - messagesWithHeight, - setMessagesWithHeight, - ] = React.useState>(null); + const [messagesWithHeight, setMessagesWithHeight] = + React.useState>(null); const measureMessages = useHeightMeasurer(); React.useEffect(() => { if (messageListData) { measureMessages( messageListData, sidebarThreadInfo, setMessagesWithHeight, ); } }, [measureMessages, messageListData, sidebarThreadInfo]); const sourceMessageID = sourceMessage.messageInfo?.id; const targetDistanceFromBottom = React.useMemo(() => { if (!messagesWithHeight) { return 0; } let offset = 0; for (const message of messagesWithHeight) { offset += chatMessageItemHeight(message); if (message.messageInfo && message.messageInfo.id === sourceMessageID) { return offset; } } return ( messageListVerticalBounds.height + chatMessageItemHeight(sourceMessage) ); }, [ messageListVerticalBounds.height, messagesWithHeight, sourceMessage, sourceMessageID, ]); if (!sidebarThreadInfo) { return { position: 0, color: sourceMessage.threadInfo.color, }; } const authorNameComponentHeight = sourceMessage.messageInfo.creator.isViewer ? 0 : authorNameHeight; const currentDistanceFromBottom = messageListVerticalBounds.height + messageListVerticalBounds.y - initialCoordinates.y + timestampHeight + authorNameComponentHeight + currentInputBarHeight; return { position: targetDistanceFromBottom + targetInputBarHeight - currentDistanceFromBottom, color: sidebarThreadInfo.color, }; } type AnimatedMessageArgs = { +sourceMessage: ChatMessageInfoItemWithHeight, +initialCoordinates: LayoutCoordinates, +messageListVerticalBounds: VerticalBounds, +progress: Node, +targetInputBarHeight: ?number, }; function useAnimatedMessageTooltipButton({ sourceMessage, initialCoordinates, messageListVerticalBounds, progress, targetInputBarHeight, }: AnimatedMessageArgs): { +style: AnimatedViewStyle, +threadColorOverride: ?Node, +isThreadColorDarkOverride: ?boolean, } { const chatContext = React.useContext(ChatContext); invariant(chatContext, 'chatContext should be set'); const { currentTransitionSidebarSourceID, setCurrentTransitionSidebarSourceID, chatInputBarHeights, sidebarAnimationType, setSidebarAnimationType, } = chatContext; const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const sidebarThreadInfo = React.useMemo( () => getUnresolvedSidebarThreadInfo({ sourceMessage, viewerID }), [sourceMessage, viewerID], ); const currentInputBarHeight = chatInputBarHeights.get(sourceMessage.threadInfo.id) ?? 0; const keyboardState = React.useContext(KeyboardContext); const newSidebarAnimationType = !currentInputBarHeight || !targetInputBarHeight || keyboardState?.keyboardShowing || !viewerIsMember(sidebarThreadInfo) ? 'fade_source_message' : 'move_source_message'; React.useEffect(() => { setSidebarAnimationType(newSidebarAnimationType); }, [setSidebarAnimationType, newSidebarAnimationType]); - const { - position: targetPosition, - color: targetColor, - } = useMessageTargetParameters( - sourceMessage, - initialCoordinates, - messageListVerticalBounds, - currentInputBarHeight, - targetInputBarHeight ?? currentInputBarHeight, - sidebarThreadInfo, - ); + const { position: targetPosition, color: targetColor } = + useMessageTargetParameters( + sourceMessage, + initialCoordinates, + messageListVerticalBounds, + currentInputBarHeight, + targetInputBarHeight ?? currentInputBarHeight, + sidebarThreadInfo, + ); React.useEffect(() => { return () => setCurrentTransitionSidebarSourceID(null); }, [setCurrentTransitionSidebarSourceID]); const bottom = React.useMemo( () => interpolateNode(progress, { inputRange: [0.3, 1], outputRange: [targetPosition, 0], extrapolate: Extrapolate.CLAMP, }), [progress, targetPosition], ); - const [ - isThreadColorDarkOverride, - setThreadColorDarkOverride, - ] = React.useState(null); + const [isThreadColorDarkOverride, setThreadColorDarkOverride] = + React.useState(null); const setThreadColorBrightness = React.useCallback(() => { const isSourceThreadDark = colorIsDark(sourceMessage.threadInfo.color); const isTargetThreadDark = colorIsDark(targetColor); if (isSourceThreadDark !== isTargetThreadDark) { setThreadColorDarkOverride(isTargetThreadDark); } }, [sourceMessage.threadInfo.color, targetColor]); const threadColorOverride = React.useMemo(() => { if ( sourceMessage.messageShapeType !== 'text' || !currentTransitionSidebarSourceID ) { return null; } return block([ cond(eq(progress, 1), call([], setThreadColorBrightness)), interpolateColors(progress, { inputRange: [0, 1], outputColorRange: [ `#${targetColor}`, `#${sourceMessage.threadInfo.color}`, ], }), ]); }, [ currentTransitionSidebarSourceID, progress, setThreadColorBrightness, sourceMessage.messageShapeType, sourceMessage.threadInfo.color, targetColor, ]); const messageContainerStyle = React.useMemo(() => { return { bottom: currentTransitionSidebarSourceID ? bottom : 0, opacity: currentTransitionSidebarSourceID && sidebarAnimationType === 'fade_source_message' ? 0 : 1, }; }, [bottom, currentTransitionSidebarSourceID, sidebarAnimationType]); return { style: messageContainerStyle, threadColorOverride, isThreadColorDarkOverride, }; } function getMessageTooltipKey(item: ChatMessageInfoItemWithHeight): string { return `tooltip|${messageKey(item.messageInfo)}`; } function isMessageTooltipKey(key: string): boolean { return key.startsWith('tooltip|'); } function useOverlayPosition(item: ChatMessageInfoItemWithHeight) { const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'should be set'); for (const overlay of overlayContext.visibleOverlays) { if ( (overlay.routeName === MultimediaMessageTooltipModalRouteName || overlay.routeName === TextMessageTooltipModalRouteName || overlay.routeName === RobotextMessageTooltipModalRouteName) && overlay.routeKey === getMessageTooltipKey(item) ) { return overlay.position; } } return undefined; } function useContentAndHeaderOpacity( item: ChatMessageInfoItemWithHeight, ): number | Node { const overlayPosition = useOverlayPosition(item); const chatContext = React.useContext(ChatContext); return React.useMemo( () => overlayPosition && chatContext?.sidebarAnimationType === 'move_source_message' ? sub( 1, interpolateNode(overlayPosition, { inputRange: [0.05, 0.06], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), ) : 1, [chatContext?.sidebarAnimationType, overlayPosition], ); } function useDeliveryIconOpacity( item: ChatMessageInfoItemWithHeight, ): number | Node { const overlayPosition = useOverlayPosition(item); const chatContext = React.useContext(ChatContext); return React.useMemo(() => { if ( !overlayPosition || !chatContext?.currentTransitionSidebarSourceID || chatContext?.sidebarAnimationType === 'fade_source_message' ) { return 1; } return interpolateNode(overlayPosition, { inputRange: [0.05, 0.06, 1], outputRange: [1, 0, 0], extrapolate: Extrapolate.CLAMP, }); }, [ chatContext?.currentTransitionSidebarSourceID, chatContext?.sidebarAnimationType, overlayPosition, ]); } function chatMessageItemKey( item: ChatMessageItemWithHeight | ChatMessageItem, ): string { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } export { chatMessageItemKey, chatMessageItemHeight, useAnimatedMessageTooltipButton, messageItemHeight, getMessageTooltipKey, isMessageTooltipKey, useContentAndHeaderOpacity, useDeliveryIconOpacity, }; diff --git a/native/components/clearable-text-input.react.ios.js b/native/components/clearable-text-input.react.ios.js index e41a74708..a9f70e39e 100644 --- a/native/components/clearable-text-input.react.ios.js +++ b/native/components/clearable-text-input.react.ios.js @@ -1,191 +1,190 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { TextInput as BaseTextInput, View, StyleSheet } from 'react-native'; import type { ClearableTextInputProps } from './clearable-text-input.js'; import TextInput from './text-input.react.js'; import type { KeyPressEvent } from '../types/react-native.js'; type State = { +textInputKey: number, }; class ClearableTextInput extends React.PureComponent< ClearableTextInputProps, State, > { state: State = { textInputKey: 0, }; pendingMessage: ?{ value: string, resolve: (value: string) => void }; lastKeyPressed: ?string; lastTextInputSent: number = -1; currentTextInput: ?React.ElementRef; focused: boolean = false; sendMessage() { if (this.pendingMessageSent) { return; } const { pendingMessage } = this; invariant(pendingMessage, 'cannot send an empty message'); pendingMessage.resolve(pendingMessage.value); const textInputSent = this.state.textInputKey - 1; if (textInputSent > this.lastTextInputSent) { this.lastTextInputSent = textInputSent; } } get pendingMessageSent(): boolean { return this.lastTextInputSent >= this.state.textInputKey - 1; } onOldInputChangeText: (text: string) => void = text => { const { pendingMessage, lastKeyPressed } = this; invariant( pendingMessage, 'onOldInputChangeText should have a pendingMessage', ); if ( !this.pendingMessageSent && lastKeyPressed && lastKeyPressed.length > 1 ) { // This represents an autocorrect event on blur pendingMessage.value = text; } this.lastKeyPressed = null; this.sendMessage(); this.updateTextFromOldInput(text); }; updateTextFromOldInput(text: string) { const { pendingMessage } = this; invariant( pendingMessage, 'updateTextFromOldInput should have a pendingMessage', ); const pendingValue = pendingMessage.value; if (!pendingValue || !text.startsWith(pendingValue)) { return; } const newValue = text.substring(pendingValue.length); if (this.props.value === newValue) { return; } this.props.onChangeText(newValue); } onOldInputKeyPress: (event: KeyPressEvent) => void = event => { const { key } = event.nativeEvent; if (this.lastKeyPressed && this.lastKeyPressed.length > key.length) { return; } this.lastKeyPressed = key; this.props.onKeyPress && this.props.onKeyPress(event); }; onOldInputBlur: () => void = () => { this.sendMessage(); }; onOldInputFocus: () => void = () => { // It's possible for the user to press the old input after the new one // appears. We can prevent that with pointerEvents="none", but that causes a // blur event when we set it, which makes the keyboard briefly pop down // before popping back up again when textInputRef is called below. Instead // we try to catch the focus event here and refocus the currentTextInput if (this.currentTextInput) { this.currentTextInput.focus(); } }; - textInputRef: ( - textInput: ?React.ElementRef, - ) => void = textInput => { - if (this.focused && textInput) { - textInput.focus(); - } - this.currentTextInput = textInput; - this.props.textInputRef(textInput); - }; + textInputRef: (textInput: ?React.ElementRef) => void = + textInput => { + if (this.focused && textInput) { + textInput.focus(); + } + this.currentTextInput = textInput; + this.props.textInputRef(textInput); + }; async getValueAndReset(): Promise { const { value } = this.props; this.props.onChangeText(''); if (!this.focused) { return value; } return await new Promise(resolve => { this.pendingMessage = { value, resolve }; this.setState(prevState => ({ textInputKey: prevState.textInputKey + 1, })); }); } onFocus: () => void = () => { this.focused = true; }; onBlur: () => void = () => { this.focused = false; if (this.pendingMessage) { // This is to catch a race condition where somebody hits the send button // and then blurs the TextInput before the textInputKey increment can // rerender this component. With this.focused set to false, the new // TextInput won't focus, and the old TextInput won't blur, which means // nothing will call sendMessage unless we do it right here. this.sendMessage(); } }; render(): React.Node { const { textInputRef, ...props } = this.props; const textInputs = []; if (this.state.textInputKey > 0) { textInputs.push( , ); } textInputs.push( , ); return {textInputs}; } } const styles = StyleSheet.create({ invisibleTextInput: { opacity: 0, position: 'absolute', }, textInputContainer: { flex: 1, }, }); export default ClearableTextInput; diff --git a/native/components/clearable-text-input.react.js b/native/components/clearable-text-input.react.js index 7cc7256eb..728b08db6 100644 --- a/native/components/clearable-text-input.react.js +++ b/native/components/clearable-text-input.react.js @@ -1,84 +1,83 @@ // @flow import * as React from 'react'; import { TextInput as BaseTextInput, View, StyleSheet } from 'react-native'; import sleep from 'lib/utils/sleep.js'; import type { ClearableTextInputProps } from './clearable-text-input.js'; import TextInput from './text-input.react.js'; import { waitForInteractions } from '../utils/timers.js'; class ClearableTextInput extends React.PureComponent { textInput: ?React.ElementRef; lastMessageSent: ?string; queuedResolve: ?() => mixed; onChangeText: (inputText: string) => void = inputText => { let text; if ( this.lastMessageSent && this.lastMessageSent.length < inputText.length && inputText.startsWith(this.lastMessageSent) ) { text = inputText.substring(this.lastMessageSent.length); } else { text = inputText; this.lastMessageSent = null; } this.props.onChangeText(text); }; getValueAndReset(): Promise { const { value } = this.props; this.lastMessageSent = value; this.props.onChangeText(''); if (this.textInput) { this.textInput.clear(); } return new Promise(resolve => { this.queuedResolve = async () => { await waitForInteractions(); await sleep(5); resolve(value); }; }); } componentDidUpdate(prevProps: ClearableTextInputProps) { if (!this.props.value && prevProps.value && this.queuedResolve) { const resolve = this.queuedResolve; this.queuedResolve = null; resolve(); } } render(): React.Node { const { textInputRef, ...props } = this.props; return ( ); } - textInputRef: ( - textInput: ?React.ElementRef, - ) => void = textInput => { - this.textInput = textInput; - this.props.textInputRef(textInput); - }; + textInputRef: (textInput: ?React.ElementRef) => void = + textInput => { + this.textInput = textInput; + this.props.textInputRef(textInput); + }; } const styles = StyleSheet.create({ textInputContainer: { flex: 1, }, }); export default ClearableTextInput; diff --git a/native/components/keyboard-avoiding-view.react.js b/native/components/keyboard-avoiding-view.react.js index e268778d0..6e6a3226b 100644 --- a/native/components/keyboard-avoiding-view.react.js +++ b/native/components/keyboard-avoiding-view.react.js @@ -1,197 +1,196 @@ // @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 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 = []; }; 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/components/link-button.react.js b/native/components/link-button.react.js index d8c492d6e..afcf30eb8 100644 --- a/native/components/link-button.react.js +++ b/native/components/link-button.react.js @@ -1,60 +1,59 @@ // @flow import * as React from 'react'; import { Text } from 'react-native'; import Button from './button.react.js'; import { useStyles } from '../themes/colors.js'; import type { ViewStyle } from '../types/styles.js'; type BaseProps = { +text: string, +onPress: () => void, +disabled?: boolean, +style?: ViewStyle, }; type Props = { ...BaseProps, +styles: typeof unboundStyles, }; class LinkButton extends React.PureComponent { render() { const disabledStyle = this.props.disabled ? this.props.styles.disabled : null; return ( ); } } const unboundStyles = { disabled: { color: 'modalBackgroundSecondaryLabel', }, text: { color: 'link', fontSize: 17, paddingHorizontal: 10, }, }; -const ConnectedLinkButton: React.ComponentType = React.memo( - function ConnectedLinkButton(props: BaseProps) { +const ConnectedLinkButton: React.ComponentType = + React.memo(function ConnectedLinkButton(props: BaseProps) { const styles = useStyles(unboundStyles); return ; - }, -); + }); export default ConnectedLinkButton; diff --git a/native/components/node-height-measurer.react.js b/native/components/node-height-measurer.react.js index 7fbc3796e..b2fdad1dc 100644 --- a/native/components/node-height-measurer.react.js +++ b/native/components/node-height-measurer.react.js @@ -1,521 +1,517 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet, PixelRatio } from 'react-native'; import shallowequal from 'shallowequal'; import type { Shape } from 'lib/types/core.js'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle.js'; import type { LayoutEvent, EventSubscription } from '../types/react-native.js'; const measureBatchSize = 50; type MergedItemPair = { +item: Item, +mergedItem: MergedItem, }; type Props = { // What we want to render +listData: ?$ReadOnlyArray, // Every item should have an ID. We use this ID to cache the result of calling // mergeItemWithHeight below, and only update it if the input item changes, // mergeItemWithHeight changes, or any extra props we get passed change +itemToID: Item => string, // Only measurable items should return a measureKey. // Falsey keys won't get measured, but will still get passed through // mergeItemWithHeight with height undefined // Make sure that if an item's height changes, its measure key does too! +itemToMeasureKey: Item => ?string, // The "dummy" is the component whose height we will be measuring // We will only call this with items for which itemToMeasureKey returns truthy +itemToDummy: Item => React.Element, // Once we have the height, we need to merge it into the item +mergeItemWithHeight: (item: Item, height: ?number) => MergedItem, // We'll pass our results here when we're done +allHeightsMeasured: ( items: $ReadOnlyArray, measuredHeights: $ReadOnlyMap, ) => mixed, +initialMeasuredHeights?: ?$ReadOnlyMap, ... }; type State = { // These are the dummies currently being rendered +currentlyMeasuring: $ReadOnlyArray<{ +measureKey: string, +dummy: React.Element, }>, // When certain parameters change we need to remeasure everything. In order to // avoid considering any onLayouts that got queued before we issued the // remeasure, we increment the "iteration" and only count onLayouts with the // right value +iteration: number, // We cache the measured heights here, keyed by measure key +measuredHeights: Map, // We cache the results of calling mergeItemWithHeight on measured items after // measuring their height, keyed by ID +measurableItems: Map>, // We cache the results of calling mergeItemWithHeight on items that aren't // measurable (eg. itemToKey reurns falsey), keyed by ID +unmeasurableItems: Map>, }; class NodeHeightMeasurer extends React.PureComponent< Props, State, > { containerWidth: ?number; // we track font scale when native app state changes appLifecycleSubscription: ?EventSubscription; currentLifecycleState: ?string = getCurrentLifecycleState(); currentFontScale: number = PixelRatio.getFontScale(); constructor(props: Props) { super(props); this.state = NodeHeightMeasurer.createInitialStateFromProps(props); } static createInitialStateFromProps( props: Props, ): State { const { listData, itemToID, itemToMeasureKey, mergeItemWithHeight, initialMeasuredHeights, } = props; const unmeasurableItems = new Map(); const measurableItems = new Map(); const measuredHeights = initialMeasuredHeights ? new Map(initialMeasuredHeights) : new Map(); if (listData) { for (const item of listData) { const measureKey = itemToMeasureKey(item); if (measureKey === null || measureKey === undefined) { const mergedItem = mergeItemWithHeight(item, undefined); unmeasurableItems.set(itemToID(item), { item, mergedItem }); continue; } const height = measuredHeights.get(measureKey); if (height === undefined) { continue; } const mergedItem = mergeItemWithHeight(item, height); measurableItems.set(itemToID(item), { item, mergedItem }); } } return { currentlyMeasuring: [], iteration: 0, measuredHeights, measurableItems, unmeasurableItems, }; } static getDerivedStateFromProps( props: Props, state: State, ): ?Shape> { return NodeHeightMeasurer.getPossibleStateUpdateForNextBatch< InnerItem, InnerMergedItem, >(props, state); } static getPossibleStateUpdateForNextBatch( props: Props, state: State, ): ?Shape> { const { currentlyMeasuring, measuredHeights } = state; let stillMeasuring = false; for (const { measureKey } of currentlyMeasuring) { const height = measuredHeights.get(measureKey); if (height === null || height === undefined) { stillMeasuring = true; break; } } if (stillMeasuring) { return null; } const { listData, itemToMeasureKey, itemToDummy } = props; const toMeasure = new Map(); if (listData) { for (const item of listData) { const measureKey = itemToMeasureKey(item); if (measureKey === null || measureKey === undefined) { continue; } const height = measuredHeights.get(measureKey); if (height !== null && height !== undefined) { continue; } const dummy = itemToDummy(item); toMeasure.set(measureKey, dummy); if (toMeasure.size === measureBatchSize) { break; } } } if (currentlyMeasuring.length === 0 && toMeasure.size === 0) { return null; } const nextCurrentlyMeasuring = []; for (const [measureKey, dummy] of toMeasure) { nextCurrentlyMeasuring.push({ measureKey, dummy }); } return { currentlyMeasuring: nextCurrentlyMeasuring, measuredHeights: new Map(measuredHeights), }; } possiblyIssueNewBatch() { const stateUpdate = NodeHeightMeasurer.getPossibleStateUpdateForNextBatch( this.props, this.state, ); if (stateUpdate) { this.setState(stateUpdate); } } handleAppStateChange: (nextState: ?string) => void = nextState => { if (!nextState || nextState === 'unknown') { return; } const lastState = this.currentLifecycleState; this.currentLifecycleState = nextState; // detect font scale changes only when app enters foreground if (lastState !== 'background' || nextState !== 'active') { return; } const lastScale = this.currentFontScale; this.currentFontScale = PixelRatio.getFontScale(); if (lastScale !== this.currentFontScale) { // recreate initial state to trigger full remeasurement this.setState(NodeHeightMeasurer.createInitialStateFromProps(this.props)); } }; componentDidMount() { this.appLifecycleSubscription = addLifecycleListener( this.handleAppStateChange, ); this.triggerCallback( this.state.measurableItems, this.state.unmeasurableItems, this.state.measuredHeights, false, ); } componentWillUnmount() { if (this.appLifecycleSubscription) { this.appLifecycleSubscription.remove(); } } triggerCallback( measurableItems: Map>, unmeasurableItems: Map>, measuredHeights: Map, mustTrigger: boolean, ) { - const { - listData, - itemToID, - itemToMeasureKey, - allHeightsMeasured, - } = this.props; + const { listData, itemToID, itemToMeasureKey, allHeightsMeasured } = + this.props; if (!listData) { return; } const result = []; for (const item of listData) { const id = itemToID(item); const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { const measurableItem = measurableItems.get(id); if (!measurableItem && !mustTrigger) { return; } invariant( measurableItem, `currentlyMeasuring empty but no result for ${id}`, ); result.push(measurableItem.mergedItem); } else { const unmeasurableItem = unmeasurableItems.get(id); if (!unmeasurableItem && !mustTrigger) { return; } invariant( unmeasurableItem, `currentlyMeasuring empty but no result for ${id}`, ); result.push(unmeasurableItem.mergedItem); } } allHeightsMeasured(result, new Map(measuredHeights)); } componentDidUpdate( prevProps: Props, prevState: State, ) { const { listData, itemToID, itemToMeasureKey, itemToDummy, mergeItemWithHeight, allHeightsMeasured, ...rest } = this.props; const { listData: prevListData, itemToID: prevItemToID, itemToMeasureKey: prevItemToMeasureKey, itemToDummy: prevItemToDummy, mergeItemWithHeight: prevMergeItemWithHeight, allHeightsMeasured: prevAllHeightsMeasured, ...prevRest } = prevProps; const restShallowEqual = shallowequal(rest, prevRest); const measurementJustCompleted = this.state.currentlyMeasuring.length === 0 && prevState.currentlyMeasuring.length !== 0; let incrementIteration = false; const nextMeasuredHeights = new Map(this.state.measuredHeights); let measuredHeightsChanged = false; const nextMeasurableItems = new Map(this.state.measurableItems); let measurableItemsChanged = false; const nextUnmeasurableItems = new Map(this.state.unmeasurableItems); let unmeasurableItemsChanged = false; if ( itemToMeasureKey !== prevItemToMeasureKey || itemToDummy !== prevItemToDummy ) { incrementIteration = true; nextMeasuredHeights.clear(); measuredHeightsChanged = true; } if ( itemToID !== prevItemToID || itemToMeasureKey !== prevItemToMeasureKey || itemToDummy !== prevItemToDummy || mergeItemWithHeight !== prevMergeItemWithHeight || !restShallowEqual ) { if (nextMeasurableItems.size > 0) { nextMeasurableItems.clear(); measurableItemsChanged = true; } } if ( itemToID !== prevItemToID || itemToMeasureKey !== prevItemToMeasureKey || mergeItemWithHeight !== prevMergeItemWithHeight || !restShallowEqual ) { if (nextUnmeasurableItems.size > 0) { nextUnmeasurableItems.clear(); unmeasurableItemsChanged = true; } } if ( measurementJustCompleted || listData !== prevListData || measuredHeightsChanged || measurableItemsChanged || unmeasurableItemsChanged ) { const currentMeasurableItems = new Map(); const currentUnmeasurableItems = new Map(); if (listData) { for (const item of listData) { const id = itemToID(item); const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { currentMeasurableItems.set(id, item); } else { currentUnmeasurableItems.set(id, item); } } } for (const [id, { item }] of nextMeasurableItems) { const currentItem = currentMeasurableItems.get(id); if (!currentItem) { measurableItemsChanged = true; nextMeasurableItems.delete(id); } else if (currentItem !== item) { measurableItemsChanged = true; const measureKey = itemToMeasureKey(currentItem); if (measureKey === null || measureKey === undefined) { nextMeasurableItems.delete(id); continue; } const height = nextMeasuredHeights.get(measureKey); if (height === null || height === undefined) { nextMeasurableItems.delete(id); continue; } const mergedItem = mergeItemWithHeight(currentItem, height); nextMeasurableItems.set(id, { item: currentItem, mergedItem }); } } for (const [id, item] of currentMeasurableItems) { if (nextMeasurableItems.has(id)) { continue; } const measureKey = itemToMeasureKey(item); if (measureKey === null || measureKey === undefined) { continue; } const height = nextMeasuredHeights.get(measureKey); if (height === null || height === undefined) { continue; } const mergedItem = mergeItemWithHeight(item, height); nextMeasurableItems.set(id, { item, mergedItem }); measurableItemsChanged = true; } for (const [id, { item }] of nextUnmeasurableItems) { const currentItem = currentUnmeasurableItems.get(id); if (!currentItem) { unmeasurableItemsChanged = true; nextUnmeasurableItems.delete(id); } else if (currentItem !== item) { unmeasurableItemsChanged = true; const measureKey = itemToMeasureKey(currentItem); if (measureKey !== null && measureKey !== undefined) { nextUnmeasurableItems.delete(id); continue; } const mergedItem = mergeItemWithHeight(currentItem, undefined); nextUnmeasurableItems.set(id, { item: currentItem, mergedItem }); } } for (const [id, item] of currentUnmeasurableItems) { if (nextUnmeasurableItems.has(id)) { continue; } const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { continue; } const mergedItem = mergeItemWithHeight(item, undefined); nextUnmeasurableItems.set(id, { item, mergedItem }); unmeasurableItemsChanged = true; } } const stateUpdate = {}; if (incrementIteration) { stateUpdate.iteration = this.state.iteration + 1; } if (measuredHeightsChanged) { stateUpdate.measuredHeights = nextMeasuredHeights; } if (measurableItemsChanged) { stateUpdate.measurableItems = nextMeasurableItems; } if (unmeasurableItemsChanged) { stateUpdate.unmeasurableItems = nextUnmeasurableItems; } if (Object.keys(stateUpdate).length > 0) { this.setState(stateUpdate); } if (measurementJustCompleted || !shallowequal(this.props, prevProps)) { this.triggerCallback( nextMeasurableItems, nextUnmeasurableItems, nextMeasuredHeights, measurementJustCompleted, ); } } onContainerLayout: (event: LayoutEvent) => void = event => { const { width, height } = event.nativeEvent.layout; if (width > height) { // We currently only use NodeHeightMeasurer on interfaces that are // portrait-locked. If we expand beyond that we'll need to rethink this return; } if (this.containerWidth === undefined) { this.containerWidth = width; } else if (this.containerWidth !== width) { this.containerWidth = width; this.setState(innerPrevState => ({ iteration: innerPrevState.iteration + 1, measuredHeights: new Map(), measurableItems: new Map(), })); } }; onDummyLayout(measureKey: string, iteration: number, event: LayoutEvent) { if (iteration !== this.state.iteration) { return; } const { height } = event.nativeEvent.layout; this.state.measuredHeights.set(measureKey, height); this.possiblyIssueNewBatch(); } render(): React.Node { const { currentlyMeasuring, iteration } = this.state; const dummies = currentlyMeasuring.map(({ measureKey, dummy }) => { const { children } = dummy.props; const style = [dummy.props.style, styles.dummy]; const onLayout = event => this.onDummyLayout(measureKey, iteration, event); const node = React.cloneElement(dummy, { style, onLayout, children, }); return {node}; }); return {dummies}; } } const styles = StyleSheet.create({ dummy: { opacity: 0, position: 'absolute', }, }); export default NodeHeightMeasurer; diff --git a/native/components/selectable-text-input.react.ios.js b/native/components/selectable-text-input.react.ios.js index 8874bf4f7..7224f6d91 100644 --- a/native/components/selectable-text-input.react.ios.js +++ b/native/components/selectable-text-input.react.ios.js @@ -1,142 +1,141 @@ // @flow import _debounce from 'lodash/debounce.js'; import * as React from 'react'; import type { Selection } from 'lib/shared/typeahead-utils.js'; // eslint-disable-next-line import/extensions import ClearableTextInput from './clearable-text-input.react'; import type { SelectableTextInputProps, SelectableTextInputRef, } from './selectable-text-input.js'; import type { SelectionChangeEvent } from '../types/react-native.js'; const SelectableTextInput = React.forwardRef(function BaseSelectableTextInput( props, ref, ): React.Node { const { clearableTextInputRef, onChangeText, onSelectionChange, onUpdateSyncedSelectionData, ...rest } = props; // React Native doesn't handle controlled selection well, so we only set the // selection prop when we need to mutate the selection // https://github.com/facebook/react-native/issues/29063 - const [controlSelection, setControlSelection] = React.useState( - false, - ); + const [controlSelection, setControlSelection] = + React.useState(false); const clearableTextInputRefCallback = React.useCallback( (clearableTextInput: ?React.ElementRef) => { clearableTextInputRef(clearableTextInput); }, [clearableTextInputRef], ); // - It's important for us to keep text and selection state in sync, since // upstream code in ChatInputBar processes this data during render to // generate a list of @-mention suggestions // - On iOS, selection events precede text change events, and each leads to a // separate React render cycle // - To prevent render cycles where the data isn't in sync, we defer selection // events until the corresponding text change event comes in // - Since selection events can happen without text changes (user moving the // cursor) we also set a debounced timeout after each selection event that // will activate if no corresponding text change event comes in within 50ms const pendingSelectionEventRef = React.useRef(); const sendPendingSelectionEvent = React.useCallback( (text: string) => { const pendingSelectionEvent = pendingSelectionEventRef.current; if (!pendingSelectionEvent) { return; } pendingSelectionEventRef.current = undefined; onUpdateSyncedSelectionData({ text, selection: pendingSelectionEvent }); }, [onUpdateSyncedSelectionData], ); const onChangeTextOverride = React.useCallback( (text: string) => { onChangeText(text); sendPendingSelectionEvent(text); }, [onChangeText, sendPendingSelectionEvent], ); // When a user selects a @-mention in the middle of some text, React Native on // iOS has a strange bug where it emits two selection events in a row: // - The first selection event resets the cursor to the very end of the text // - The second selection event puts the cursor back where it should go, which // is the middle of the text where it started, but after the new text that // just got inserted // In contrast, if an @-mention is entered at the end, only the first event // occurs. We actually want to ignore both, because we manually reset the // selection state ourselves and these events don't reflect our updates. const numNextSelectionEventsToIgnoreRef = React.useRef(0); const prepareForSelectionMutation = React.useCallback( (text: string, selection: Selection) => { setControlSelection(true); numNextSelectionEventsToIgnoreRef.current = selection.start === text.length ? 1 : 2; }, [], ); const ourRef = React.useMemo( () => ({ prepareForSelectionMutation, }), [prepareForSelectionMutation], ); React.useImperativeHandle(ref, () => ourRef, [ourRef]); const debouncedSendPendingSelectionEvent = React.useMemo( () => _debounce(sendPendingSelectionEvent, 50), [sendPendingSelectionEvent], ); const onSelectionChangeOverride = React.useCallback( (event: SelectionChangeEvent) => { if (numNextSelectionEventsToIgnoreRef.current <= 1) { // If after this tick we will start allowing selection events through, // then we will drop control of selection setControlSelection(false); } if (numNextSelectionEventsToIgnoreRef.current > 0) { numNextSelectionEventsToIgnoreRef.current--; return; } pendingSelectionEventRef.current = event.nativeEvent.selection; debouncedSendPendingSelectionEvent(props.value); if (onSelectionChange) { onSelectionChange(event); } }, [debouncedSendPendingSelectionEvent, props.value, onSelectionChange], ); return ( ); }); const MemoizedSelectableTextInput: React.AbstractComponent< SelectableTextInputProps, SelectableTextInputRef, > = React.memo( SelectableTextInput, ); export default MemoizedSelectableTextInput; diff --git a/native/components/selectable-text-input.react.js b/native/components/selectable-text-input.react.js index 11f74df4a..2dff3a9d6 100644 --- a/native/components/selectable-text-input.react.js +++ b/native/components/selectable-text-input.react.js @@ -1,87 +1,86 @@ // @flow import * as React from 'react'; // eslint-disable-next-line import/extensions import ClearableTextInput from './clearable-text-input.react'; import type { SelectableTextInputProps, SelectableTextInputRef, } from './selectable-text-input.js'; import type { SelectionChangeEvent } from '../types/react-native.js'; const SelectableTextInput = React.forwardRef(function BaseSelectableTextInput( props, ref, ): React.Node { const { clearableTextInputRef, onUpdateSyncedSelectionData, onSelectionChange, selection, ...rest } = props; // React Native doesn't handle controlled selection well, so we only set the // selection prop when we need to mutate the selection // https://github.com/facebook/react-native/issues/29063 - const [controlSelection, setControlSelection] = React.useState( - false, - ); + const [controlSelection, setControlSelection] = + React.useState(false); const clearableTextInputRefCallback = React.useCallback( (clearableTextInput: ?React.ElementRef) => { clearableTextInputRef(clearableTextInput); }, [clearableTextInputRef], ); const prepareForSelectionMutation = React.useCallback( () => setControlSelection(true), [], ); const ourRef = React.useMemo( () => ({ prepareForSelectionMutation, }), [prepareForSelectionMutation], ); React.useImperativeHandle(ref, () => ourRef, [ourRef]); // - It's important for us to keep text and selection state in sync, since // upstream code in ChatInputBar processes this data during render to // generate a list of @-mention suggestions // - On Android, text change events precede selection events, and each leads // to a separate React render cycle // - To prevent render cycles where the data isn't in sync, we defer text // change events until the corresponding selection event comes in const onSelectionChangeOverride = React.useCallback( (event: SelectionChangeEvent) => { setControlSelection(false); onSelectionChange?.(event); onUpdateSyncedSelectionData({ text: props.value, selection: event.nativeEvent.selection, }); }, [onUpdateSyncedSelectionData, props.value, onSelectionChange], ); return ( ); }); const MemoizedSelectableTextInput: React.AbstractComponent< SelectableTextInputProps, SelectableTextInputRef, > = React.memo( SelectableTextInput, ); export default MemoizedSelectableTextInput; diff --git a/native/components/swipeable.js b/native/components/swipeable.js index 5bed21967..880b4c45d 100644 --- a/native/components/swipeable.js +++ b/native/components/swipeable.js @@ -1,118 +1,117 @@ // @flow import * as React from 'react'; import { Animated, View } from 'react-native'; // eslint-disable-next-line import/extensions import SwipeableComponent from 'react-native-gesture-handler/Swipeable'; import { useSelector } from 'react-redux'; import Button from './button.react.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; type BaseProps = { +buttonWidth: number, +rightActions: $ReadOnlyArray<{ +key: string, +onPress: () => mixed, +color: ?string, +content: React.Node, }>, +onSwipeableRightWillOpen?: () => void, +innerRef: { current: ?SwipeableComponent, }, +children?: React.Node, }; type Props = { ...BaseProps, +windowWidth: number, +colors: Colors, +styles: typeof unboundStyles, }; class Swipeable extends React.PureComponent { static defaultProps = { rightActions: [], }; renderRightActions = progress => { const actions = this.props.rightActions.map( ({ key, content, color, onPress }, i) => { const translation = progress.interpolate({ inputRange: [0, 1], outputRange: [ (this.props.rightActions.length - i) * this.props.buttonWidth, 0, ], }); return ( ); }, ); return {actions}; }; render() { return ( {this.props.children} ); } } const unboundStyles = { action: { height: '100%', alignItems: 'center', justifyContent: 'center', }, actionsContainer: { flexDirection: 'row', }, }; -const ConnectedSwipeable: React.ComponentType = React.memo( - function ConnectedSwipeable(props: BaseProps) { +const ConnectedSwipeable: React.ComponentType = + React.memo(function ConnectedSwipeable(props: BaseProps) { const styles = useStyles(unboundStyles); const windowWidth = useSelector(state => state.dimensions.width); const colors = useColors(); return ( ); - }, -); + }); export default ConnectedSwipeable; diff --git a/native/components/tag-input.react.js b/native/components/tag-input.react.js index 136e42160..dd147a067 100644 --- a/native/components/tag-input.react.js +++ b/native/components/tag-input.react.js @@ -1,484 +1,482 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, TextInput as BaseTextInput, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, ScrollView, Platform, } from 'react-native'; import type { Shape } from 'lib/types/core.js'; import TextInput from './text-input.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useColors, type Colors } from '../themes/colors.js'; import type { LayoutEvent, KeyPressEvent, BlurEvent, } from '../types/react-native.js'; import type { ViewStyle, TextStyle } from '../types/styles.js'; type DefaultProps = { /** * Min height of the tag input on screen */ +minHeight: number, /** * Max height of the tag input on screen (will scroll if max height reached) */ +maxHeight: number, /** * inputWidth if text === "". we want this number explicitly because if we're * forced to measure the component, there can be a short jump between the old * value and the new value, which looks sketchy. */ +defaultInputWidth: number, }; type TagInputProps = { ...DefaultProps, /** * An array of tags, which can be any type, as long as labelExtractor below * can extract a string from it. */ +value: $ReadOnlyArray, /** * A handler to be called when array of tags change. */ +onChange: (items: $ReadOnlyArray) => void, /** * Function to extract string value for label from item */ +labelExtractor: (tagData: T) => string, /** * The text currently being displayed in the TextInput following the list of * tags. */ +text: string, /** * This callback gets called when the user in the TextInput. The caller should * update the text prop when this is called if they want to access input. */ +onChangeText: (text: string) => mixed, /** * If `true`, text and tags are not editable. The default value is `false`. */ +disabled?: boolean, /** * Background color of tags */ +tagColor?: string, /** * Text color of tags */ +tagTextColor?: string, /** * Styling override for container surrounding tag text */ +tagContainerStyle?: ViewStyle, /** * Styling override for tag's text component */ +tagTextStyle?: TextStyle, /** * Color of text input */ +inputColor?: string, /** * Any misc. TextInput props (autoFocus, placeholder, returnKeyType, etc.) */ +inputProps?: React.ElementConfig, /** * Callback that gets passed the new component height when it changes */ +onHeightChange?: (height: number) => void, }; type BaseTagInputProps = { ...TagInputProps, +windowWidth: number, +colors: Colors, }; type State = { +wrapperHeight: number, +contentHeight: number, +wrapperWidth: number, +spaceLeft: number, }; class BaseTagInput extends React.PureComponent, State> { // scroll to bottom scrollViewHeight: number = 0; scrollToBottomAfterNextScrollViewLayout: boolean = false; // refs tagInput: ?React.ElementRef = null; scrollView: ?React.ElementRef = null; lastChange: ?{ time: number, prevText: string }; static defaultProps: DefaultProps = { minHeight: 30, maxHeight: 75, defaultInputWidth: 90, }; constructor(props: BaseTagInputProps) { super(props); this.state = { wrapperHeight: 30, // was wrapperHeight: 36, contentHeight: 0, wrapperWidth: props.windowWidth, spaceLeft: 0, }; } static getDerivedStateFromProps( props: BaseTagInputProps, state: State, ): Shape { const wrapperHeight = Math.max( Math.min(props.maxHeight, state.contentHeight), props.minHeight, ); return { wrapperHeight }; } componentDidUpdate(prevProps: BaseTagInputProps, prevState: State) { if ( this.props.onHeightChange && this.state.wrapperHeight !== prevState.wrapperHeight ) { this.props.onHeightChange(this.state.wrapperHeight); } } measureWrapper: (event: LayoutEvent) => void = event => { const wrapperWidth = event.nativeEvent.layout.width; if (wrapperWidth !== this.state.wrapperWidth) { this.setState({ wrapperWidth }); } }; onChangeText: (text: string) => void = text => { this.lastChange = { time: Date.now(), prevText: this.props.text }; this.props.onChangeText(text); }; onBlur: (event: BlurEvent) => void = event => { invariant(Platform.OS === 'ios', 'only iOS gets text on TextInput.onBlur'); const nativeEvent: $ReadOnly<{ target: number, text: string, }> = (event.nativeEvent: any); this.onChangeText(nativeEvent.text); }; onKeyPress: (event: KeyPressEvent) => void = event => { const { lastChange } = this; let { text } = this.props; if ( Platform.OS === 'android' && lastChange !== null && lastChange !== undefined && Date.now() - lastChange.time < 150 ) { text = lastChange.prevText; } if (text !== '' || event.nativeEvent.key !== 'Backspace') { return; } const tags = [...this.props.value]; tags.pop(); this.props.onChange(tags); this.focus(); }; focus: () => void = () => { invariant(this.tagInput, 'should be set'); this.tagInput.focus(); }; removeIndex: (index: number) => void = index => { const tags = [...this.props.value]; tags.splice(index, 1); this.props.onChange(tags); }; scrollToBottom: () => void = () => { const scrollView = this.scrollView; invariant( scrollView, 'this.scrollView ref should exist before scrollToBottom called', ); scrollView.scrollToEnd(); }; render(): React.Node { const tagColor = this.props.tagColor || this.props.colors.modalSubtext; const tagTextColor = this.props.tagTextColor || this.props.colors.modalForegroundLabel; const inputColor = this.props.inputColor || this.props.colors.modalForegroundLabel; const placeholderColor = this.props.colors.modalForegroundTertiaryLabel; const tags = this.props.value.map((tag, index) => ( )); let inputWidth; if (this.props.text === '') { inputWidth = this.props.defaultInputWidth; } else if (this.state.spaceLeft >= 100) { inputWidth = this.state.spaceLeft - 10; } else { inputWidth = this.state.wrapperWidth; } const defaultTextInputProps: React.ElementConfig = { blurOnSubmit: false, style: [ styles.textInput, { width: inputWidth, color: inputColor, }, ], autoCapitalize: 'none', autoCorrect: false, placeholder: 'Start typing', placeholderTextColor: placeholderColor, returnKeyType: 'done', keyboardType: 'default', }; const textInputProps: React.ElementConfig = { ...defaultTextInputProps, ...this.props.inputProps, // should not be overridden onKeyPress: this.onKeyPress, value: this.props.text, onBlur: Platform.OS === 'ios' ? this.onBlur : undefined, onChangeText: this.onChangeText, editable: !this.props.disabled, }; return ( {tags} ); } - tagInputRef: ( - tagInput: ?React.ElementRef, - ) => void = tagInput => { - this.tagInput = tagInput; - }; + tagInputRef: (tagInput: ?React.ElementRef) => void = + tagInput => { + this.tagInput = tagInput; + }; - scrollViewRef: ( - scrollView: ?React.ElementRef, - ) => void = scrollView => { - this.scrollView = scrollView; - }; + scrollViewRef: (scrollView: ?React.ElementRef) => void = + scrollView => { + this.scrollView = scrollView; + }; onScrollViewContentSizeChange: (w: number, h: number) => void = (w, h) => { const oldContentHeight = this.state.contentHeight; if (h === oldContentHeight) { return; } let callback; if (h > oldContentHeight) { callback = () => { if (this.scrollViewHeight === this.props.maxHeight) { this.scrollToBottom(); } else { this.scrollToBottomAfterNextScrollViewLayout = true; } }; } this.setState({ contentHeight: h }, callback); }; onScrollViewLayout: (event: LayoutEvent) => void = event => { this.scrollViewHeight = event.nativeEvent.layout.height; if (this.scrollToBottomAfterNextScrollViewLayout) { this.scrollToBottom(); this.scrollToBottomAfterNextScrollViewLayout = false; } }; onLayoutLastTag: (endPosOfTag: number) => void = endPosOfTag => { const margin = 3; const spaceLeft = this.state.wrapperWidth - endPosOfTag - margin - 10; if (spaceLeft !== this.state.spaceLeft) { this.setState({ spaceLeft }); } }; } type TagProps = { +index: number, +label: string, +isLastTag: boolean, +onLayoutLastTag: (endPosOfTag: number) => void, +removeIndex: (index: number) => void, +tagColor: string, +tagTextColor: string, +tagContainerStyle?: ViewStyle, +tagTextStyle?: TextStyle, +disabled?: boolean, }; class Tag extends React.PureComponent { curPos: ?number = null; componentDidUpdate(prevProps: TagProps) { if ( !prevProps.isLastTag && this.props.isLastTag && this.curPos !== null && this.curPos !== undefined ) { this.props.onLayoutLastTag(this.curPos); } } render() { return ( {this.props.label}  × ); } onPress = () => { this.props.removeIndex(this.props.index); }; onLayoutLastTag = (event: LayoutEvent) => { const layout = event.nativeEvent.layout; this.curPos = layout.width + layout.x; if (this.props.isLastTag) { this.props.onLayoutLastTag(this.curPos); } }; } const styles = StyleSheet.create({ tag: { borderRadius: 2, justifyContent: 'center', marginBottom: 3, marginRight: 3, paddingHorizontal: 6, paddingVertical: 2, }, tagInputContainer: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', }, tagInputContainerScroll: { flex: 1, }, tagText: { fontSize: 16, margin: 0, padding: 0, }, textInput: { borderBottomColor: 'transparent', flex: 0.6, fontSize: 16, height: 24, marginBottom: 3, marginHorizontal: 0, marginTop: 3, padding: 0, }, textInputContainer: {}, wrapper: {}, }); type BaseConfig = React.Config< TagInputProps, typeof BaseTagInput.defaultProps, >; function createTagInput(): React.AbstractComponent< BaseConfig, BaseTagInput, > { return React.forwardRef, BaseTagInput>( function ForwardedTagInput( props: BaseConfig, ref: React.Ref, ) { const windowWidth = useSelector(state => state.dimensions.width); const colors = useColors(); return ( ); }, ); } export { createTagInput, BaseTagInput }; diff --git a/native/components/thread-list-thread.react.js b/native/components/thread-list-thread.react.js index 558909c72..efdfae586 100644 --- a/native/components/thread-list-thread.react.js +++ b/native/components/thread-list-thread.react.js @@ -1,86 +1,85 @@ // @flow import * as React from 'react'; import type { ThreadInfo, ResolvedThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import Button from './button.react.js'; import ColorSplotch from './color-splotch.react.js'; import { SingleLine } from './single-line.react.js'; import { type Colors, useStyles, useColors } from '../themes/colors.js'; import type { ViewStyle, TextStyle } from '../types/styles.js'; type SharedProps = { +onSelect: (threadID: string) => void, +style?: ViewStyle, +textStyle?: TextStyle, }; type BaseProps = { ...SharedProps, +threadInfo: ThreadInfo, }; type Props = { ...SharedProps, +threadInfo: ResolvedThreadInfo, +colors: Colors, +styles: typeof unboundStyles, }; class ThreadListThread extends React.PureComponent { render() { const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSelect = () => { this.props.onSelect(this.props.threadInfo.id); }; } const unboundStyles = { button: { alignItems: 'center', flexDirection: 'row', paddingLeft: 13, }, text: { color: 'modalForegroundLabel', fontSize: 16, paddingLeft: 9, paddingRight: 12, paddingVertical: 6, }, }; -const ConnectedThreadListThread: React.ComponentType = React.memo( - function ConnectedThreadListThread(props: BaseProps) { +const ConnectedThreadListThread: React.ComponentType = + React.memo(function ConnectedThreadListThread(props: BaseProps) { const { threadInfo, ...rest } = props; const styles = useStyles(unboundStyles); const colors = useColors(); const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); return ( ); - }, -); + }); export default ConnectedThreadListThread; diff --git a/native/components/thread-list.react.js b/native/components/thread-list.react.js index 8378ca342..d612925c2 100644 --- a/native/components/thread-list.react.js +++ b/native/components/thread-list.react.js @@ -1,151 +1,150 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { FlatList, TextInput } from 'react-native'; import { createSelector } from 'reselect'; import SearchIndex from 'lib/shared/search-index.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import Search from './search.react.js'; import ThreadListThread from './thread-list-thread.react.js'; import { type IndicatorStyle, useStyles, useIndicatorStyle, } from '../themes/colors.js'; import type { ViewStyle, TextStyle } from '../types/styles.js'; import { waitForModalInputFocus } from '../utils/timers.js'; type BaseProps = { +threadInfos: $ReadOnlyArray, +onSelect: (threadID: string) => void, +itemStyle?: ViewStyle, +itemTextStyle?: TextStyle, +searchIndex?: SearchIndex, }; type Props = { ...BaseProps, // Redux state +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, }; type State = { +searchText: string, +searchResults: Set, }; type PropsAndState = { ...Props, ...State }; class ThreadList extends React.PureComponent { state: State = { searchText: '', searchResults: new Set(), }; textInput: ?React.ElementRef; listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfos, (propsAndState: PropsAndState) => propsAndState.searchText, (propsAndState: PropsAndState) => propsAndState.searchResults, (propsAndState: PropsAndState) => propsAndState.itemStyle, (propsAndState: PropsAndState) => propsAndState.itemTextStyle, ( threadInfos: $ReadOnlyArray, text: string, searchResults: Set, ) => text ? threadInfos.filter(threadInfo => searchResults.has(threadInfo.id)) : // We spread to make sure the result of this selector updates when // any input param (namely itemStyle or itemTextStyle) changes [...threadInfos], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { let searchBar = null; if (this.props.searchIndex) { searchBar = ( ); } return ( {searchBar} ); } static keyExtractor = (threadInfo: ThreadInfo) => { return threadInfo.id; }; renderItem = (row: { item: ThreadInfo, ... }) => { return ( ); }; static getItemLayout = (data: ?$ReadOnlyArray, index: number) => { return { length: 24, offset: 24 * index, index }; }; onChangeSearchText = (searchText: string) => { invariant(this.props.searchIndex, 'should be set'); const results = this.props.searchIndex.getSearchResults(searchText); this.setState({ searchText, searchResults: new Set(results) }); }; searchRef = async (textInput: ?React.ElementRef) => { this.textInput = textInput; if (!textInput) { return; } await waitForModalInputFocus(); if (this.textInput) { this.textInput.focus(); } }; } const unboundStyles = { search: { marginBottom: 8, }, }; -const ConnectedThreadList: React.ComponentType = React.memo( - function ConnectedThreadList(props: BaseProps) { +const ConnectedThreadList: React.ComponentType = + React.memo(function ConnectedThreadList(props: BaseProps) { const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); return ( ); - }, -); + }); export default ConnectedThreadList; diff --git a/native/components/user-list-user.react.js b/native/components/user-list-user.react.js index 9b8956056..a4f7f9969 100644 --- a/native/components/user-list-user.react.js +++ b/native/components/user-list-user.react.js @@ -1,94 +1,93 @@ // @flow import * as React from 'react'; import { Text, Platform, Alert } from 'react-native'; import type { UserListItem } from 'lib/types/user-types.js'; import Button from './button.react.js'; import { SingleLine } from './single-line.react.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { TextStyle } from '../types/styles.js'; // eslint-disable-next-line no-unused-vars const getUserListItemHeight = (item: UserListItem): number => { // TODO consider parent thread notice return Platform.OS === 'ios' ? 31.5 : 33.5; }; type BaseProps = { +userInfo: UserListItem, +onSelect: (userID: string) => void, +textStyle?: TextStyle, }; type Props = { ...BaseProps, // Redux state +colors: Colors, +styles: typeof unboundStyles, }; class UserListUser extends React.PureComponent { render() { const { userInfo } = this.props; let notice = null; if (userInfo.notice) { notice = {userInfo.notice}; } const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSelect = () => { const { userInfo } = this.props; if (!userInfo.alertText) { this.props.onSelect(userInfo.id); return; } Alert.alert(userInfo.alertTitle, userInfo.alertText, [{ text: 'OK' }], { cancelable: true, }); }; } const unboundStyles = { button: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', }, notice: { color: 'modalForegroundSecondaryLabel', fontStyle: 'italic', }, text: { color: 'modalForegroundLabel', flex: 1, fontSize: 16, paddingHorizontal: 12, paddingVertical: 6, }, }; -const ConnectedUserListUser: React.ComponentType = React.memo( - function ConnectedUserListUser(props: BaseProps) { +const ConnectedUserListUser: React.ComponentType = + React.memo(function ConnectedUserListUser(props: BaseProps) { const colors = useColors(); const styles = useStyles(unboundStyles); return ; - }, -); + }); export { ConnectedUserListUser as UserListUser, getUserListItemHeight }; diff --git a/native/data/sqlite-data-handler.js b/native/data/sqlite-data-handler.js index fda093791..30cd1cdac 100644 --- a/native/data/sqlite-data-handler.js +++ b/native/data/sqlite-data-handler.js @@ -1,201 +1,197 @@ // @flow import * as React from 'react'; import { Alert } from 'react-native'; import ExitApp from 'react-native-exit-app'; import { useDispatch } from 'react-redux'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { logInActionSources, type LogInActionSource, } from 'lib/types/account-types.js'; import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { convertClientDBThreadInfosToRawThreadInfos } from 'lib/utils/thread-ops-utils.js'; import { commCoreModule } from '../native-modules.js'; import { setStoreLoadedActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { StaffContext } from '../staff/staff-context.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; function SQLiteDataHandler(): React.Node { const storeLoaded = useSelector(state => state.storeLoaded); const dispatch = useDispatch(); const rehydrateConcluded = useSelector( state => !!(state._persist && state._persist.rehydrated), ); const cookie = useSelector(state => state.cookie); const urlPrefix = useSelector(state => state.urlPrefix); const staffCanSee = useStaffCanSee(); const { staffUserHasBeenLoggedIn } = React.useContext(StaffContext); const loggedIn = useSelector(isLoggedIn); const currentLoggedInUserID = useSelector(state => state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id, ); const callFetchNewCookieFromNativeCredentials = React.useCallback( async (source: LogInActionSource) => { try { await fetchNewCookieFromNativeCredentials( dispatch, cookie, urlPrefix, source, ); dispatch({ type: setStoreLoadedActionType }); } catch (fetchCookieException) { if (staffCanSee) { Alert.alert( `Error fetching new cookie from native credentials: ${ getMessageForException(fetchCookieException) ?? '{no exception message}' }. Please kill the app.`, ); } else { ExitApp.exitApp(); } } }, [cookie, dispatch, staffCanSee, urlPrefix], ); const callClearSensitiveData = React.useCallback( async (triggeredBy: string) => { if (staffCanSee || staffUserHasBeenLoggedIn) { Alert.alert('Starting SQLite database deletion process'); } await commCoreModule.clearSensitiveData(); if (staffCanSee || staffUserHasBeenLoggedIn) { Alert.alert( 'SQLite database successfully deleted', `SQLite database deletion was triggered by ${triggeredBy}`, ); } }, [staffCanSee, staffUserHasBeenLoggedIn], ); const handleSensitiveData = React.useCallback(async () => { try { const databaseCurrentUserInfoID = await commCoreModule.getCurrentUserID(); if ( databaseCurrentUserInfoID && databaseCurrentUserInfoID !== currentLoggedInUserID ) { await callClearSensitiveData('change in logged-in user credentials'); } if (currentLoggedInUserID) { await commCoreModule.setCurrentUserID(currentLoggedInUserID); } const databaseDeviceID = await commCoreModule.getDeviceID(); if (!databaseDeviceID) { await commCoreModule.setDeviceID('MOBILE'); } } catch (e) { if (isTaskCancelledError(e)) { return; } if (__DEV__) { throw e; } else { console.log(e); ExitApp.exitApp(); } } }, [callClearSensitiveData, currentLoggedInUserID]); React.useEffect(() => { if (!rehydrateConcluded) { return; } const databaseNeedsDeletion = commCoreModule.checkIfDatabaseNeedsDeletion(); if (databaseNeedsDeletion) { (async () => { try { await callClearSensitiveData('detecting corrupted database'); } catch (e) { if (__DEV__) { throw e; } else { console.log(e); ExitApp.exitApp(); } } await callFetchNewCookieFromNativeCredentials( logInActionSources.corruptedDatabaseDeletion, ); })(); return; } const sensitiveDataHandled = handleSensitiveData(); if (storeLoaded) { return; } if (!loggedIn) { dispatch({ type: setStoreLoadedActionType }); return; } (async () => { await sensitiveDataHandled; try { - const { - threads, - messages, - drafts, - } = await commCoreModule.getClientDBStore(); - const threadInfosFromDB = convertClientDBThreadInfosToRawThreadInfos( - threads, - ); + const { threads, messages, drafts } = + await commCoreModule.getClientDBStore(); + const threadInfosFromDB = + convertClientDBThreadInfosToRawThreadInfos(threads); dispatch({ type: setClientDBStoreActionType, payload: { drafts, messages, threadStore: { threadInfos: threadInfosFromDB }, currentUserID: currentLoggedInUserID, }, }); } catch (setStoreException) { if (isTaskCancelledError(setStoreException)) { dispatch({ type: setStoreLoadedActionType }); return; } if (staffCanSee) { Alert.alert( `Error setting threadStore or messageStore: ${ getMessageForException(setStoreException) ?? '{no exception message}' }`, ); } await callFetchNewCookieFromNativeCredentials( logInActionSources.sqliteLoadFailure, ); } })(); }, [ currentLoggedInUserID, handleSensitiveData, loggedIn, cookie, dispatch, rehydrateConcluded, staffCanSee, storeLoaded, urlPrefix, staffUserHasBeenLoggedIn, callFetchNewCookieFromNativeCredentials, callClearSensitiveData, ]); return null; } export { SQLiteDataHandler }; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index 7c09c13c4..7ec3b5d22 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1431 +1,1428 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Platform } from 'react-native'; import * as Upload from 'react-native-background-upload'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, sendTextMessageActionTypes, sendTextMessage, } from 'lib/actions/message-actions.js'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { newThread } from 'lib/actions/thread-actions.js'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, type MultimediaUploadCallbacks, type MultimediaUploadExtras, } from 'lib/actions/upload-actions.js'; import { pathFromURI, replaceExtension } from 'lib/media/file-utils.js'; import { isLocalUploadID, getNextLocalUploadID, } from 'lib/media/media-utils.js'; import { videoDurationLimit } from 'lib/media/video-utils.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { createMediaMessageInfo, localIDPrefix, } from 'lib/shared/message-utils.js'; import { createRealThreadFromPendingThread, threadIsPending, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, } from 'lib/types/media-types.js'; import { messageTypes, type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, } from 'lib/types/message-types.js'; import type { RawImagesMessageInfo } from 'lib/types/messages/images.js'; import type { MediaMessageServerDBContent, RawMediaMessageInfo, } from 'lib/types/messages/media.js'; import { getMediaMessageServerDBContentsFromMedia } from 'lib/types/messages/media.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type MediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types.js'; import type { ClientNewThreadRequest, NewThreadResult, ThreadInfo, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import type { CallServerEndpointOptions, CallServerEndpointResponse, } from 'lib/utils/call-server-endpoint.js'; import { getConfig } from 'lib/utils/config.js'; import { getMessageForException, cloneError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { useIsReportEnabled } from 'lib/utils/report-utils.js'; import { InputStateContext, type PendingMultimediaUploads, type MultimediaProcessingStep, } from './input-state.js'; import { disposeTempFile } from '../media/file-utils.js'; import { processMedia } from '../media/media-utils.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import { useCalendarQuery } from '../navigation/nav-selectors.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; type MediaIDs = | { +type: 'photo', +localMediaID: string } | { +type: 'video', +localMediaID: string, +localThumbnailID: string }; type UploadFileInput = { +selection: NativeMediaSelection, +ids: MediaIDs, }; type CompletedUploads = { +[localMessageID: string]: ?Set }; type BaseProps = { +children: React.Node, }; type Props = { ...BaseProps, +viewerID: ?string, +nextLocalID: number, +messageStoreMessages: { +[id: string]: RawMessageInfo }, +ongoingMessageCreation: boolean, +hasWiFi: boolean, +mediaReportsEnabled: boolean, +calendarQuery: () => CalendarQuery, +dispatch: Dispatch, +staffCanSee: boolean, +dispatchActionPromise: DispatchActionPromise, +uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, +sendMultimediaMessage: ( threadID: string, localID: string, mediaMessageContents: $ReadOnlyArray, ) => Promise, +sendTextMessage: ( threadID: string, localID: string, text: string, ) => Promise, +newThread: (request: ClientNewThreadRequest) => Promise, }; type State = { +pendingUploads: PendingMultimediaUploads, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs = new Map(); replyCallbacks: Array<(message: string) => void> = []; pendingThreadCreations = new Map>(); static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads = {}; for (const localMessageID in state.pendingUploads) { const messagePendingUploads = state.pendingUploads[localMessageID]; const rawMessageInfo = props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); let allUploadsComplete = true; const completedUploadIDs = new Set(Object.keys(messagePendingUploads)); for (const singleMedia of rawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { allUploadsComplete = false; completedUploadIDs.delete(singleMedia.id); } } if (allUploadsComplete) { completedUploads[localMessageID] = null; } else if (completedUploadIDs.size > 0) { completedUploads[localMessageID] = completedUploadIDs; } } return completedUploads; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const currentlyComplete = InputStateContainer.getCompletedUploads( this.props, this.state, ); const previouslyComplete = InputStateContainer.getCompletedUploads( prevProps, prevState, ); const newPendingUploads = {}; let pendingUploadsChanged = false; const readyMessageIDs = []; for (const localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; const prevRawMessageInfo = prevProps.messageStoreMessages[localMessageID]; const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; const completedUploadIDs = currentlyComplete[localMessageID]; const previouslyCompletedUploadIDs = previouslyComplete[localMessageID]; if (!rawMessageInfo && prevRawMessageInfo) { pendingUploadsChanged = true; continue; } else if (completedUploadIDs === null) { // All of this message's uploads have been completed newPendingUploads[localMessageID] = {}; if (previouslyCompletedUploadIDs !== null) { readyMessageIDs.push(localMessageID); pendingUploadsChanged = true; } continue; } else if (!completedUploadIDs) { // Nothing has been completed newPendingUploads[localMessageID] = messagePendingUploads; continue; } const newUploads = {}; let uploadsChanged = false; for (const localUploadID in messagePendingUploads) { if (!completedUploadIDs.has(localUploadID)) { newUploads[localUploadID] = messagePendingUploads[localUploadID]; } else if ( !previouslyCompletedUploadIDs || !previouslyCompletedUploadIDs.has(localUploadID) ) { uploadsChanged = true; } } if (uploadsChanged) { pendingUploadsChanged = true; newPendingUploads[localMessageID] = newUploads; } else { newPendingUploads[localMessageID] = messagePendingUploads; } } if (pendingUploadsChanged) { this.setState({ pendingUploads: newPendingUploads }); } for (const localMessageID of readyMessageIDs) { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); this.dispatchMultimediaMessageAction(rawMessageInfo); } } async dispatchMultimediaMessageAction(messageInfo: RawMultimediaMessageInfo) { if (!threadIsPending(messageInfo.threadID)) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { const threadCreationPromise = this.pendingThreadCreations.get( messageInfo.threadID, ); if (!threadCreationPromise) { // When we create or retry multimedia message, we add a promise to // pendingThreadCreations map. This promise can be removed in // sendMultimediaMessage and sendTextMessage methods. When any of these // method remove the promise, it has to be settled. If the promise was // fulfilled, this method would be called with realized thread, so we // can conclude that the promise was rejected. We don't have enough info // here to retry the thread creation, but we can mark the message as // failed. Then the retry will be possible and promise will be created // again. throw new Error('Thread creation failed'); } newThreadID = await threadCreationPromise; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendMultimediaMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(messageInfo.threadID); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(newMessageInfo), undefined, newMessageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const mediaMessageContents = getMediaMessageServerDBContentsFromMedia( messageInfo.media, ); try { const result = await this.props.sendMultimediaMessage( threadID, localID, mediaMessageContents, ); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } inputStateSelector = createSelector( (state: State) => state.pendingUploads, (pendingUploads: PendingMultimediaUploads) => ({ pendingUploads, sendTextMessage: this.sendTextMessage, sendMultimediaMessage: this.sendMultimediaMessage, addReply: this.addReply, addReplyListener: this.addReplyListener, removeReplyListener: this.removeReplyListener, messageHasUploadFailure: this.messageHasUploadFailure, retryMessage: this.retryMessage, registerSendCallback: this.registerSendCallback, unregisterSendCallback: this.unregisterSendCallback, uploadInProgress: this.uploadInProgress, reportURIDisplayed: this.reportURIDisplayed, }), ); uploadInProgress = () => { if (this.props.ongoingMessageCreation) { return true; } const { pendingUploads } = this.state; return values(pendingUploads).some(messagePendingUploads => values(messagePendingUploads).some(upload => !upload.failed), ); }; sendTextMessage = async ( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); if (!threadIsPending(threadInfo.id)) { this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendTextMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { newThreadID = await this.startThreadCreation(threadInfo); } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendTextMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(threadInfo.id); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(newMessageInfo), undefined, newMessageInfo, ); }; async startThreadCreation(threadInfo: ThreadInfo): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id); if (!threadCreationPromise) { const calendarQuery = this.props.calendarQuery(); threadCreationPromise = createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise: this.props.dispatchActionPromise, createNewThread: this.props.newThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } return threadCreationPromise; } async sendTextMessageAction( messageInfo: RawTextMessageInfo, ): Promise { try { const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const result = await this.props.sendTextMessage( messageInfo.threadID, localID, messageInfo.text, ); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } sendMultimediaMessage = async ( selections: $ReadOnlyArray, threadInfo: ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const localMessageID = `${localIDPrefix}${this.props.nextLocalID}`; this.startThreadCreation(threadInfo); const uploadFileInputs = [], media = []; for (const selection of selections) { const localMediaID = getNextLocalUploadID(); let ids; if ( selection.step === 'photo_library' || selection.step === 'photo_capture' || selection.step === 'photo_paste' ) { media.push({ id: localMediaID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }); ids = { type: 'photo', localMediaID }; } const localThumbnailID = getNextLocalUploadID(); if (selection.step === 'video_library') { media.push({ id: localMediaID, uri: selection.uri, type: 'video', dimensions: selection.dimensions, localMediaSelection: selection, loop: false, thumbnailID: localThumbnailID, thumbnailURI: selection.uri, }); ids = { type: 'video', localMediaID, localThumbnailID }; } invariant(ids, `unexpected MediaSelection ${selection.step}`); uploadFileInputs.push({ selection, ids }); } const pendingUploads = {}; for (const uploadFileInput of uploadFileInputs) { const { localMediaID } = uploadFileInput.ids; pendingUploads[localMediaID] = { failed: false, progressPercent: 0, processingStep: null, }; if (uploadFileInput.ids.type === 'video') { const { localThumbnailID } = uploadFileInput.ids; pendingUploads[localThumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } this.setState( prevState => { return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, }; }, () => { const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const messageInfo = createMediaMessageInfo({ localID: localMessageID, threadID: threadInfo.id, creatorID, media, }); this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); }, ); await this.uploadFiles(localMessageID, uploadFileInputs); }; async uploadFiles( localMessageID: string, uploadFileInputs: $ReadOnlyArray, ) { const results = await Promise.all( uploadFileInputs.map(uploadFileInput => this.uploadFile(localMessageID, uploadFileInput), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, uploadFileInput: UploadFileInput, ): Promise { const { ids, selection } = uploadFileInput; const { localMediaID } = ids; const start = selection.sendTime; const steps = [selection]; let serverID; let userTime; let errorMessage; let reportPromise; const onUploadFinished = async (result: MediaMissionResult) => { if (!this.props.mediaReportsEnabled) { return errorMessage; } if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); } const totalTime = Date.now() - start; userTime = userTime ? userTime : totalTime; this.queueMediaMissionReport( { localID: localMediaID, localMessageID, serverID }, { steps, result, totalTime, userTime }, ); return errorMessage; }; const onUploadFailed = (mediaID: string, message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, mediaID); userTime = Date.now() - start; }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia( selection, this.mediaProcessConfig(localMessageID, localMediaID), ); reportPromise = processMediaReturn.reportPromise; const processResult = await processMediaReturn.resultPromise; if (!processResult.success) { const message = processResult.reason === 'video_too_long' ? `can't do vids longer than ${videoDurationLimit}min` : 'processing failed'; onUploadFailed(localMediaID, message); return await onUploadFinished(processResult); } processedMedia = processResult; } catch (e) { onUploadFailed(localMediaID, 'processing failed'); return await onUploadFinished({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } const { uploadURI, shouldDisposePath, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, uploadThumbnailResult, mediaMissionResult; try { const uploadPromises = []; uploadPromises.push( this.props.uploadMultimedia( { uri: uploadURI, name: filename, type: mime }, { ...processedMedia.dimensions, loop: processedMedia.mediaType === 'video' ? processedMedia.loop : undefined, }, { onProgress: (percent: number) => this.setProgress( localMessageID, localMediaID, 'uploading', percent, ), uploadBlob: this.uploadBlob, }, ), ); if (processedMedia.mediaType === 'video') { uploadPromises.push( this.props.uploadMultimedia( { uri: processedMedia.uploadThumbnailURI, name: replaceExtension(`thumb${filename}`, 'jpg'), type: 'image/jpeg', }, { ...processedMedia.dimensions, loop: false, }, { uploadBlob: this.uploadBlob, }, ), ); } [uploadResult, uploadThumbnailResult] = await Promise.all(uploadPromises); mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); onUploadFailed(localMediaID, 'upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if ( (processedMedia.mediaType === 'photo' && uploadResult) || (processedMedia.mediaType === 'video' && uploadResult && uploadThumbnailResult) ) { const { id, uri, dimensions, loop } = uploadResult; serverID = id; let updateMediaPayload = { messageID: localMessageID, currentMediaID: localMediaID, mediaUpdate: { id, type: uploadResult.mediaType, uri, dimensions, localMediaSelection: undefined, loop: uploadResult.mediaType === 'video' ? loop : undefined, }, }; if (processedMedia.mediaType === 'video') { invariant(uploadThumbnailResult, 'uploadThumbnailResult exists'); const { uri: thumbnailURI, id: thumbnailID } = uploadThumbnailResult; updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailURI, thumbnailID, }, }; } // When we dispatch this action, it updates Redux and triggers the // componentDidUpdate in this class. componentDidUpdate will handle // calling dispatchMultimediaMessageAction once all the uploads are // complete, and does not wait until this function concludes. this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: updateMediaPayload, }); userTime = Date.now() - start; } const processSteps = await reportPromise; reportPromise = null; steps.push(...processSteps); steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: filename, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, hasWiFi, }); const cleanupPromises = []; if (shouldDisposePath) { // If processMedia needed to do any transcoding before upload, we dispose // of the resultant temporary file here. Since the transcoded temporary // file is only used for upload, we can dispose of it after processMedia // (reportPromise) and the upload are complete cleanupPromises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); } // if there's a thumbnail we'll temporarily unlink it here // instead of in media-utils, will be changed in later diffs if (processedMedia.mediaType === 'video') { const { uploadThumbnailURI } = processedMedia; cleanupPromises.push( (async () => { - const { - steps: clearSteps, - result: thumbnailPath, - } = await this.waitForCaptureURIUnload(uploadThumbnailURI); + const { steps: clearSteps, result: thumbnailPath } = + await this.waitForCaptureURIUnload(uploadThumbnailURI); steps.push(...clearSteps); if (!thumbnailPath) { return; } const disposeStep = await disposeTempFile(thumbnailPath); steps.push(disposeStep); })(), ); } if (selection.captureTime || selection.step === 'photo_paste') { // If we are uploading a newly captured photo, we dispose of the original // file here. Note that we try to save photo captures to the camera roll // if we have permission. Even if we fail, this temporary file isn't // visible to the user, so there's no point in keeping it around. Since // the initial URI is used in rendering paths, we have to wait for it to // be replaced with the remote URI before we can dispose. Check out the // Multimedia component to see how the URIs get switched out. const captureURI = selection.uri; cleanupPromises.push( (async () => { - const { - steps: clearSteps, - result: capturePath, - } = await this.waitForCaptureURIUnload(captureURI); + const { steps: clearSteps, result: capturePath } = + await this.waitForCaptureURIUnload(captureURI); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(cleanupPromises); return await onUploadFinished(mediaMissionResult); } mediaProcessConfig(localMessageID: string, localID: string) { const { hasWiFi, staffCanSee } = this.props; const onTranscodingProgress = (percent: number) => { this.setProgress(localMessageID, localID, 'transcoding', percent); }; if (staffCanSee) { return { hasWiFi, finalFileHeaderCheck: true, onTranscodingProgress, }; } return { hasWiFi, onTranscodingProgress }; } setProgress( localMessageID: string, localUploadID: string, processingStep: MultimediaProcessingStep, progressPercent: number, ) { this.setState(prevState => { const pendingUploads = prevState.pendingUploads[localMessageID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newOutOfHundred = Math.floor(progressPercent * 100); const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100); if (newOutOfHundred === oldOutOfHundred) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, processingStep, }, }; return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newPendingUploads, }, }; }); } uploadBlob = async ( url: string, cookie: ?string, sessionID: ?string, input: { [key: string]: mixed }, options?: ?CallServerEndpointOptions, ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.uploadBlob sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.uploadBlob sent incorrect input', ); const parameters = {}; parameters.cookie = cookie; parameters.filename = name; for (const key in input) { if ( key === 'multimedia' || key === 'cookie' || key === 'sessionID' || key === 'filename' ) { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); parameters[key] = value; } let path = uri; if (Platform.OS === 'android') { const resolvedPath = pathFromURI(uri); if (resolvedPath) { path = resolvedPath; } } const uploadID = await Upload.startUpload({ url, path, type: 'multipart', headers: { Accept: 'application/json', }, field: 'multimedia', parameters, }); if (options && options.abortHandler) { options.abortHandler(() => { Upload.cancelUpload(uploadID); }); } return await new Promise((resolve, reject) => { Upload.addListener('error', uploadID, data => { reject(data.error); }); Upload.addListener('cancelled', uploadID, () => { reject(new Error('request aborted')); }); Upload.addListener('completed', uploadID, data => { try { resolve(JSON.parse(data.responseBody)); } catch (e) { reject(e); } }); if (options && options.onProgress) { const { onProgress } = options; Upload.addListener('progress', uploadID, data => onProgress(data.progress / 100), ); } }); }; handleUploadFailure(localMessageID: string, localUploadID: string) { this.setState(prevState => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: { ...uploads, [localUploadID]: { ...upload, failed: true, progressPercent: 0, }, }, }, }; }); } queueMediaMissionReport( ids: { localID: string, localMessageID: string, serverID: ?string }, mediaMission: MediaMission, ) { const report: MediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: ids.serverID, uploadLocalID: ids.localID, messageLocalID: ids.localMessageID, }; this.props.dispatch({ type: queueReportsActionType, payload: { reports: [report], }, }); } messageHasUploadFailure = (localMessageID: string) => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } return values(pendingUploads).some(upload => upload.failed); }; addReply = (message: string) => { this.replyCallbacks.forEach(addReplyCallback => addReplyCallback(message)); }; addReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks.push(callbackReply); }; removeReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks = this.replyCallbacks.filter( candidate => candidate !== callbackReply, ); }; retryTextMessage = async ( rawMessageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, ) => { await this.sendTextMessage( { ...rawMessageInfo, time: Date.now(), }, threadInfo, ); }; retryMultimediaMessage = async ( rawMessageInfo: RawMultimediaMessageInfo, localMessageID: string, threadInfo: ThreadInfo, ) => { const pendingUploads = this.state.pendingUploads[localMessageID] ?? {}; const now = Date.now(); this.startThreadCreation(threadInfo); const updateMedia = (media: $ReadOnlyArray): T[] => media.map(singleMedia => { let updatedMedia = singleMedia; const oldMediaID = updatedMedia.id; if ( // not complete isLocalUploadID(oldMediaID) && // not still ongoing (!pendingUploads[oldMediaID] || pendingUploads[oldMediaID].failed) ) { // If we have an incomplete upload that isn't in pendingUploads, that // indicates the app has restarted. We'll reassign a new localID to // avoid collisions. Note that this isn't necessary for the message ID // since the localID reducer prevents collisions there const mediaID = pendingUploads[oldMediaID] ? oldMediaID : getNextLocalUploadID(); if (updatedMedia.type === 'photo') { updatedMedia = { type: 'photo', ...updatedMedia, id: mediaID, }; } else { updatedMedia = { type: 'video', ...updatedMedia, id: mediaID, }; } } if (updatedMedia.type === 'video') { const oldThumbnailID = updatedMedia.thumbnailID; invariant(oldThumbnailID, 'oldThumbnailID not null or undefined'); if ( // not complete isLocalUploadID(oldThumbnailID) && // not still ongoing (!pendingUploads[oldThumbnailID] || pendingUploads[oldThumbnailID].failed) ) { const thumbnailID = pendingUploads[oldThumbnailID] ? oldThumbnailID : getNextLocalUploadID(); updatedMedia = { ...updatedMedia, thumbnailID, }; } } if (updatedMedia === singleMedia) { return singleMedia; } const oldSelection = updatedMedia.localMediaSelection; invariant( oldSelection, 'localMediaSelection should be set on locally created Media', ); const retries = oldSelection.retries ? oldSelection.retries + 1 : 1; // We switch for Flow let selection; if (oldSelection.step === 'photo_capture') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_library') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_paste') { selection = { ...oldSelection, sendTime: now, retries }; } else { selection = { ...oldSelection, sendTime: now, retries }; } if (updatedMedia.type === 'photo') { return { type: 'photo', ...updatedMedia, localMediaSelection: selection, }; } return { type: 'video', ...updatedMedia, localMediaSelection: selection, }; }); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawMediaMessageInfo); } else if (rawMessageInfo.type === messageTypes.IMAGES) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawImagesMessageInfo); } else { invariant(false, `rawMessageInfo ${localMessageID} should be multimedia`); } const incompleteMedia: Media[] = []; for (const singleMedia of newRawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { incompleteMedia.push(singleMedia); } } if (incompleteMedia.length === 0) { this.dispatchMultimediaMessageAction(newRawMessageInfo); this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: {}, }, })); return; } const retryMedia = incompleteMedia.filter( ({ id }) => !pendingUploads[id] || pendingUploads[id].failed, ); if (retryMedia.length === 0) { // All media are already in the process of being uploaded return; } // We're not actually starting the send here, // we just use this action to update the message in Redux this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: newRawMessageInfo, }); // We clear out the failed status on individual media here, // which makes the UI show pending status instead of error messages for (const singleMedia of retryMedia) { pendingUploads[singleMedia.id] = { failed: false, progressPercent: 0, processingStep: null, }; if (singleMedia.type === 'video') { const { thumbnailID } = singleMedia; invariant(thumbnailID, 'thumbnailID not null or undefined'); pendingUploads[thumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, })); const uploadFileInputs = retryMedia.map(singleMedia => { invariant( singleMedia.localMediaSelection, 'localMediaSelection should be set on locally created Media', ); let ids; if (singleMedia.type === 'photo') { ids = { type: 'photo', localMediaID: singleMedia.id }; } else { invariant( singleMedia.thumbnailID, 'singleMedia.thumbnailID should be set for videos', ); ids = { type: 'video', localMediaID: singleMedia.id, localThumbnailID: singleMedia.thumbnailID, }; } return { selection: singleMedia.localMediaSelection, ids, }; }); await this.uploadFiles(localMessageID, uploadFileInputs); }; retryMessage = async (localMessageID: string, threadInfo: ThreadInfo) => { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); if (rawMessageInfo.type === messageTypes.TEXT) { await this.retryTextMessage(rawMessageInfo, threadInfo); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { await this.retryMultimediaMessage( rawMessageInfo, localMessageID, threadInfo, ); } }; registerSendCallback = (callback: () => void) => { this.sendCallbacks.push(callback); }; unregisterSendCallback = (callback: () => void) => { this.sendCallbacks = this.sendCallbacks.filter( candidate => candidate !== callback, ); }; reportURIDisplayed = (uri: string, loaded: boolean) => { const prevActiveURI = this.activeURIs.get(uri); const curCount = prevActiveURI && prevActiveURI.count; const prevCount = curCount ? curCount : 0; const count = loaded ? prevCount + 1 : prevCount - 1; const prevOnClear = prevActiveURI && prevActiveURI.onClear; const onClear = prevOnClear ? prevOnClear : []; const activeURI = { count, onClear }; if (count) { this.activeURIs.set(uri, activeURI); return; } this.activeURIs.delete(uri); for (const callback of onClear) { callback(); } }; waitForCaptureURIUnload(uri: string) { const start = Date.now(); const path = pathFromURI(uri); if (!path) { return Promise.resolve({ result: null, steps: [ { step: 'wait_for_capture_uri_unload', success: false, time: Date.now() - start, uri, }, ], }); } const getResult = () => ({ result: path, steps: [ { step: 'wait_for_capture_uri_unload', success: true, time: Date.now() - start, uri, }, ], }); const activeURI = this.activeURIs.get(uri); if (!activeURI) { return Promise.resolve(getResult()); } return new Promise(resolve => { const finish = () => resolve(getResult()); const newActiveURI = { ...activeURI, onClear: [...activeURI.onClear, finish], }; this.activeURIs.set(uri, newActiveURI); }); } render() { const inputState = this.inputStateSelector(this.state); return ( {this.props.children} ); } } const mediaCreationLoadingStatusSelector = createLoadingStatusSelector( sendMultimediaMessageActionTypes, ); const textCreationLoadingStatusSelector = createLoadingStatusSelector( sendTextMessageActionTypes, ); -const ConnectedInputStateContainer: React.ComponentType = React.memo( - function ConnectedInputStateContainer(props: BaseProps) { +const ConnectedInputStateContainer: React.ComponentType = + React.memo(function ConnectedInputStateContainer( + props: BaseProps, + ) { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nextLocalID = useSelector(state => state.nextLocalID); const messageStoreMessages = useSelector( state => state.messageStore.messages, ); const ongoingMessageCreation = useSelector( state => combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', ); const hasWiFi = useSelector(state => state.connectivity.hasWiFi); const calendarQuery = useCalendarQuery(); const callUploadMultimedia = useServerCall(uploadMultimedia); const callSendMultimediaMessage = useServerCall(sendMultimediaMessage); const callSendTextMessage = useServerCall(sendTextMessage); const callNewThread = useServerCall(newThread); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); const mediaReportsEnabled = useIsReportEnabled('mediaReports'); const staffCanSee = useStaffCanSee(); return ( ); - }, -); + }); export default ConnectedInputStateContainer; diff --git a/native/input/input-state.js b/native/input/input-state.js index a523190b9..c5e605ca7 100644 --- a/native/input/input-state.js +++ b/native/input/input-state.js @@ -1,53 +1,52 @@ // @flow import * as React from 'react'; import type { NativeMediaSelection } from 'lib/types/media-types.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; export type MultimediaProcessingStep = 'transcoding' | 'uploading'; export type PendingMultimediaUpload = { +failed: boolean, +progressPercent: number, +processingStep: ?MultimediaProcessingStep, }; export type MessagePendingUploads = { [localUploadID: string]: PendingMultimediaUpload, }; export type PendingMultimediaUploads = { [localMessageID: string]: MessagePendingUploads, }; export type InputState = { +pendingUploads: PendingMultimediaUploads, +sendTextMessage: ( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, ) => Promise, +sendMultimediaMessage: ( selections: $ReadOnlyArray, threadInfo: ThreadInfo, ) => Promise, +addReply: (text: string) => void, +addReplyListener: ((message: string) => void) => void, +removeReplyListener: ((message: string) => void) => void, +messageHasUploadFailure: (localMessageID: string) => boolean, +retryMessage: ( localMessageID: string, threadInfo: ThreadInfo, ) => Promise, +registerSendCallback: (() => void) => void, +unregisterSendCallback: (() => void) => void, +uploadInProgress: () => boolean, +reportURIDisplayed: (uri: string, loaded: boolean) => void, }; -const InputStateContext: React.Context = React.createContext( - null, -); +const InputStateContext: React.Context = + React.createContext(null); export { InputStateContext }; diff --git a/native/keyboard/keyboard-input-host.react.js b/native/keyboard/keyboard-input-host.react.js index b692beaa0..a013876da 100644 --- a/native/keyboard/keyboard-input-host.react.js +++ b/native/keyboard/keyboard-input-host.react.js @@ -1,122 +1,121 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { TextInput } from 'react-native'; import { KeyboardAccessoryView } from 'react-native-keyboard-input'; import type { MediaLibrarySelection } from 'lib/types/media-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { type KeyboardState, KeyboardContext } from './keyboard-state.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { mediaGalleryKeyboardName } from '../media/media-gallery-keyboard.react.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { useStyles } from '../themes/colors.js'; type BaseProps = { +textInputRef?: ?React.ElementRef, }; type Props = { ...BaseProps, // Redux state +styles: typeof unboundStyles, +activeMessageList: ?string, // withKeyboardState +keyboardState: KeyboardState, // withInputState +inputState: ?InputState, }; class KeyboardInputHost extends React.PureComponent { componentDidUpdate(prevProps: Props) { if ( prevProps.activeMessageList && this.props.activeMessageList !== prevProps.activeMessageList ) { this.hideMediaGallery(); } } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } render() { const kbComponent = KeyboardInputHost.mediaGalleryOpen(this.props) ? mediaGalleryKeyboardName : null; const kbInitialProps = { ...this.props.styles.kbInitialProps, threadInfo: this.props.keyboardState.getMediaGalleryThread(), }; return ( ); } onMediaGalleryItemSelected = async ( keyboardName: string, result: { +selections: $ReadOnlyArray, +threadInfo: ?ThreadInfo, }, ) => { const { keyboardState } = this.props; keyboardState.dismissKeyboard(); const { selections, threadInfo: mediaGalleryThread } = result; if (!mediaGalleryThread) { return; } const { inputState } = this.props; invariant( inputState, 'inputState should be set in onMediaGalleryItemSelected', ); inputState.sendMultimediaMessage(selections, mediaGalleryThread); }; hideMediaGallery = () => { const { keyboardState } = this.props; keyboardState.hideMediaGallery(); }; } const unboundStyles = { kbInitialProps: { backgroundColor: 'listBackground', }, }; -const ConnectedKeyboardInputHost: React.ComponentType = React.memo( - function ConnectedKeyboardInputHost(props: BaseProps) { +const ConnectedKeyboardInputHost: React.ComponentType = + React.memo(function ConnectedKeyboardInputHost(props: BaseProps) { const inputState = React.useContext(InputStateContext); const keyboardState = React.useContext(KeyboardContext); invariant(keyboardState, 'keyboardState should be initialized'); const navContext = React.useContext(NavContext); const styles = useStyles(unboundStyles); const activeMessageList = activeMessageListSelector(navContext); return ( ); - }, -); + }); export default ConnectedKeyboardInputHost; diff --git a/native/keyboard/keyboard-state-container.react.js b/native/keyboard/keyboard-state-container.react.js index f86f09aeb..46aef59ef 100644 --- a/native/keyboard/keyboard-state-container.react.js +++ b/native/keyboard/keyboard-state-container.react.js @@ -1,150 +1,147 @@ // @flow import * as React from 'react'; import { Platform } from 'react-native'; import { KeyboardUtils } from 'react-native-keyboard-input'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import sleep from 'lib/utils/sleep.js'; import KeyboardInputHost from './keyboard-input-host.react.js'; import { KeyboardContext } from './keyboard-state.js'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from './keyboard.js'; import { tabBarAnimationDuration } from '../navigation/tab-bar.react.js'; import { waitForInteractions } from '../utils/timers.js'; type Props = { +children: React.Node, }; type State = { +systemKeyboardShowing: boolean, +mediaGalleryOpen: boolean, +mediaGalleryThread: ?ThreadInfo, +renderKeyboardInputHost: boolean, }; class KeyboardStateContainer extends React.PureComponent { state: State = { systemKeyboardShowing: false, mediaGalleryOpen: false, mediaGalleryThread: null, renderKeyboardInputHost: false, }; keyboardShowListener: ?Object; keyboardDismissListener: ?Object; keyboardShow: () => void = () => { this.setState({ systemKeyboardShowing: true }); }; keyboardDismiss: () => void = () => { this.setState({ systemKeyboardShowing: false }); }; componentDidMount() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); } componentWillUnmount() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } } componentDidUpdate(prevProps: Props, prevState: State) { if (Platform.OS !== 'android') { return; } if (this.state.mediaGalleryOpen && !prevState.mediaGalleryOpen) { (async () => { await sleep(tabBarAnimationDuration); await waitForInteractions(); this.setState({ renderKeyboardInputHost: true }); })(); } } dismissKeyboard: () => void = () => { KeyboardUtils.dismiss(); this.hideMediaGallery(); }; dismissKeyboardIfShowing: () => boolean = () => { if (!this.keyboardShowing) { return false; } this.dismissKeyboard(); return true; }; get keyboardShowing(): boolean { const { systemKeyboardShowing, mediaGalleryOpen } = this.state; return systemKeyboardShowing || mediaGalleryOpen; } showMediaGallery: (thread: ThreadInfo) => void = (thread: ThreadInfo) => { this.setState({ mediaGalleryOpen: true, mediaGalleryThread: thread, }); }; hideMediaGallery: () => void = () => { this.setState({ mediaGalleryOpen: false, mediaGalleryThread: null, renderKeyboardInputHost: false, }); }; getMediaGalleryThread: () => ?ThreadInfo = () => this.state.mediaGalleryThread; render(): React.Node { - const { - systemKeyboardShowing, - mediaGalleryOpen, - renderKeyboardInputHost, - } = this.state; + const { systemKeyboardShowing, mediaGalleryOpen, renderKeyboardInputHost } = + this.state; const { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, showMediaGallery, hideMediaGallery, getMediaGalleryThread, } = this; const keyboardState = { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, systemKeyboardShowing, mediaGalleryOpen, showMediaGallery, hideMediaGallery, getMediaGalleryThread, }; const keyboardInputHost = renderKeyboardInputHost ? ( ) : null; return ( {this.props.children} {keyboardInputHost} ); } } export default KeyboardStateContainer; diff --git a/native/keyboard/keyboard-state.js b/native/keyboard/keyboard-state.js index 50ae9d716..3ac97b881 100644 --- a/native/keyboard/keyboard-state.js +++ b/native/keyboard/keyboard-state.js @@ -1,22 +1,21 @@ // @flow import * as React from 'react'; import type { ThreadInfo } from 'lib/types/thread-types.js'; export type KeyboardState = { +keyboardShowing: boolean, +dismissKeyboard: () => void, +dismissKeyboardIfShowing: () => boolean, +systemKeyboardShowing: boolean, +mediaGalleryOpen: boolean, +showMediaGallery: (thread: ThreadInfo) => void, +hideMediaGallery: () => void, +getMediaGalleryThread: () => ?ThreadInfo, }; -const KeyboardContext: React.Context = React.createContext( - null, -); +const KeyboardContext: React.Context = + React.createContext(null); export { KeyboardContext }; diff --git a/native/markdown/markdown-context.js b/native/markdown/markdown-context.js index cf33b3a84..550c6fee0 100644 --- a/native/markdown/markdown-context.js +++ b/native/markdown/markdown-context.js @@ -1,19 +1,18 @@ // @flow import * as React from 'react'; import type { SetState } from 'lib/types/hook-types.js'; export type MarkdownContextType = { +setLinkModalActive: SetState<{ [key: string]: boolean }>, +linkModalActive: { [key: string]: boolean }, +setSpoilerRevealed: SetState<{ [key: string]: { [key: number]: boolean } }>, +spoilerRevealed: { [key: string]: { [key: number]: boolean } }, +clearMarkdownContextData: () => void, }; -const MarkdownContext: React.Context = React.createContext( - null, -); +const MarkdownContext: React.Context = + React.createContext(null); export { MarkdownContext }; diff --git a/native/markdown/markdown-spoiler-context.js b/native/markdown/markdown-spoiler-context.js index d6359773a..a7e50fa2e 100644 --- a/native/markdown/markdown-spoiler-context.js +++ b/native/markdown/markdown-spoiler-context.js @@ -1,13 +1,12 @@ // @flow import * as React from 'react'; export type MarkdownSpoilerContextType = { +isRevealed: boolean, }; -const MarkdownSpoilerContext: React.Context = React.createContext( - null, -); +const MarkdownSpoilerContext: React.Context = + React.createContext(null); export { MarkdownSpoilerContext }; diff --git a/native/media/blob-utils.js b/native/media/blob-utils.js index 8069cfba3..dafc06bc3 100644 --- a/native/media/blob-utils.js +++ b/native/media/blob-utils.js @@ -1,151 +1,149 @@ // @flow import base64 from 'base-64'; import invariant from 'invariant'; import { fileInfoFromData, bytesNeededForFileTypeCheck, } from 'lib/media/file-utils.js'; import type { MediaMissionStep, MediaMissionFailure, } from 'lib/types/media-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { getFetchableURI } from './identifier-utils.js'; function blobToDataURI(blob: Blob): Promise { const fileReader = new FileReader(); return new Promise((resolve, reject) => { fileReader.onerror = error => { fileReader.abort(); reject(error); }; fileReader.onload = () => { invariant( typeof fileReader.result === 'string', 'FileReader.readAsDataURL should result in string', ); resolve(fileReader.result); }; fileReader.readAsDataURL(blob); }); } const base64CharsNeeded = 4 * Math.ceil(bytesNeededForFileTypeCheck / 3); function dataURIToIntArray(dataURI: string): Uint8Array { const uri = dataURI.replace(/\r?\n/g, ''); const firstComma = uri.indexOf(','); if (firstComma <= 4) { throw new TypeError('malformed data-URI'); } const meta = uri.substring(5, firstComma).split(';'); const base64Encoded = meta.some(metum => metum === 'base64'); let data = unescape(uri.substr(firstComma + 1, base64CharsNeeded)); if (base64Encoded) { data = base64.decode(data); } return stringToIntArray(data); } function stringToIntArray(str: string): Uint8Array { const array = new Uint8Array(str.length); for (let i = 0; i < str.length; i++) { array[i] = str.charCodeAt(i); } return array; } type FetchBlobResult = { success: true, base64: string, mime: string, }; -async function fetchBlob( - inputURI: string, -): Promise<{ +async function fetchBlob(inputURI: string): Promise<{ steps: $ReadOnlyArray, result: MediaMissionFailure | FetchBlobResult, }> { const uri = getFetchableURI(inputURI); const steps = []; let blob, fetchExceptionMessage; const fetchStart = Date.now(); try { const response = await fetch(uri); blob = await response.blob(); } catch (e) { fetchExceptionMessage = getMessageForException(e); } steps.push({ step: 'fetch_blob', success: !!blob, exceptionMessage: fetchExceptionMessage, time: Date.now() - fetchStart, inputURI, uri, size: blob && blob.size, mime: blob && blob.type, }); if (!blob) { return { result: { success: false, reason: 'fetch_failed' }, steps }; } let dataURI, dataURIExceptionMessage; const dataURIStart = Date.now(); try { dataURI = await blobToDataURI(blob); } catch (e) { dataURIExceptionMessage = getMessageForException(e); } steps.push({ step: 'data_uri_from_blob', success: !!dataURI, exceptionMessage: dataURIExceptionMessage, time: Date.now() - dataURIStart, first255Chars: dataURI && dataURI.substring(0, 255), }); if (!dataURI) { return { result: { success: false, reason: 'data_uri_failed' }, steps }; } const firstComma = dataURI.indexOf(','); invariant(firstComma > 4, 'malformed data-URI'); const base64String = dataURI.substring(firstComma + 1); let mime = blob.type; if (!mime) { let mimeCheckExceptionMessage; const mimeCheckStart = Date.now(); try { const intArray = dataURIToIntArray(dataURI); ({ mime } = fileInfoFromData(intArray)); } catch (e) { mimeCheckExceptionMessage = getMessageForException(e); } steps.push({ step: 'mime_check', success: !!mime, exceptionMessage: mimeCheckExceptionMessage, time: Date.now() - mimeCheckStart, mime, }); } if (!mime) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } return { result: { success: true, base64: base64String, mime }, steps }; } export { stringToIntArray, fetchBlob }; diff --git a/native/media/camera-modal.react.js b/native/media/camera-modal.react.js index 30374a70f..3dc2b0f80 100644 --- a/native/media/camera-modal.react.js +++ b/native/media/camera-modal.react.js @@ -1,1209 +1,1206 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Image, Animated, Easing, } from 'react-native'; import { RNCamera } from 'react-native-camera'; import filesystem from 'react-native-fs'; import { PinchGestureHandler, TapGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import type { Orientations } from 'react-native-orientation-locker'; import Reanimated, { EasingNode as ReanimatedEasing, } from 'react-native-reanimated'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useDispatch } from 'react-redux'; import { pathFromURI, filenameFromPathOrURI } from 'lib/media/file-utils.js'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils.js'; import type { PhotoCapture } from 'lib/types/media-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import SendMediaButton from './send-media-button.react.js'; import ContentLoading from '../components/content-loading.react.js'; import ConnectedStatusBar from '../connected-status-bar.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { updateDeviceCameraInfoActionType } from '../redux/action-types.js'; import { type DimensionsInfo } from '../redux/dimensions-updater.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { colors } from '../themes/colors.js'; import { type DeviceCameraInfo } from '../types/camera.js'; import type { NativeMethods } from '../types/react-native.js'; import { AnimatedView, type ViewStyle, type AnimatedViewStyle, } from '../types/styles.js'; import { clamp, gestureJustEnded } from '../utils/animation-utils.js'; /* eslint-disable import/no-named-as-default-member */ const { Value, Node, Clock, event, Extrapolate, block, set, call, cond, not, and, or, eq, greaterThan, lessThan, add, sub, multiply, divide, abs, interpolateNode, startClock, stopClock, clockRunning, timing, spring, SpringUtils, } = Reanimated; /* eslint-enable import/no-named-as-default-member */ const maxZoom = 16; const zoomUpdateFactor = (() => { if (Platform.OS === 'ios') { return 0.002; } if (Platform.OS === 'android' && Platform.Version > 26) { return 0.005; } if (Platform.OS === 'android' && Platform.Version > 23) { return 0.01; } return 0.03; })(); const stagingModeAnimationConfig = { duration: 150, easing: ReanimatedEasing.inOut(ReanimatedEasing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; const indicatorSpringConfig = { ...SpringUtils.makeDefaultConfig(), damping: 0, mass: 0.6, toValue: 1, }; const indicatorTimingConfig = { duration: 500, easing: ReanimatedEasing.out(ReanimatedEasing.ease), toValue: 0, }; function runIndicatorAnimation( // Inputs springClock: Clock, delayClock: Clock, timingClock: Clock, animationRunning: Node, // Outputs scale: Value, opacity: Value, ): Node { const delayStart = new Value(0); const springScale = new Value(0.75); const delayScale = new Value(0); const timingScale = new Value(0.75); const animatedScale = cond( clockRunning(springClock), springScale, cond(clockRunning(delayClock), delayScale, timingScale), ); const lastAnimatedScale = new Value(0.75); const numScaleLoops = new Value(0); const springState = { finished: new Value(1), velocity: new Value(0), time: new Value(0), position: springScale, }; const timingState = { finished: new Value(1), frameTime: new Value(0), time: new Value(0), position: timingScale, }; return block([ cond(not(animationRunning), [ set(springState.finished, 0), set(springState.velocity, 0), set(springState.time, 0), set(springScale, 0.75), set(lastAnimatedScale, 0.75), set(numScaleLoops, 0), set(opacity, 1), startClock(springClock), ]), [ cond( clockRunning(springClock), spring(springClock, springState, indicatorSpringConfig), ), timing(timingClock, timingState, indicatorTimingConfig), ], [ cond( and( greaterThan(animatedScale, 1.2), not(greaterThan(lastAnimatedScale, 1.2)), ), [ set(numScaleLoops, add(numScaleLoops, 1)), cond(greaterThan(numScaleLoops, 1), [ set(springState.finished, 1), stopClock(springClock), set(delayScale, springScale), set(delayStart, delayClock), startClock(delayClock), ]), ], ), set(lastAnimatedScale, animatedScale), ], cond( and( clockRunning(delayClock), greaterThan(delayClock, add(delayStart, 400)), ), [ stopClock(delayClock), set(timingState.finished, 0), set(timingState.frameTime, 0), set(timingState.time, 0), set(timingScale, delayScale), startClock(timingClock), ], ), cond( and(springState.finished, timingState.finished), stopClock(timingClock), ), set(scale, animatedScale), cond(clockRunning(timingClock), set(opacity, clamp(animatedScale, 0, 1))), ]); } export type CameraModalParams = { +presentedFrom: string, +thread: ThreadInfo, }; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; type BaseProps = { +navigation: AppNavigationProp<'CameraModal'>, +route: NavigationRoute<'CameraModal'>, }; type Props = { ...BaseProps, // Redux state +dimensions: DimensionsInfo, +deviceCameraInfo: DeviceCameraInfo, +deviceOrientation: Orientations, +foreground: boolean, // Redux dispatch functions +dispatch: Dispatch, // withInputState +inputState: ?InputState, // withOverlayContext +overlayContext: ?OverlayContextType, }; type State = { +zoom: number, +useFrontCamera: boolean, +hasCamerasOnBothSides: boolean, +flashMode: number, +autoFocusPointOfInterest: ?{ x: number, y: number, autoExposure?: boolean, }, +stagingMode: boolean, +pendingPhotoCapture: ?PhotoCapture, }; class CameraModal extends React.PureComponent { camera: ?RNCamera; pinchEvent; pinchHandler = React.createRef(); tapEvent; tapHandler = React.createRef(); animationCode: Node; closeButton: ?React.ElementRef; closeButtonX = new Value(-1); closeButtonY = new Value(-1); closeButtonWidth = new Value(0); closeButtonHeight = new Value(0); photoButton: ?React.ElementRef; photoButtonX = new Value(-1); photoButtonY = new Value(-1); photoButtonWidth = new Value(0); photoButtonHeight = new Value(0); switchCameraButton: ?React.ElementRef; switchCameraButtonX = new Value(-1); switchCameraButtonY = new Value(-1); switchCameraButtonWidth = new Value(0); switchCameraButtonHeight = new Value(0); flashButton: ?React.ElementRef; flashButtonX = new Value(-1); flashButtonY = new Value(-1); flashButtonWidth = new Value(0); flashButtonHeight = new Value(0); focusIndicatorX = new Value(-1); focusIndicatorY = new Value(-1); focusIndicatorScale = new Value(0); focusIndicatorOpacity = new Value(0); cancelIndicatorAnimation = new Value(0); cameraIDsFetched = false; stagingModeProgress = new Value(0); sendButtonProgress = new Animated.Value(0); sendButtonStyle: ViewStyle; overlayStyle: AnimatedViewStyle; constructor(props: Props) { super(props); this.state = { zoom: 0, useFrontCamera: props.deviceCameraInfo.defaultUseFrontCamera, hasCamerasOnBothSides: props.deviceCameraInfo.hasCamerasOnBothSides, flashMode: RNCamera.Constants.FlashMode.off, autoFocusPointOfInterest: undefined, stagingMode: false, pendingPhotoCapture: undefined, }; const sendButtonScale = this.sendButtonProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.1, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.sendButtonProgress, transform: [{ scale: sendButtonScale }], }; const overlayOpacity = interpolateNode(this.stagingModeProgress, { inputRange: [0, 0.01, 1], outputRange: [0, 0.5, 0], extrapolate: Extrapolate.CLAMP, }); this.overlayStyle = { ...styles.overlay, opacity: overlayOpacity, }; const pinchState = new Value(-1); const pinchScale = new Value(1); this.pinchEvent = event([ { nativeEvent: { state: pinchState, scale: pinchScale, }, }, ]); const tapState = new Value(-1); const tapX = new Value(0); const tapY = new Value(0); this.tapEvent = event([ { nativeEvent: { state: tapState, x: tapX, y: tapY, }, }, ]); this.animationCode = block([ this.zoomAnimationCode(pinchState, pinchScale), this.focusAnimationCode(tapState, tapX, tapY), ]); } zoomAnimationCode(pinchState: Node, pinchScale: Node): Node { const pinchJustEnded = gestureJustEnded(pinchState); const zoomBase = new Value(1); const zoomReported = new Value(1); const currentZoom = interpolateNode(multiply(zoomBase, pinchScale), { inputRange: [1, 8], outputRange: [1, 8], extrapolate: Extrapolate.CLAMP, }); const cameraZoomFactor = interpolateNode(zoomReported, { inputRange: [1, 8], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const resolvedZoom = cond( eq(pinchState, GestureState.ACTIVE), currentZoom, zoomBase, ); return block([ cond(pinchJustEnded, set(zoomBase, currentZoom)), cond( or( pinchJustEnded, greaterThan( abs(sub(divide(resolvedZoom, zoomReported), 1)), zoomUpdateFactor, ), ), [ set(zoomReported, resolvedZoom), call([cameraZoomFactor], this.updateZoom), ], ), ]); } focusAnimationCode(tapState: Node, tapX: Node, tapY: Node): Node { const lastTapX = new Value(0); const lastTapY = new Value(0); const fingerJustReleased = and( gestureJustEnded(tapState), this.outsideButtons(lastTapX, lastTapY), ); const indicatorSpringClock = new Clock(); const indicatorDelayClock = new Clock(); const indicatorTimingClock = new Clock(); const indicatorAnimationRunning = or( clockRunning(indicatorSpringClock), clockRunning(indicatorDelayClock), clockRunning(indicatorTimingClock), ); return block([ cond(fingerJustReleased, [ call([tapX, tapY], this.focusOnPoint), set(this.focusIndicatorX, tapX), set(this.focusIndicatorY, tapY), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), ]), cond(this.cancelIndicatorAnimation, [ set(this.cancelIndicatorAnimation, 0), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), set(this.focusIndicatorOpacity, 0), ]), cond( or(fingerJustReleased, indicatorAnimationRunning), runIndicatorAnimation( indicatorSpringClock, indicatorDelayClock, indicatorTimingClock, indicatorAnimationRunning, this.focusIndicatorScale, this.focusIndicatorOpacity, ), ), set(lastTapX, tapX), set(lastTapY, tapY), ]); } outsideButtons(x: Node, y: Node): Node { const { closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, photoButtonX, photoButtonY, photoButtonWidth, photoButtonHeight, switchCameraButtonX, switchCameraButtonY, switchCameraButtonWidth, switchCameraButtonHeight, flashButtonX, flashButtonY, flashButtonWidth, flashButtonHeight, } = this; return and( or( lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( lessThan(x, photoButtonX), greaterThan(x, add(photoButtonX, photoButtonWidth)), lessThan(y, photoButtonY), greaterThan(y, add(photoButtonY, photoButtonHeight)), ), or( lessThan(x, switchCameraButtonX), greaterThan(x, add(switchCameraButtonX, switchCameraButtonWidth)), lessThan(y, switchCameraButtonY), greaterThan(y, add(switchCameraButtonY, switchCameraButtonHeight)), ), or( lessThan(x, flashButtonX), greaterThan(x, add(flashButtonX, flashButtonWidth)), lessThan(y, flashButtonY), greaterThan(y, add(flashButtonY, flashButtonHeight)), ), ); } static isActive(props) { const { overlayContext } = props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return !overlayContext.isDismissing; } componentDidMount() { if (CameraModal.isActive(this.props)) { Orientation.unlockAllOrientations(); } } componentWillUnmount() { if (CameraModal.isActive(this.props)) { Orientation.lockToPortrait(); } } componentDidUpdate(prevProps: Props, prevState: State) { const isActive = CameraModal.isActive(this.props); const wasActive = CameraModal.isActive(prevProps); if (isActive && !wasActive) { Orientation.unlockAllOrientations(); } else if (!isActive && wasActive) { Orientation.lockToPortrait(); } if (!this.state.hasCamerasOnBothSides && prevState.hasCamerasOnBothSides) { this.switchCameraButtonX.setValue(-1); this.switchCameraButtonY.setValue(-1); this.switchCameraButtonWidth.setValue(0); this.switchCameraButtonHeight.setValue(0); } if (this.props.deviceOrientation !== prevProps.deviceOrientation) { this.setState({ autoFocusPointOfInterest: null }); this.cancelIndicatorAnimation.setValue(1); } if (this.props.foreground && !prevProps.foreground && this.camera) { this.camera.refreshAuthorizationStatus(); } if (this.state.stagingMode && !prevState.stagingMode) { this.cancelIndicatorAnimation.setValue(1); this.focusIndicatorOpacity.setValue(0); timing(this.stagingModeProgress, { ...stagingModeAnimationConfig, toValue: 1, }).start(); } else if (!this.state.stagingMode && prevState.stagingMode) { this.stagingModeProgress.setValue(0); } if (this.state.pendingPhotoCapture && !prevState.pendingPhotoCapture) { Animated.timing(this.sendButtonProgress, { ...sendButtonAnimationConfig, toValue: 1, }).start(); } else if ( !this.state.pendingPhotoCapture && prevState.pendingPhotoCapture ) { CameraModal.cleanUpPendingPhotoCapture(prevState.pendingPhotoCapture); this.sendButtonProgress.setValue(0); } } static async cleanUpPendingPhotoCapture(pendingPhotoCapture: PhotoCapture) { const path = pathFromURI(pendingPhotoCapture.uri); if (!path) { return; } try { await filesystem.unlink(path); } catch (e) {} } get containerStyle() { const { overlayContext } = this.props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return { ...styles.container, opacity: overlayContext.position, }; } get focusIndicatorStyle() { return { ...styles.focusIndicator, opacity: this.focusIndicatorOpacity, transform: [ { translateX: this.focusIndicatorX }, { translateY: this.focusIndicatorY }, { scale: this.focusIndicatorScale }, ], }; } renderCamera = ({ camera, status }) => { if (camera && camera._cameraHandle) { this.fetchCameraIDs(camera); } if (this.state.stagingMode) { return this.renderStagingView(); } return ( {this.renderCameraContent(status)} × ); }; renderStagingView() { let image = null; const { pendingPhotoCapture } = this.state; if (pendingPhotoCapture) { const imageSource = { uri: pendingPhotoCapture.uri }; image = ; } else { image = ; } return ( <> {image} ); } renderCameraContent(status) { if (status === 'PENDING_AUTHORIZATION') { return ; } else if (status === 'NOT_AUTHORIZED') { return ( {'don’t have permission :('} ); } let switchCameraButton = null; if (this.state.hasCamerasOnBothSides) { switchCameraButton = ( ); } let flashIcon; if (this.state.flashMode === RNCamera.Constants.FlashMode.on) { flashIcon = ; } else if (this.state.flashMode === RNCamera.Constants.FlashMode.off) { flashIcon = ; } else { flashIcon = ( <> A ); } return ( {flashIcon} {switchCameraButton} ); } render() { const statusBar = CameraModal.isActive(this.props) ? (