diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js index 4b52becdd..713bc6877 100644 --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -1,1794 +1,1797 @@ // @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 _groupBy from 'lodash/fp/groupBy.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _pickBy from 'lodash/fp/pickBy.js'; import type { QueryResults } from 'mysql'; import t from 'tcomb'; import uuidv4 from 'uuid/v4.js'; import { oldValidUsernameRegex } from 'lib/shared/account-utils.js'; import { isUserMentioned } from 'lib/shared/mention-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 { isStaff } from 'lib/shared/staff-utils.js'; import { rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Platform, PlatformDetails } from 'lib/types/device-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type MessageData, } from 'lib/types/message-types.js'; import { rawMessageInfoValidator } from 'lib/types/message-types.js'; import type { WNSNotification, ResolvedNotifTexts, } from 'lib/types/notif-types.js'; import { resolvedNotifTextsValidator } from 'lib/types/notif-types.js'; -import type { ServerThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; +import type { + ServerThreadInfo, + LegacyThreadInfo, +} from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { type GlobalUserInfo } from 'lib/types/user-types.js'; import { isDev } from 'lib/utils/dev-utils.js'; import { values } from 'lib/utils/objects.js'; import { tID, tPlatformDetails, tShape } from 'lib/utils/validation-utils.js'; import { prepareEncryptedAPNsNotifications, prepareEncryptedAndroidNotifications, prepareEncryptedWebNotifications, } from './crypto.js'; import { getAPNsNotificationTopic } from './providers.js'; import { rescindPushNotifs } from './rescind.js'; import type { NotificationTargetDevice, TargetedAPNsNotification, TargetedAndroidNotification, TargetedWebNotification, TargetedWNSNotification, AndroidNotification, } from './types.js'; import { apnPush, fcmPush, getUnreadCounts, apnMaxNotificationPayloadByteSize, fcmMaxNotificationPayloadByteSize, wnsMaxNotificationPayloadByteSize, webPush, wnsPush, type WebPushError, type WNSPushError, } 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'; import { validateOutput } from '../utils/validation-utils.js'; export type Device = { +platform: Platform, +deviceToken: string, +cookieID: string, +codeVersion: ?number, +stateVersion: ?number, +majorDesktopVersion: ?number, }; export type PushUserInfo = { +devices: Device[], // messageInfos and messageDatas have the same key +messageInfos: RawMessageInfo[], +messageDatas: MessageData[], }; type Delivery = PushDelivery | { 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 preparePromises: Array>> = []; 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]) { preparePromises.push( preparePushNotif({ notifInfo, userID, pushUserInfo: pushInfo[userID], unreadCount: unreadCounts[userID], threadInfos, userInfos, dbIDs, rowsToSave: notifications, }), ); } } const prepareResults = await Promise.all(preparePromises); const flattenedPrepareResults = prepareResults.filter(Boolean).flat(); const deliveryResults = await deliverPushNotifsInEncryptionOrder( flattenedPrepareResults, ); const cleanUpPromise = (async () => { if (dbIDs.length === 0) { return; } const query = SQL`DELETE FROM ids WHERE id IN (${dbIDs})`; await dbQuery(query); })(); await Promise.all([ cleanUpPromise, saveNotifResults(deliveryResults, notifications, true), ]); } type PreparePushResult = { +platform: Platform, +notificationInfo: NotificationInfo, +notification: | TargetedAPNsNotification | TargetedAndroidNotification | TargetedWebNotification | TargetedWNSNotification, }; async function preparePushNotif(input: { notifInfo: CollapsableNotifInfo, userID: string, pushUserInfo: PushUserInfo, unreadCount: number, - threadInfos: { +[threadID: string]: ThreadInfo }, + threadInfos: { +[threadID: string]: LegacyThreadInfo }, userInfos: { +[userID: string]: GlobalUserInfo }, dbIDs: string[], // mutable rowsToSave: Map, // mutable }): Promise> { const { notifInfo, userID, pushUserInfo, unreadCount, threadInfos, userInfos, dbIDs, rowsToSave, } = input; 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) { return null; } const existingMessageInfos = notifInfo.existingMessageInfos .map(hydrateMessageInfo) .filter(Boolean); const allMessageInfos = sortMessageInfoList([ ...newMessageInfos, ...existingMessageInfos, ]); const [firstNewMessageInfo, ...remainingNewMessageInfos] = newMessageInfos; const { threadID } = firstNewMessageInfo; const threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; 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) && newMessageInfos.some(newMessageInfo => { const unwrappedMessageInfo = newMessageInfo.type === messageTypes.SIDEBAR_SOURCE ? newMessageInfo.sourceMessage : newMessageInfo; return ( unwrappedMessageInfo.type === messageTypes.TEXT && isUserMentioned(username, unwrappedMessageInfo.text) ); }); if (!updateBadge && !displayBanner && !userWasMentioned) { return null; } const badgeOnly = !displayBanner && !userWasMentioned; const notifTargetUserInfo = { id: userID, username }; const notifTexts = await notifTextsForMessageInfo( allMessageInfos, threadInfo, parentThreadInfo, notifTargetUserInfo, getENSNames, ); if (!notifTexts) { return null; } const dbID = dbIDs.shift(); invariant(dbID, 'should have sufficient DB IDs'); const byPlatform = getDevicesByPlatform(pushUserInfo.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 preparePromises: Array>> = []; const iosVersionsToTokens = byPlatform.get('ios'); if (iosVersionsToTokens) { for (const [versionKey, devices] of iosVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails: PlatformDetails = { platform: 'ios', codeVersion, stateVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAPNsNotification( userID, { notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'ios', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const androidVersionsToTokens = byPlatform.get('android'); if (androidVersionsToTokens) { for (const [versionKey, devices] of androidVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'android', codeVersion, stateVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAndroidNotification( { notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, dbID, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'android', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const webVersionsToTokens = byPlatform.get('web'); if (webVersionsToTokens) { for (const [versionKey, devices] of webVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'web', codeVersion, stateVersion, }; const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareWebNotification( userID, { notifTexts, threadID: threadInfo.id, unreadCount, platformDetails, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'web', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [versionKey, devices] of macosVersionsToTokens) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'macos', codeVersion, stateVersion, majorDesktopVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAPNsNotification( userID, { notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'macos', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const windowsVersionsToTokens = byPlatform.get('windows'); if (windowsVersionsToTokens) { for (const [versionKey, devices] of windowsVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'windows', codeVersion, stateVersion, }; const preparePromise: Promise<$ReadOnlyArray> = (async () => { const notification = await prepareWNSNotification({ notifTexts, threadID: threadInfo.id, unreadCount, platformDetails, }); return devices.map(({ deviceToken }) => ({ notification: ({ deviceToken, notification, }: TargetedWNSNotification), platform: 'windows', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } 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'); rowsToSave.set(newDBID, { dbID: newDBID, userID, threadID: newMessageInfo.threadID, messageID, collapseKey: notifInfo.collapseKey, deliveries: [{ collapsedInto: dbID }], }); } const prepareResults = await Promise.all(preparePromises); return prepareResults.flat(); } // For better readability we don't differentiate between // encrypted and unencrypted notifs and order them together function compareEncryptionOrder( pushNotif1: PreparePushResult, pushNotif2: PreparePushResult, ): number { const order1 = pushNotif1.notification.encryptionOrder ?? 0; const order2 = pushNotif2.notification.encryptionOrder ?? 0; return order1 - order2; } async function deliverPushNotifsInEncryptionOrder( preparedPushNotifs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const deliveryPromises: Array>> = []; const groupedByDevice = _groupBy( preparedPushNotif => preparedPushNotif.deviceToken, )(preparedPushNotifs); for (const preparedPushNotifsForDevice of values(groupedByDevice)) { const orderedPushNotifsForDevice = preparedPushNotifsForDevice.sort( compareEncryptionOrder, ); const deviceDeliveryPromise = (async () => { const deliveries = []; for (const preparedPushNotif of orderedPushNotifsForDevice) { const { platform, notification, notificationInfo } = preparedPushNotif; let delivery: PushResult; if (platform === 'ios' || platform === 'macos') { delivery = await sendAPNsNotification( platform, [notification], notificationInfo, ); } else if (platform === 'android') { delivery = await sendAndroidNotification( [notification], notificationInfo, ); } else if (platform === 'web') { delivery = await sendWebNotifications( [notification], notificationInfo, ); } else if (platform === 'windows') { delivery = await sendWNSNotification( [notification], notificationInfo, ); } if (delivery) { deliveries.push(delivery); } } return deliveries; })(); deliveryPromises.push(deviceDeliveryPromise); } const deliveryResults = await Promise.all(deliveryPromises); return deliveryResults.flat(); } async function sendRescindNotifs(rescindInfo: PushInfo) { if (Object.keys(rescindInfo).length === 0) { return; } const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(rescindInfo); const promises = []; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const existingMessageInfo of notifInfo.existingMessageInfos) { const rescindCondition = SQL` n.user = ${userID} AND n.thread = ${existingMessageInfo.threadID} AND n.message = ${existingMessageInfo.id} `; promises.push(rescindPushNotifs(rescindCondition)); } } } await Promise.all(promises); } // 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: Array> = []; 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); } } } // These threadInfos won't have currentUser set const threadPromise = fetchServerThreadInfos({ threadIDs }); const oldNamesPromise: Promise = (async () => { if (threadWithChangedNamesToMessages.size === 0) { return undefined; } 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 `); return await dbQuery(oldNameQuery); })(); const [threadResult, oldNames] = await Promise.all([ threadPromise, oldNamesPromise, ]); const serverThreadInfos = { ...threadResult.threadInfos }; if (oldNames) { const [result] = oldNames; for (const row of result) { const threadID = row.thread.toString(); serverThreadInfos[threadID] = { ...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) { missingUserIDs.add(userID); 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); } type VersionKey = { +codeVersion: number, +stateVersion: number, +majorDesktopVersion?: number, }; const versionKeyRegex: RegExp = new RegExp(/^-?\d+\|-?\d+$/); function versionKeyToString(versionKey: VersionKey): string { const baseStringVersionKey = `${versionKey.codeVersion}|${versionKey.stateVersion}`; if (!versionKey.majorDesktopVersion) { return baseStringVersionKey; } return `${baseStringVersionKey}|${versionKey.majorDesktopVersion}`; } function stringToVersionKey(versionKeyString: string): VersionKey { invariant( versionKeyRegex.test(versionKeyString), 'should pass correct version key string', ); const [codeVersion, stateVersion, majorDesktopVersion] = versionKeyString .split('|') .map(Number); return { codeVersion, stateVersion, majorDesktopVersion }; } function getDevicesByPlatform( devices: $ReadOnlyArray, ): Map>> { const byPlatform = new Map< Platform, Map>, >(); for (const device of devices) { let innerMap = byPlatform.get(device.platform); if (!innerMap) { innerMap = new Map>(); byPlatform.set(device.platform, innerMap); } const codeVersion: number = device.codeVersion !== null && device.codeVersion !== undefined ? device.codeVersion : -1; const stateVersion: number = device.stateVersion ?? -1; let versionsObject = { codeVersion, stateVersion }; if (device.majorDesktopVersion) { versionsObject = { ...versionsObject, majorDesktopVersion: device.majorDesktopVersion, }; } const versionKey = versionKeyToString(versionsObject); let innerMostArrayTmp: ?Array = innerMap.get(versionKey); if (!innerMostArrayTmp) { innerMostArrayTmp = []; innerMap.set(versionKey, innerMostArrayTmp); } const innerMostArray = innerMostArrayTmp; innerMostArray.push({ cookieID: device.cookieID, deviceToken: device.deviceToken, }); } return byPlatform; } type APNsNotifInputData = { +notifTexts: ResolvedNotifTexts, +newRawMessageInfos: RawMessageInfo[], +threadID: string, +collapseKey: ?string, +badgeOnly: boolean, +unreadCount: number, +platformDetails: PlatformDetails, }; const apnsNotifInputDataValidator = tShape({ notifTexts: resolvedNotifTextsValidator, newRawMessageInfos: t.list(rawMessageInfoValidator), threadID: tID, collapseKey: t.maybe(t.String), badgeOnly: t.Boolean, unreadCount: t.Number, platformDetails: tPlatformDetails, }); async function prepareAPNsNotification( userID: string, inputData: APNsNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = validateOutput( inputData.platformDetails, apnsNotifInputDataValidator, inputData, ); const { notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails, } = convertedData; const canDecryptNonCollapsibleTextNotifs = platformDetails.codeVersion && platformDetails.codeVersion > 222; const isNonCollapsibleTextNotification = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllNotifTypes = platformDetails.codeVersion && platformDetails.codeVersion >= 267; const canDecryptIOSNotifs = platformDetails.platform === 'ios' && (canDecryptAllNotifTypes || (isNonCollapsibleTextNotification && canDecryptNonCollapsibleTextNotifs)); const isStaffOrDev = isStaff(userID) || isDev; const canDecryptMacOSNotifs = isStaffOrDev && platformDetails.platform === 'macos' && hasMinCodeVersion(platformDetails, { web: 47, majorDesktop: 9, }); const shouldBeEncrypted = canDecryptIOSNotifs || canDecryptMacOSNotifs; const uniqueID = uuidv4(); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic(platformDetails); const { merged, ...rest } = notifTexts; // We don't include alert's body on macos because we // handle displaying the notification ourselves and // we don't want macOS to display it automatically. if (!badgeOnly && platformDetails.platform !== 'macos') { notification.body = merged; notification.sound = 'default'; } notification.payload = { ...notification.payload, ...rest, }; notification.badge = unreadCount; notification.threadId = threadID; notification.id = uniqueID; notification.pushType = 'alert'; notification.payload.id = uniqueID; notification.payload.threadID = threadID; if (platformDetails.codeVersion && platformDetails.codeVersion > 198) { notification.mutableContent = true; } if (collapseKey && canDecryptAllNotifTypes) { notification.payload.collapseID = collapseKey; } else 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, }; const notificationSizeValidator = (notif: apn.Notification) => notif.length() <= apnMaxNotificationPayloadByteSize; if (!shouldBeEncrypted) { const notificationToSend = notificationSizeValidator( _cloneDeep(copyWithMessageInfos), ) ? copyWithMessageInfos : notification; return devices.map(({ deviceToken }) => ({ notification: notificationToSend, deviceToken, })); } const notifsWithMessageInfos = await prepareEncryptedAPNsNotifications( devices, copyWithMessageInfos, platformDetails.codeVersion, notificationSizeValidator, ); const devicesWithExcessiveSize = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ deviceToken, cookieID }) => ({ deviceToken, cookieID })); if (devicesWithExcessiveSize.length === 0) { return notifsWithMessageInfos.map( ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }), ); } const notifsWithoutMessageInfos = await prepareEncryptedAPNsNotifications( devicesWithExcessiveSize, notification, platformDetails.codeVersion, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map( ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }), ); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }), ); return [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; } type AndroidNotifInputData = { ...APNsNotifInputData, +dbID: string, }; const androidNotifInputDataValidator = tShape({ ...apnsNotifInputDataValidator.meta.props, dbID: t.String, }); async function prepareAndroidNotification( inputData: AndroidNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = validateOutput( inputData.platformDetails, androidNotifInputDataValidator, inputData, ); const { notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails: { codeVersion }, dbID, } = convertedData; const canDecryptNonCollapsibleTextNotifs = codeVersion && codeVersion > 228; const isNonCollapsibleTextNotif = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllNotifTypes = codeVersion && codeVersion >= 267; const shouldBeEncrypted = canDecryptAllNotifTypes || (canDecryptNonCollapsibleTextNotifs && isNonCollapsibleTextNotif); const { merged, ...rest } = notifTexts; const notification = { data: { badge: unreadCount.toString(), ...rest, threadID, }, }; let notifID; if (collapseKey && canDecryptAllNotifTypes) { notifID = dbID; notification.data = { ...notification.data, collapseKey, }; } else if (collapseKey) { notifID = collapseKey; } else { notifID = dbID; } // 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 && 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 (!shouldBeEncrypted) { const notificationToSend = Buffer.byteLength(JSON.stringify(copyWithMessageInfos)) <= fcmMaxNotificationPayloadByteSize ? copyWithMessageInfos : notification; return devices.map(({ deviceToken }) => ({ notification: notificationToSend, deviceToken, })); } const notificationsSizeValidator = (notif: AndroidNotification) => { const serializedNotif = JSON.stringify(notif); return ( !serializedNotif || Buffer.byteLength(serializedNotif) <= fcmMaxNotificationPayloadByteSize ); }; const notifsWithMessageInfos = await prepareEncryptedAndroidNotifications( devices, copyWithMessageInfos, notificationsSizeValidator, ); const devicesWithExcessiveSize = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ cookieID, deviceToken }) => ({ cookieID, deviceToken })); if (devicesWithExcessiveSize.length === 0) { return notifsWithMessageInfos.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); } const notifsWithoutMessageInfos = await prepareEncryptedAndroidNotifications( devicesWithExcessiveSize, notification, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map(({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, })); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); return [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; } type WebNotifInputData = { +notifTexts: ResolvedNotifTexts, +threadID: string, +unreadCount: number, +platformDetails: PlatformDetails, }; const webNotifInputDataValidator = tShape({ notifTexts: resolvedNotifTextsValidator, threadID: tID, unreadCount: t.Number, platformDetails: tPlatformDetails, }); async function prepareWebNotification( userID: string, inputData: WebNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = validateOutput( inputData.platformDetails, webNotifInputDataValidator, inputData, ); const { notifTexts, threadID, unreadCount } = convertedData; const id = uuidv4(); const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, id, threadID, }; const isStaffOrDev = isStaff(userID) || isDev; const shouldBeEncrypted = hasMinCodeVersion(convertedData.platformDetails, { web: 43, }) && isStaffOrDev; if (!shouldBeEncrypted) { return devices.map(({ deviceToken }) => ({ deviceToken, notification })); } return prepareEncryptedWebNotifications(devices, notification); } type WNSNotifInputData = { +notifTexts: ResolvedNotifTexts, +threadID: string, +unreadCount: number, +platformDetails: PlatformDetails, }; const wnsNotifInputDataValidator = tShape({ notifTexts: resolvedNotifTextsValidator, threadID: tID, unreadCount: t.Number, platformDetails: tPlatformDetails, }); async function prepareWNSNotification( inputData: WNSNotifInputData, ): Promise { const convertedData = validateOutput( inputData.platformDetails, wnsNotifInputDataValidator, inputData, ); const { notifTexts, threadID, unreadCount } = convertedData; const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, threadID, }; if ( Buffer.byteLength(JSON.stringify(notification)) > wnsMaxNotificationPayloadByteSize ) { console.warn('WNS notification exceeds size limit'); } return notification; } type NotificationInfo = | { +source: 'new_message', +dbID: string, +userID: string, +threadID: string, +messageID: string, +collapseKey: ?string, +codeVersion: number, +stateVersion: number, } | { +source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', +dbID: string, +userID: string, +codeVersion: number, +stateVersion: number, }; type APNsDelivery = { +source: $PropertyType, +deviceType: 'ios' | 'macos', +iosID: string, +deviceTokens: $ReadOnlyArray, +codeVersion: number, +stateVersion: number, +errors?: $ReadOnlyArray, +encryptedPayloadHashes?: $ReadOnlyArray, +deviceTokensToPayloadHash?: { +[deviceToken: string]: string, }, }; type APNsResult = { info: NotificationInfo, delivery: APNsDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendAPNsNotification( platform: 'ios' | 'macos', targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await apnPush({ targetedNotifications, platformDetails: { platform, codeVersion }, }); invariant( new Set(targetedNotifications.map(({ notification }) => notification.id)) .size === 1, 'Encrypted versions of the same notification must share id value', ); const iosID = targetedNotifications[0].notification.id; const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); let delivery: APNsDelivery = { source, deviceType: platform, iosID, deviceTokens, codeVersion, stateVersion, }; if (response.errors) { delivery = { ...delivery, errors: response.errors, }; } const deviceTokensToPayloadHash: { [string]: string } = {}; for (const targetedNotification of targetedNotifications) { if (targetedNotification.encryptedPayloadHash) { deviceTokensToPayloadHash[targetedNotification.deviceToken] = targetedNotification.encryptedPayloadHash; } } if (Object.keys(deviceTokensToPayloadHash).length !== 0) { delivery = { ...delivery, deviceTokensToPayloadHash, }; } const result: APNsResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type PushResult = AndroidResult | APNsResult | WebResult | WNSResult; type PushDelivery = AndroidDelivery | APNsDelivery | WebDelivery | WNSDelivery; type AndroidDelivery = { source: $PropertyType, deviceType: 'android', androidIDs: $ReadOnlyArray, deviceTokens: $ReadOnlyArray, codeVersion: number, stateVersion: number, errors?: $ReadOnlyArray, }; type AndroidResult = { info: NotificationInfo, delivery: AndroidDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendAndroidNotification( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const collapseKey = notificationInfo.collapseKey ? notificationInfo.collapseKey : null; // for Flow... const { source, codeVersion, stateVersion } = notificationInfo; const response = await fcmPush({ targetedNotifications, collapseKey, codeVersion, }); const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); const androidIDs = response.fcmIDs ? response.fcmIDs : []; const delivery: AndroidDelivery = { source, deviceType: 'android', androidIDs, deviceTokens, codeVersion, stateVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: AndroidResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type WebDelivery = { +source: $PropertyType, +deviceType: 'web', +deviceTokens: $ReadOnlyArray, +codeVersion?: number, +stateVersion: number, +errors?: $ReadOnlyArray, }; type WebResult = { +info: NotificationInfo, +delivery: WebDelivery, +invalidTokens?: $ReadOnlyArray, }; async function sendWebNotifications( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await webPush(targetedNotifications); const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); const delivery: WebDelivery = { source, deviceType: 'web', deviceTokens, codeVersion, errors: response.errors, stateVersion, }; const result: WebResult = { info: notificationInfo, delivery, invalidTokens: response.invalidTokens, }; return result; } type WNSDelivery = { +source: $PropertyType, +deviceType: 'windows', +wnsIDs: $ReadOnlyArray, +deviceTokens: $ReadOnlyArray, +codeVersion?: number, +stateVersion: number, +errors?: $ReadOnlyArray, }; type WNSResult = { +info: NotificationInfo, +delivery: WNSDelivery, +invalidTokens?: $ReadOnlyArray, }; async function sendWNSNotification( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await wnsPush(targetedNotifications); const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); const wnsIDs = response.wnsIDs ?? []; const delivery: WNSDelivery = { source, deviceType: 'windows', wnsIDs, deviceTokens, codeVersion, errors: response.errors, stateVersion, }; const result: WNSResult = { info: notificationInfo, delivery, 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: Array> = []; 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, id 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 => { const versions = JSON.parse(row.versions); return { platform: row.platform, cookieID: row.id, deviceToken: row.device_token, codeVersion: versions?.codeVersion, stateVersion: versions?.stateVersion, }; }); const byPlatform = getDevicesByPlatform(devices); const preparePromises: Array>> = []; const iosVersionsToTokens = byPlatform.get('ios'); if (iosVersionsToTokens) { for (const [versionKey, deviceInfos] of iosVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'ios', codeVersion, stateVersion, }); notification.badge = unreadCount; notification.pushType = 'alert'; const preparePromise: Promise = (async () => { let targetedNotifications: $ReadOnlyArray; if (codeVersion > 222) { const notificationsArray = await prepareEncryptedAPNsNotifications( deviceInfos, notification, codeVersion, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); } else { targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ notification, deviceToken, })); } return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'ios', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const androidVersionsToTokens = byPlatform.get('android'); if (androidVersionsToTokens) { for (const [versionKey, deviceInfos] of androidVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const notificationData = codeVersion < 69 ? { badge: unreadCount.toString() } : { badge: unreadCount.toString(), badgeOnly: '1' }; const notification = { data: notificationData }; const preparePromise: Promise = (async () => { let targetedNotifications: $ReadOnlyArray; if (codeVersion > 222) { const notificationsArray = await prepareEncryptedAndroidNotifications( deviceInfos, notification, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); } else { targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ deviceToken, notification, })); } return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'android', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [versionKey, deviceInfos] of macosVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'macos', codeVersion, stateVersion, }); notification.badge = unreadCount; notification.pushType = 'alert'; const preparePromise: Promise = (async () => { const isStaffOrDev = isStaff(userID) || isDev; const shouldBeEncrypted = isStaffOrDev && hasMinCodeVersion(viewer.platformDetails, { web: 47, majorDesktop: 9, }); let targetedNotifications: $ReadOnlyArray; if (shouldBeEncrypted) { const notificationsArray = await prepareEncryptedAPNsNotifications( deviceInfos, notification, codeVersion, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); } else { targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ deviceToken, notification, })); } return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'macos', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const prepareResults = await Promise.all(preparePromises); const flattenedPrepareResults = prepareResults.filter(Boolean).flat(); const deliveryResults = await deliverPushNotifsInEncryptionOrder( flattenedPrepareResults, ); await saveNotifResults(deliveryResults, new Map(), false); } export { sendPushNotifs, sendRescindNotifs, updateBadgeCount }; diff --git a/lib/components/chat-mention-provider.react.js b/lib/components/chat-mention-provider.react.js index 2d3f2031d..0e549eae2 100644 --- a/lib/components/chat-mention-provider.react.js +++ b/lib/components/chat-mention-provider.react.js @@ -1,244 +1,244 @@ // @flow import * as React from 'react'; import genesis from '../facts/genesis.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import SentencePrefixSearchIndex from '../shared/sentence-prefix-search-index.js'; import type { MinimallyEncodedResolvedThreadInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { ChatMentionCandidates, ChatMentionCandidatesObj, ResolvedThreadInfo, - ThreadInfo, + LegacyThreadInfo, } from '../types/thread-types.js'; import { useResolvedThreadInfosObj } from '../utils/entity-helpers.js'; import { useSelector } from '../utils/redux-utils.js'; type Props = { +children: React.Node, }; export type ChatMentionContextType = { +getChatMentionSearchIndex: ( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ) => SentencePrefixSearchIndex, +communityThreadIDForGenesisThreads: { +[id: string]: string }, +chatMentionCandidatesObj: ChatMentionCandidatesObj, }; const emptySearchIndex = new SentencePrefixSearchIndex(); const ChatMentionContext: React.Context = React.createContext({ getChatMentionSearchIndex: () => emptySearchIndex, communityThreadIDForGenesisThreads: {}, chatMentionCandidatesObj: {}, }); function ChatMentionContextProvider(props: Props): React.Node { const { children } = props; const { communityThreadIDForGenesisThreads, chatMentionCandidatesObj } = useChatMentionCandidatesObjAndUtils(); const searchIndices = useChatMentionSearchIndex(chatMentionCandidatesObj); const getChatMentionSearchIndex = React.useCallback( - (threadInfo: ThreadInfo | MinimallyEncodedThreadInfo) => { + (threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo) => { if (threadInfo.community === genesis.id) { return searchIndices[communityThreadIDForGenesisThreads[threadInfo.id]]; } return searchIndices[threadInfo.community ?? threadInfo.id]; }, [communityThreadIDForGenesisThreads, searchIndices], ); const value = React.useMemo( () => ({ getChatMentionSearchIndex, communityThreadIDForGenesisThreads, chatMentionCandidatesObj, }), [ getChatMentionSearchIndex, communityThreadIDForGenesisThreads, chatMentionCandidatesObj, ], ); return ( {children} ); } function getChatMentionCandidates(threadInfos: { +[id: string]: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, }): { chatMentionCandidatesObj: ChatMentionCandidatesObj, communityThreadIDForGenesisThreads: { +[id: string]: string }, } { const result: { [string]: { [string]: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, }, } = {}; const visitedGenesisThreads = new Set(); const communityThreadIDForGenesisThreads: { [string]: string } = {}; for (const currentThreadID in threadInfos) { const currentThreadInfo = threadInfos[currentThreadID]; const { community: currentThreadCommunity } = currentThreadInfo; if (!currentThreadCommunity) { if (!result[currentThreadID]) { result[currentThreadID] = { [currentThreadID]: currentThreadInfo }; } continue; } if (!result[currentThreadCommunity]) { result[currentThreadCommunity] = {}; result[currentThreadCommunity][currentThreadCommunity] = threadInfos[currentThreadCommunity]; } // Handle GENESIS community case: mentioning inside GENESIS should only // show chats and threads inside the top level that is below GENESIS. if (threadInfos[currentThreadCommunity].type === threadTypes.GENESIS) { if (visitedGenesisThreads.has(currentThreadID)) { continue; } const threadTraversePath = [currentThreadInfo]; visitedGenesisThreads.add(currentThreadID); let currentlySelectedThreadID = currentThreadInfo.parentThreadID; while (currentlySelectedThreadID) { const currentlySelectedThreadInfo = threadInfos[currentlySelectedThreadID]; if ( visitedGenesisThreads.has(currentlySelectedThreadID) || !currentlySelectedThreadInfo || currentlySelectedThreadInfo.type === threadTypes.GENESIS ) { break; } threadTraversePath.push(currentlySelectedThreadInfo); visitedGenesisThreads.add(currentlySelectedThreadID); currentlySelectedThreadID = currentlySelectedThreadInfo.parentThreadID; } const lastThreadInTraversePath = threadTraversePath[threadTraversePath.length - 1]; let lastThreadInTraversePathParentID; if (lastThreadInTraversePath.parentThreadID) { lastThreadInTraversePathParentID = threadInfos[ lastThreadInTraversePath.parentThreadID ] ? lastThreadInTraversePath.parentThreadID : lastThreadInTraversePath.id; } else { lastThreadInTraversePathParentID = lastThreadInTraversePath.id; } if ( threadInfos[lastThreadInTraversePathParentID].type === threadTypes.GENESIS ) { if (!result[lastThreadInTraversePath.id]) { result[lastThreadInTraversePath.id] = {}; } for (const threadInfo of threadTraversePath) { result[lastThreadInTraversePath.id][threadInfo.id] = threadInfo; communityThreadIDForGenesisThreads[threadInfo.id] = lastThreadInTraversePath.id; } if ( lastThreadInTraversePath.type !== threadTypes.PERSONAL && lastThreadInTraversePath.type !== threadTypes.PRIVATE ) { result[genesis.id][lastThreadInTraversePath.id] = lastThreadInTraversePath; } } else { if ( !communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID] ) { result[lastThreadInTraversePathParentID] = {}; communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID] = lastThreadInTraversePathParentID; } const lastThreadInTraversePathParentCommunityThreadID = communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID]; for (const threadInfo of threadTraversePath) { result[lastThreadInTraversePathParentCommunityThreadID][ threadInfo.id ] = threadInfo; communityThreadIDForGenesisThreads[threadInfo.id] = lastThreadInTraversePathParentCommunityThreadID; } } continue; } result[currentThreadCommunity][currentThreadID] = currentThreadInfo; } return { chatMentionCandidatesObj: result, communityThreadIDForGenesisThreads, }; } // Without allAtOnce, useChatMentionCandidatesObjAndUtils is very expensive. // useResolvedThreadInfosObj would trigger its recalculation for each ENS name // as it streams in, but we would prefer to trigger its recaculation just once // for every update of the underlying Redux data. const useResolvedThreadInfosObjOptions = { allAtOnce: true }; function useChatMentionCandidatesObjAndUtils(): { chatMentionCandidatesObj: ChatMentionCandidatesObj, resolvedThreadInfos: ChatMentionCandidates, communityThreadIDForGenesisThreads: { +[id: string]: string }, } { const threadInfos = useSelector(threadInfoSelector); const resolvedThreadInfos = useResolvedThreadInfosObj( threadInfos, useResolvedThreadInfosObjOptions, ); const { chatMentionCandidatesObj, communityThreadIDForGenesisThreads } = React.useMemo( () => getChatMentionCandidates(resolvedThreadInfos), [resolvedThreadInfos], ); return { chatMentionCandidatesObj, resolvedThreadInfos, communityThreadIDForGenesisThreads, }; } function useChatMentionSearchIndex( chatMentionCandidatesObj: ChatMentionCandidatesObj, ): { +[id: string]: SentencePrefixSearchIndex, } { return React.useMemo(() => { const result: { [string]: SentencePrefixSearchIndex } = {}; for (const communityThreadID in chatMentionCandidatesObj) { const searchIndex = new SentencePrefixSearchIndex(); const searchIndexEntries = []; for (const threadID in chatMentionCandidatesObj[communityThreadID]) { searchIndexEntries.push({ id: threadID, uiName: chatMentionCandidatesObj[communityThreadID][threadID].uiName, }); } // Sort the keys so that the order of the search result is consistent searchIndexEntries.sort(({ uiName: uiNameA }, { uiName: uiNameB }) => uiNameA.localeCompare(uiNameB), ); for (const { id, uiName } of searchIndexEntries) { searchIndex.addEntry(id, uiName); } result[communityThreadID] = searchIndex; } return result; }, [chatMentionCandidatesObj]); } export { ChatMentionContextProvider, ChatMentionContext }; diff --git a/lib/hooks/chat-mention-hooks.js b/lib/hooks/chat-mention-hooks.js index 44c629235..5cd37b270 100644 --- a/lib/hooks/chat-mention-hooks.js +++ b/lib/hooks/chat-mention-hooks.js @@ -1,48 +1,48 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ChatMentionContext, type ChatMentionContextType, } from '../components/chat-mention-provider.react.js'; import genesis from '../facts/genesis.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { ChatMentionCandidates, - ThreadInfo, + LegacyThreadInfo, } from '../types/thread-types.js'; function useChatMentionContext(): ChatMentionContextType { const context = React.useContext(ChatMentionContext); invariant(context, 'ChatMentionContext not found'); return context; } function useThreadChatMentionCandidates( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): ChatMentionCandidates { const { communityThreadIDForGenesisThreads, chatMentionCandidatesObj } = useChatMentionContext(); return React.useMemo(() => { const communityID = threadInfo.community === genesis.id ? communityThreadIDForGenesisThreads[threadInfo.id] : threadInfo.community ?? threadInfo.id; const allChatsWithinCommunity = chatMentionCandidatesObj[communityID]; if (!allChatsWithinCommunity) { return {}; } const { [threadInfo.id]: _, ...result } = allChatsWithinCommunity; return result; }, [ chatMentionCandidatesObj, communityThreadIDForGenesisThreads, threadInfo.community, threadInfo.id, ]); } export { useThreadChatMentionCandidates, useChatMentionContext }; diff --git a/lib/hooks/child-threads.js b/lib/hooks/child-threads.js index 69e2f5d0d..e322e43f6 100644 --- a/lib/hooks/child-threads.js +++ b/lib/hooks/child-threads.js @@ -1,123 +1,127 @@ // @flow import * as React from 'react'; import { useFetchSingleMostRecentMessagesFromThreads, fetchSingleMostRecentMessagesFromThreadsActionTypes, } from '../actions/message-actions.js'; import { useFilteredChatListData, type ChatThreadItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { childThreadInfos } from '../selectors/thread-selectors.js'; import { threadInChatList } from '../shared/thread-utils.js'; import threadWatcher from '../shared/thread-watcher.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo, RawThreadInfo } from '../types/thread-types.js'; +import type { LegacyThreadInfo, RawThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type ThreadFilter = { - +predicate?: (thread: ThreadInfo | MinimallyEncodedThreadInfo) => boolean, + +predicate?: ( + thread: LegacyThreadInfo | MinimallyEncodedThreadInfo, + ) => boolean, +searchText?: string, }; function useFilteredChildThreads( threadID: string, filter?: ThreadFilter, ): $ReadOnlyArray { const defaultPredicate = React.useCallback(() => true, []); const { predicate = defaultPredicate, searchText = '' } = filter ?? {}; const childThreads = useSelector(state => childThreadInfos(state)[threadID]); const subchannelIDs = React.useMemo(() => { if (!childThreads) { return new Set(); } return new Set( childThreads.filter(predicate).map(threadInfo => threadInfo.id), ); }, [childThreads, predicate]); const filterSubchannels = React.useCallback( - (thread: ?(ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo)) => { + ( + thread: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + ) => { const candidateThreadID = thread?.id; if (!candidateThreadID) { return false; } return subchannelIDs.has(candidateThreadID); }, [subchannelIDs], ); const allSubchannelsList = useFilteredChatListData(filterSubchannels); const searchIndex = useGlobalThreadSearchIndex(); const searchResultIDs = React.useMemo( () => searchIndex.getSearchResults(searchText), [searchIndex, searchText], ); const searchTextExists = !!searchText.length; const subchannelIDsNotInChatList = React.useMemo( () => new Set( allSubchannelsList .filter(item => !threadInChatList(item.threadInfo)) .map(item => item.threadInfo.id), ), [allSubchannelsList], ); React.useEffect(() => { if (!subchannelIDsNotInChatList.size) { return undefined; } subchannelIDsNotInChatList.forEach(tID => threadWatcher.watchID(tID)); return () => subchannelIDsNotInChatList.forEach(tID => threadWatcher.removeID(tID)); }, [subchannelIDsNotInChatList]); const filteredSubchannelsChatList = React.useMemo(() => { if (!searchTextExists) { return allSubchannelsList; } return allSubchannelsList.filter(item => searchResultIDs.includes(item.threadInfo.id), ); }, [allSubchannelsList, searchResultIDs, searchTextExists]); const threadIDsWithNoMessages = React.useMemo( () => new Set( filteredSubchannelsChatList .filter(item => !item.mostRecentMessageInfo) .map(item => item.threadInfo.id), ), [filteredSubchannelsChatList], ); const dispatchActionPromise = useDispatchActionPromise(); const fetchSingleMostRecentMessages = useFetchSingleMostRecentMessagesFromThreads(); React.useEffect(() => { if (!threadIDsWithNoMessages.size) { return; } dispatchActionPromise( fetchSingleMostRecentMessagesFromThreadsActionTypes, fetchSingleMostRecentMessages(Array.from(threadIDsWithNoMessages)), ); }, [ threadIDsWithNoMessages, fetchSingleMostRecentMessages, dispatchActionPromise, ]); return filteredSubchannelsChatList; } export { useFilteredChildThreads }; diff --git a/lib/hooks/promote-sidebar.react.js b/lib/hooks/promote-sidebar.react.js index d27f16f58..a8948b420 100644 --- a/lib/hooks/promote-sidebar.react.js +++ b/lib/hooks/promote-sidebar.react.js @@ -1,96 +1,96 @@ // @flow import * as React from 'react'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from '../actions/thread-actions.js'; import { createLoadingStatusSelector } from '../selectors/loading-selectors.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { threadHasPermission, threadIsSidebar, } from '../shared/thread-utils.js'; import type { LoadingStatus } from '../types/loading-types.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; -import { type ThreadInfo } from '../types/thread-types.js'; +import { type LegacyThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; function canPromoteSidebar( - sidebarThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + sidebarThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ): boolean { if (!threadIsSidebar(sidebarThreadInfo)) { return false; } const canChangeThreadType = threadHasPermission( sidebarThreadInfo, threadPermissions.EDIT_PERMISSIONS, ); const canCreateSubchannelsInParent = threadHasPermission( parentThreadInfo, threadPermissions.CREATE_SUBCHANNELS, ); return canChangeThreadType && canCreateSubchannelsInParent; } type PromoteSidebarType = { +onPromoteSidebar: () => void, +loading: LoadingStatus, +canPromoteSidebar: boolean, }; function usePromoteSidebar( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, onError?: () => mixed, ): PromoteSidebarType { const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); const loadingStatus = useSelector(loadingStatusSelector); const { parentThreadID } = threadInfo; - const parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo = + const parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const canPromote = canPromoteSidebar(threadInfo, parentThreadInfo); const onClick = React.useCallback(() => { try { dispatchActionPromise( changeThreadSettingsActionTypes, (async () => { return await callChangeThreadSettings({ threadID: threadInfo.id, changes: { type: threadTypes.COMMUNITY_OPEN_SUBTHREAD }, }); })(), ); } catch (e) { onError?.(); throw e; } }, [threadInfo.id, callChangeThreadSettings, dispatchActionPromise, onError]); const returnValues = React.useMemo( () => ({ onPromoteSidebar: onClick, loading: loadingStatus, canPromoteSidebar: canPromote, }), [onClick, loadingStatus, canPromote], ); return returnValues; } export { usePromoteSidebar, canPromoteSidebar }; diff --git a/lib/hooks/relationship-prompt.js b/lib/hooks/relationship-prompt.js index e5388f55a..1f5353df5 100644 --- a/lib/hooks/relationship-prompt.js +++ b/lib/hooks/relationship-prompt.js @@ -1,128 +1,128 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { updateRelationships as serverUpdateRelationships, updateRelationshipsActionTypes, } from '../actions/relationship-actions.js'; import { getSingleOtherUser } from '../shared/thread-utils.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { type RelationshipAction, relationshipActions, } from '../types/relationship-types.js'; -import type { ThreadInfo } from '../types/thread-types.js'; +import type { LegacyThreadInfo } from '../types/thread-types.js'; import type { UserInfo } from '../types/user-types.js'; import { useDispatchActionPromise, useServerCall, } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type RelationshipCallbacks = { +blockUser: () => void, +unblockUser: () => void, +friendUser: () => void, +unfriendUser: () => void, }; type RelationshipPromptData = { +otherUserInfo: ?UserInfo, +callbacks: RelationshipCallbacks, }; function useRelationshipPrompt( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, onErrorCallback?: () => void, pendingPersonalThreadUserInfo?: ?UserInfo, ): RelationshipPromptData { // We're fetching the info from state because we need the most recent // relationship status. Additionally, member info does not contain info // about relationship. const otherUserInfo = useSelector(state => { const otherUserID = getSingleOtherUser(threadInfo, state.currentUserInfo?.id) ?? pendingPersonalThreadUserInfo?.id; const { userInfos } = state.userStore; return otherUserID && userInfos[otherUserID] ? userInfos[otherUserID] : pendingPersonalThreadUserInfo; }); const callbacks = useRelationshipCallbacks( otherUserInfo?.id, onErrorCallback, ); return React.useMemo( () => ({ otherUserInfo, callbacks, }), [callbacks, otherUserInfo], ); } function useRelationshipCallbacks( otherUserID?: string, onErrorCallback?: () => void, ): RelationshipCallbacks { const callUpdateRelationships = useServerCall(serverUpdateRelationships); const updateRelationship = React.useCallback( async (action: RelationshipAction) => { try { invariant(otherUserID, 'Other user info id should be present'); return await callUpdateRelationships({ action, userIDs: [otherUserID], }); } catch (e) { onErrorCallback?.(); throw e; } }, [callUpdateRelationships, onErrorCallback, otherUserID], ); const dispatchActionPromise = useDispatchActionPromise(); const onButtonPress = React.useCallback( (action: RelationshipAction) => { dispatchActionPromise( updateRelationshipsActionTypes, updateRelationship(action), ); }, [dispatchActionPromise, updateRelationship], ); const blockUser = React.useCallback( () => onButtonPress(relationshipActions.BLOCK), [onButtonPress], ); const unblockUser = React.useCallback( () => onButtonPress(relationshipActions.UNBLOCK), [onButtonPress], ); const friendUser = React.useCallback( () => onButtonPress(relationshipActions.FRIEND), [onButtonPress], ); const unfriendUser = React.useCallback( () => onButtonPress(relationshipActions.UNFRIEND), [onButtonPress], ); return React.useMemo( () => ({ blockUser, unblockUser, friendUser, unfriendUser, }), [blockUser, friendUser, unblockUser, unfriendUser], ); } export { useRelationshipPrompt, useRelationshipCallbacks }; diff --git a/lib/hooks/search-threads.js b/lib/hooks/search-threads.js index afa89266d..645afe6af 100644 --- a/lib/hooks/search-threads.js +++ b/lib/hooks/search-threads.js @@ -1,115 +1,116 @@ // @flow import * as React from 'react'; import { type ChatThreadItem, useFilteredChatListData, } from '../selectors/chat-selectors.js'; import { useThreadSearchIndex } from '../selectors/nav-selectors.js'; import { sidebarInfoSelector } from '../selectors/thread-selectors.js'; import { threadIsChannel } from '../shared/thread-utils.js'; import type { SetState } from '../types/hook-types.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { SidebarInfo, - ThreadInfo, + LegacyThreadInfo, RawThreadInfo, } from '../types/thread-types.js'; import { useSelector } from '../utils/redux-utils.js'; export type ThreadSearchState = { +text: string, +results: $ReadOnlySet, }; type SearchThreadsResult = { +listData: $ReadOnlyArray, +searchState: ThreadSearchState, +setSearchState: SetState, +onChangeSearchInputText: (text: string) => mixed, +clearQuery: (event: SyntheticEvent) => void, }; function useSearchThreads( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, childThreadInfos: $ReadOnlyArray, ): SearchThreadsResult { const [searchState, setSearchState] = React.useState({ text: '', results: new Set(), }); const listData = React.useMemo(() => { if (!searchState.text) { return childThreadInfos; } return childThreadInfos.filter(thread => searchState.results.has(thread.threadInfo.id), ); }, [childThreadInfos, searchState]); const justThreadInfos = React.useMemo( () => childThreadInfos.map(childThreadInfo => childThreadInfo.threadInfo), [childThreadInfos], ); const searchIndex = useThreadSearchIndex(justThreadInfos); const onChangeSearchInputText = React.useCallback( (text: string) => { setSearchState({ text, results: new Set(searchIndex.getSearchResults(text)), }); }, [searchIndex, setSearchState], ); const clearQuery = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); setSearchState({ text: '', results: new Set() }); }, [setSearchState], ); return React.useMemo( () => ({ listData, searchState, setSearchState, onChangeSearchInputText, clearQuery, }), [ listData, setSearchState, searchState, onChangeSearchInputText, clearQuery, ], ); } function useSearchSidebars( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): SearchThreadsResult { const childThreadInfos = useSelector( state => sidebarInfoSelector(state)[threadInfo.id] ?? [], ); return useSearchThreads(threadInfo, childThreadInfos); } function useSearchSubchannels( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): SearchThreadsResult { const filterFunc = React.useCallback( - (thread: ?(ThreadInfo | MinimallyEncodedThreadInfo | RawThreadInfo)) => - threadIsChannel(thread) && thread?.parentThreadID === threadInfo.id, + ( + thread: ?(LegacyThreadInfo | MinimallyEncodedThreadInfo | RawThreadInfo), + ) => threadIsChannel(thread) && thread?.parentThreadID === threadInfo.id, [threadInfo.id], ); const childThreadInfos = useFilteredChatListData(filterFunc); return useSearchThreads(threadInfo, childThreadInfos); } export { useSearchSubchannels, useSearchSidebars }; diff --git a/lib/hooks/toggle-unread-status.js b/lib/hooks/toggle-unread-status.js index 3adfeda3e..5a88848fb 100644 --- a/lib/hooks/toggle-unread-status.js +++ b/lib/hooks/toggle-unread-status.js @@ -1,55 +1,55 @@ // @flow import * as React from 'react'; import { useSetThreadUnreadStatus, setThreadUnreadStatusActionTypes, } from '../actions/activity-actions.js'; import type { SetThreadUnreadStatusPayload, SetThreadUnreadStatusRequest, } from '../types/activity-types.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from '../types/thread-types.js'; +import type { LegacyThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/action-utils.js'; function useToggleUnreadStatus( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, mostRecentNonLocalMessage: ?string, afterAction: () => void, ): () => void { const dispatchActionPromise = useDispatchActionPromise(); const { currentUser } = threadInfo; const boundSetThreadUnreadStatus: ( request: SetThreadUnreadStatusRequest, ) => Promise = useSetThreadUnreadStatus(); const toggleUnreadStatus = React.useCallback(() => { const request = { threadID: threadInfo.id, unread: !currentUser.unread, latestMessage: mostRecentNonLocalMessage, }; dispatchActionPromise( setThreadUnreadStatusActionTypes, boundSetThreadUnreadStatus(request), undefined, ({ threadID: threadInfo.id, unread: !currentUser.unread, }: { +threadID: string, +unread: boolean }), ); afterAction(); }, [ threadInfo.id, currentUser.unread, mostRecentNonLocalMessage, dispatchActionPromise, afterAction, boundSetThreadUnreadStatus, ]); return toggleUnreadStatus; } export default useToggleUnreadStatus; diff --git a/lib/permissions/minimally-encoded-thread-permissions-validators.js b/lib/permissions/minimally-encoded-thread-permissions-validators.js index 678eab77d..07f638a56 100644 --- a/lib/permissions/minimally-encoded-thread-permissions-validators.js +++ b/lib/permissions/minimally-encoded-thread-permissions-validators.js @@ -1,86 +1,86 @@ // @flow import t, { type TInterface } from 'tcomb'; import { tHexEncodedPermissionsBitmask, tHexEncodedRolePermission, } from './minimally-encoded-thread-permissions.js'; import type { MinimallyEncodedMemberInfo, MinimallyEncodedRawThreadInfo, MinimallyEncodedRelativeMemberInfo, MinimallyEncodedResolvedThreadInfo, MinimallyEncodedRoleInfo, MinimallyEncodedThreadCurrentUserInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { legacyMemberInfoValidator, legacyRawThreadInfoValidator, legacyRoleInfoValidator, threadCurrentUserInfoValidator, - threadInfoValidator, + legacyThreadInfoValidator, } from '../types/thread-types.js'; import { tBool, tID, tShape } from '../utils/validation-utils.js'; const minimallyEncodedRoleInfoValidator: TInterface = tShape({ ...legacyRoleInfoValidator.meta.props, minimallyEncoded: tBool(true), permissions: t.list(tHexEncodedRolePermission), }); const minimallyEncodedThreadCurrentUserInfoValidator: TInterface = tShape({ ...threadCurrentUserInfoValidator.meta.props, minimallyEncoded: tBool(true), permissions: tHexEncodedPermissionsBitmask, }); const minimallyEncodedMemberInfoValidator: TInterface = tShape({ ...legacyMemberInfoValidator.meta.props, minimallyEncoded: tBool(true), permissions: tHexEncodedPermissionsBitmask, }); const minimallyEncodedRelativeMemberInfoValidator: TInterface = tShape({ ...minimallyEncodedMemberInfoValidator.meta.props, username: t.maybe(t.String), isViewer: t.Boolean, }); const minimallyEncodedThreadInfoValidator: TInterface = tShape({ - ...threadInfoValidator.meta.props, + ...legacyThreadInfoValidator.meta.props, minimallyEncoded: tBool(true), members: t.list(minimallyEncodedRelativeMemberInfoValidator), roles: t.dict(tID, minimallyEncodedRoleInfoValidator), currentUser: minimallyEncodedThreadCurrentUserInfoValidator, }); const minimallyEncodedResolvedThreadInfoValidator: TInterface = tShape({ ...minimallyEncodedThreadInfoValidator.meta.props, uiName: t.String, }); const minimallyEncodedRawThreadInfoValidator: TInterface = tShape({ ...legacyRawThreadInfoValidator.meta.props, minimallyEncoded: tBool(true), members: t.list(minimallyEncodedMemberInfoValidator), roles: t.dict(tID, minimallyEncodedRoleInfoValidator), currentUser: minimallyEncodedThreadCurrentUserInfoValidator, }); export { minimallyEncodedRoleInfoValidator, minimallyEncodedThreadCurrentUserInfoValidator, minimallyEncodedMemberInfoValidator, minimallyEncodedRelativeMemberInfoValidator, minimallyEncodedThreadInfoValidator, minimallyEncodedResolvedThreadInfoValidator, minimallyEncodedRawThreadInfoValidator, }; diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 617e9ed66..a3d5b3adf 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,708 +1,718 @@ // @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 { 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 { messageTypes } from '../types/message-types-enum.js'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, isComposableMessageType, } from '../types/message-types.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import { - type ThreadInfo, + type LegacyThreadInfo, type RawThreadInfo, type SidebarInfo, maxReadSidebars, maxUnreadSidebars, } 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'; import { useSelector } from '../utils/redux-utils.js'; export type SidebarItem = | { ...SidebarInfo, +type: 'sidebar', } | { +type: 'seeMore', +unread: boolean, } | { +type: 'spacer' }; export type ChatThreadItem = { +type: 'chatThreadItem', - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray, +pendingPersonalThreadUserInfo?: UserInfo, }; 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 | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, 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 | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, mostRecentMessageInfo: ?MessageInfo, ): number { return mostRecentMessageInfo ? mostRecentMessageInfo.time : threadInfo.creationTime; } function createChatThreadItem( - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, 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: SidebarItem[] = []; 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 }, + threadInfos: { +[id: string]: LegacyThreadInfo }, 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 | MinimallyEncodedThreadInfo | RawThreadInfo), + threadInfo: ?( + | LegacyThreadInfo + | MinimallyEncodedThreadInfo + | 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 }, + threadInfos: { +[id: string]: LegacyThreadInfo }, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, sidebarInfos: { +[id: string]: $ReadOnlyArray }, filterFunction: ( - threadInfo: ?(ThreadInfo | MinimallyEncodedThreadInfo | RawThreadInfo), + threadInfo: ?( + | LegacyThreadInfo + | MinimallyEncodedThreadInfo + | RawThreadInfo + ), ) => boolean, ): $ReadOnlyArray { return _flow( _filter(filterFunction), - _map((threadInfo: ThreadInfo): ChatThreadItem => + _map((threadInfo: LegacyThreadInfo): 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 | ?MinimallyEncodedThreadInfo, + +threadCreatedFromMessage: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, +reactions: ReactionInfo, }; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | { +itemType: 'message', +messageInfoType: 'composable', +messageInfo: ComposableMessageInfo, +localMessageInfo: ?LocalMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, - +threadCreatedFromMessage: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +threadCreatedFromMessage: + | ?LegacyThreadInfo + | ?MinimallyEncodedThreadInfo, +reactions: ReactionInfo, +hasBeenEdited: boolean, +isPinned: boolean, }; export type ChatMessageItem = { itemType: 'loader' } | ChatMessageInfoItem; export type ReactionInfo = { +[reaction: string]: MessageReactionInfo }; 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 }, + threadInfos: { +[id: string]: LegacyThreadInfo }, + threadInfoFromSourceMessageID: { +[id: string]: LegacyThreadInfo }, 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 targetMessageEditMap = new Map(); for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.EDIT_MESSAGE) { continue; } targetMessageEditMap.set(messageInfo.targetMessageID, messageInfo.text); } const targetMessagePinStatusMap = new Map(); // Once again, we iterate backwards to put the order of messages in // chronological order (i.e. oldest to newest) to handle pinned messages. // This is important because we want to make sure that the most recent pin // action is the one that is used to determine whether a message // is pinned or not. for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.TOGGLE_PIN) { continue; } targetMessagePinStatusMap.set( messageInfo.targetMessageID, messageInfo.action === 'pin', ); } const chatMessageItems: ChatMessageItem[] = []; let lastMessageInfo = null; for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if ( messageInfo.type === messageTypes.REACTION || messageInfo.type === messageTypes.EDIT_MESSAGE ) { continue; } let originalMessageInfo = messageInfo.type === messageTypes.SIDEBAR_SOURCE ? messageInfo.sourceMessage : messageInfo; if (isEmptyMediaMessage(originalMessageInfo)) { continue; } let hasBeenEdited = false; if ( originalMessageInfo.type === messageTypes.TEXT && originalMessageInfo.id ) { const newText = targetMessageEditMap.get(originalMessageInfo.id); if (newText !== undefined) { hasBeenEdited = true; originalMessageInfo = { ...originalMessageInfo, text: newText, }; } } 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 isPinned = !!( originalMessageInfo.id && targetMessagePinStatusMap.get(originalMessageInfo.id) ); const renderedReactions: ReactionInfo = (() => { const result: { [string]: MessageReactionInfo } = {}; let messageReactsMap; if (originalMessageInfo.id) { messageReactsMap = targetMessageReactionsMap.get( originalMessageInfo.id, ); } if (!messageReactsMap) { return result; } for (const reaction of messageReactsMap.keys()) { const reactionUsersInfoMap = messageReactsMap.get(reaction); invariant(reactionUsersInfoMap, 'reactionUsersInfoMap should be set'); if (reactionUsersInfoMap.size === 0) { continue; } const reactionUserInfos = [...reactionUsersInfoMap.values()]; const messageReactionInfo = { users: reactionUserInfos, viewerReacted: reactionUsersInfoMap.has(viewerID), }; result[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, hasBeenEdited, isPinned, }); } else { invariant( originalMessageInfo.type !== messageTypes.TEXT && originalMessageInfo.type !== messageTypes.IMAGES && originalMessageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo?.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; const robotext = robotextForMessageInfo( originalMessageInfo, threadInfo, parentThreadInfo, ); 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, ): ((state: BaseAppState<>) => ?(ChatMessageItem[])) => 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 }, + threadInfos: { +[id: string]: LegacyThreadInfo }, + threadInfoFromSourceMessageID: { +[id: string]: LegacyThreadInfo }, viewerID: ?string, ): ?(ChatMessageItem[]) => { if (!threadID || !viewerID) { return null; } return createChatMessageItems( threadID, messageStore, messageInfos, threadInfos, threadInfoFromSourceMessageID, additionalMessages, viewerID, ); }, ); export type MessageListData = ?(ChatMessageItem[]); const messageListData: ( threadID: ?string, additionalMessages: $ReadOnlyArray, ) => (state: BaseAppState<>) => MessageListData = memoize2(baseMessageListData); export type UseMessageListDataArgs = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, - +threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, }; function useMessageListData({ searching, userInfoInputArray, threadInfo, }: UseMessageListDataArgs): MessageListData { const messageInfos = useSelector(messageInfoSelector); const containingThread = useSelector(state => { if ( !threadInfo || threadInfo.type !== threadTypes.SIDEBAR || !threadInfo.containingThreadID ) { return null; } return state.messageStore.threads[threadInfo.containingThreadID]; }); const pendingSidebarEditMessageInfo = React.useMemo(() => { const sourceMessageID = threadInfo?.sourceMessageID; const threadMessageInfos = (containingThread?.messageIDs ?? []) .map((messageID: string) => messageInfos[messageID]) .filter(Boolean) .filter( message => message.type === messageTypes.EDIT_MESSAGE && message.targetMessageID === sourceMessageID, ); if (threadMessageInfos.length === 0) { return null; } return threadMessageInfos[0]; }, [threadInfo, containingThread, messageInfos]); 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 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(() => { if (!pendingSidebarSourceMessageInfo) { return ([]: MessageInfo[]); } const result: MessageInfo[] = [pendingSidebarSourceMessageInfo]; if (pendingSidebarEditMessageInfo) { result.push(pendingSidebarEditMessageInfo); } return result; }, [pendingSidebarSourceMessageInfo, pendingSidebarEditMessageInfo]); 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/nav-selectors.js b/lib/selectors/nav-selectors.js index 2939a3ccf..ebd4959a7 100644 --- a/lib/selectors/nav-selectors.js +++ b/lib/selectors/nav-selectors.js @@ -1,175 +1,175 @@ // @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 { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-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 type { RawThreadInfo, LegacyThreadInfo } from '../types/thread-types.js'; import type { UserInfo } from '../types/user-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, }; }; }, ); // Without allAtOnce, useThreadSearchIndex is very expensive. useENSNames would // trigger its recalculation for each ENS name as it streams in, but we would // prefer to trigger its recaculation just once for every update of the // underlying Redux data. const useENSNamesOptions = { allAtOnce: true }; function useThreadSearchIndex( threadInfos: $ReadOnlyArray< - RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, >, ): 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, useENSNamesOptions, ); 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], ); return useThreadSearchIndex(threadInfosArray); } export { timeUntilCalendarRangeExpiration, currentCalendarQuery, useThreadSearchIndex, useGlobalThreadSearchIndex, }; diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js index 84e8d8658..5df0a2993 100644 --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -1,530 +1,537 @@ // @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 genesis from '../facts/genesis.js'; import { getAvatarForThread, getRandomDefaultEmojiAvatar, } from '../shared/avatar-utils.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 { ClientAvatar, ClientEmojiAvatar } from '../types/avatar-types'; import type { EntryInfo } from '../types/entry-types.js'; import type { MessageStore, RawMessageInfo } from '../types/message-types.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { threadTypes, threadTypeIsCommunityRoot, type ThreadType, } from '../types/thread-types-enum.js'; import { - type ThreadInfo, + type LegacyThreadInfo, type LegacyRawThreadInfo, type SidebarInfo, type LegacyRawThreadInfos, type RelativeMemberInfo, } 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, + +[id: string]: LegacyThreadInfo, }; 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( +) => $ReadOnlyArray = createSelector( threadInfoSelector, - (threadInfos: { +[id: string]: ThreadInfo }) => { + (threadInfos: { +[id: string]: LegacyThreadInfo }) => { 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( +) => $ReadOnlyArray = createSelector( threadInfoSelector, - (threadInfos: { +[id: string]: ThreadInfo }) => { + (threadInfos: { +[id: string]: LegacyThreadInfo }) => { 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( +) => $ReadOnlyArray = createSelector( filteredThreadIDsSelector, canBeOnScreenThreadInfos, ( inputThreadIDs: ?$ReadOnlySet, - threadInfos: $ReadOnlyArray, - ): $ReadOnlyArray => { + threadInfos: $ReadOnlyArray, + ): $ReadOnlyArray => { const threadIDs = inputThreadIDs; if (!threadIDs) { return threadInfos; } return threadInfos.filter(threadInfo => threadIDs.has(threadInfo.id)); }, ); const onScreenEntryEditableThreadInfos: ( state: BaseAppState<>, -) => $ReadOnlyArray = createSelector( +) => $ReadOnlyArray = createSelector( onScreenThreadInfos, - (threadInfos: $ReadOnlyArray): $ReadOnlyArray => + ( + threadInfos: $ReadOnlyArray, + ): $ReadOnlyArray => threadInfos.filter(threadInfo => threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES), ), ); 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( 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, + onScreen: $ReadOnlyArray, includeDeleted: boolean, ) => { const allDaysWithinRange: { [string]: string[] } = {}, 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, + +[id: string]: $ReadOnlyArray, } = createSelector( threadInfoSelector, - (threadInfos: { +[id: string]: ThreadInfo }) => { - const result: { [string]: ThreadInfo[] } = {}; + (threadInfos: { +[id: string]: LegacyThreadInfo }) => { + const result: { [string]: LegacyThreadInfo[] } = {}; 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] = ([]: ThreadInfo[]); + result[parentThreadID] = ([]: LegacyThreadInfo[]); } result[parentThreadID].push(threadInfo); } return result; }, ); const containedThreadInfos: (state: BaseAppState<>) => { - +[id: string]: $ReadOnlyArray, + +[id: string]: $ReadOnlyArray, } = createSelector( threadInfoSelector, - (threadInfos: { +[id: string]: ThreadInfo }) => { - const result: { [string]: ThreadInfo[] } = {}; + (threadInfos: { +[id: string]: LegacyThreadInfo }) => { + const result: { [string]: LegacyThreadInfo[] } = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; const { containingThreadID } = threadInfo; if (containingThreadID === null || containingThreadID === undefined) { continue; } if (result[containingThreadID] === undefined) { - result[containingThreadID] = ([]: ThreadInfo[]); + result[containingThreadID] = ([]: LegacyThreadInfo[]); } result[containingThreadID].push(threadInfo); } return result; }, ); function getMostRecentRawMessageInfo( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, 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( childThreadInfos, (state: BaseAppState<>) => state.messageStore, - (childThreads: $ReadOnlyArray, messageStore: 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: LegacyRawThreadInfos): number => values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const unreadBackgroundCount: (state: BaseAppState<>) => number = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: LegacyRawThreadInfos): number => values(threadInfos).filter( threadInfo => threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const baseAncestorThreadInfos: ( threadID: string, -) => (BaseAppState<>) => $ReadOnlyArray = (threadID: string) => +) => (BaseAppState<>) => $ReadOnlyArray = ( + threadID: string, +) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state), (threadInfos: { - +[id: string]: ThreadInfo, - }): $ReadOnlyArray => { - const pathComponents: ThreadInfo[] = []; - let node: ?ThreadInfo = threadInfos[threadID]; + +[id: string]: LegacyThreadInfo, + }): $ReadOnlyArray => { + const pathComponents: LegacyThreadInfo[] = []; + let node: ?LegacyThreadInfo = 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( +) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseAncestorThreadInfos, ); const baseOtherUsersButNoOtherAdmins: ( threadID: string, ) => (BaseAppState<>) => boolean = (threadID: string) => createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos[threadID], relativeMemberInfoSelectorForMembersOfThread(threadID), ( threadInfo: ?LegacyRawThreadInfo, 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: LegacyRawThreadInfos, ): ?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 threadInfoFromSourceMessageIDSelector: (state: BaseAppState<>) => { - +[id: string]: ThreadInfo, + +[id: string]: LegacyThreadInfo, } = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, threadInfoSelector, ( rawThreadInfos: LegacyRawThreadInfos, - threadInfos: { +[id: string]: ThreadInfo }, + threadInfos: { +[id: string]: LegacyThreadInfo }, ) => { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector(rawThreadInfos); - const result: { [string]: ThreadInfo } = {}; + const result: { [string]: LegacyThreadInfo } = {}; for (const realizedID of pendingToRealizedThreadIDs.values()) { const threadInfo = threadInfos[realizedID]; if (threadInfo && threadInfo.sourceMessageID) { result[threadInfo.sourceMessageID] = threadInfo; } } return result; }, ); const pendingToRealizedThreadIDsSelector: ( rawThreadInfos: LegacyRawThreadInfos, ) => $ReadOnlyMap = createSelector( (rawThreadInfos: LegacyRawThreadInfos) => rawThreadInfos, (rawThreadInfos: LegacyRawThreadInfos) => { const result = new Map(); for (const threadID in rawThreadInfos) { const rawThreadInfo = rawThreadInfos[threadID]; if ( threadIsPending(threadID) || (rawThreadInfo.parentThreadID !== genesis.id && rawThreadInfo.type !== threadTypes.SIDEBAR) ) { 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; }, ); const baseSavedEmojiAvatarSelectorForThread: ( threadID: string, containingThreadID: ?string, ) => (BaseAppState<>) => () => ClientAvatar = ( threadID: string, containingThreadID: ?string, ) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state)[threadID], (state: BaseAppState<>) => containingThreadID ? threadInfoSelector(state)[containingThreadID] : null, - (threadInfo: ThreadInfo, containingThreadInfo: ?ThreadInfo) => { + (threadInfo: LegacyThreadInfo, containingThreadInfo: ?LegacyThreadInfo) => { return () => { let threadAvatar = getAvatarForThread(threadInfo, containingThreadInfo); if (threadAvatar.type !== 'emoji') { threadAvatar = getRandomDefaultEmojiAvatar(); } return threadAvatar; }; }, ); const savedEmojiAvatarSelectorForThread: ( threadID: string, containingThreadID: ?string, ) => (state: BaseAppState<>) => () => ClientEmojiAvatar = _memoize( baseSavedEmojiAvatarSelectorForThread, ); const baseThreadInfosSelectorForThreadType: ( threadType: ThreadType, -) => (BaseAppState<>) => $ReadOnlyArray = ( +) => (BaseAppState<>) => $ReadOnlyArray = ( threadType: ThreadType, ) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state), (threadInfos: { - +[id: string]: ThreadInfo, - }): $ReadOnlyArray => { + +[id: string]: LegacyThreadInfo, + }): $ReadOnlyArray => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.type === threadType) { result.push(threadInfo); } } return result; }, ); const threadInfosSelectorForThreadType: ( threadType: ThreadType, -) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( +) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseThreadInfosSelectorForThreadType, ); export { ancestorThreadInfos, threadInfoSelector, communityThreadSelector, onScreenThreadInfos, onScreenEntryEditableThreadInfos, entryInfoSelector, currentDaysToEntries, childThreadInfos, containedThreadInfos, unreadCount, unreadBackgroundCount, otherUsersButNoOtherAdmins, mostRecentlyReadThread, mostRecentlyReadThreadSelector, sidebarInfoSelector, threadInfoFromSourceMessageIDSelector, pendingToRealizedThreadIDsSelector, savedEmojiAvatarSelectorForThread, threadInfosSelectorForThreadType, }; diff --git a/lib/shared/ancestor-threads.js b/lib/shared/ancestor-threads.js index 63df1085b..7181364a0 100644 --- a/lib/shared/ancestor-threads.js +++ b/lib/shared/ancestor-threads.js @@ -1,30 +1,30 @@ // @flow import genesis from '../facts/genesis.js'; import { threadInfoSelector, ancestorThreadInfos, } from '../selectors/thread-selectors.js'; import { threadIsPending } from '../shared/thread-utils.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import { type ThreadInfo } from '../types/thread-types.js'; +import { type LegacyThreadInfo } from '../types/thread-types.js'; import { useSelector } from '../utils/redux-utils.js'; function useAncestorThreads( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, -): $ReadOnlyArray { + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +): $ReadOnlyArray { return useSelector(state => { if (!threadIsPending(threadInfo.id)) { const ancestorThreads = ancestorThreadInfos(threadInfo.id)(state); if (ancestorThreads.length > 1) { return ancestorThreads.slice(0, -1); } return ancestorThreads; } const genesisThreadInfo = threadInfoSelector(state)[genesis.id]; return genesisThreadInfo ? [genesisThreadInfo] : []; }); } export { useAncestorThreads }; diff --git a/lib/shared/avatar-utils.js b/lib/shared/avatar-utils.js index 3814e603f..298e7630c 100644 --- a/lib/shared/avatar-utils.js +++ b/lib/shared/avatar-utils.js @@ -1,366 +1,366 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import stringHash from 'string-hash'; import { selectedThreadColors } from './color-utils.js'; import { threadOtherMembers } from './thread-utils.js'; import genesis from '../facts/genesis.js'; import { useENSAvatar } from '../hooks/ens-cache.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { getETHAddressForUserInfo } from '../shared/account-utils.js'; import type { ClientEmojiAvatar, ClientAvatar, ResolvedClientAvatar, GenericUserInfoWithAvatar, } from '../types/avatar-types.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; -import type { ThreadInfo, RawThreadInfo } from '../types/thread-types.js'; +import type { LegacyThreadInfo, RawThreadInfo } from '../types/thread-types.js'; import type { UserInfos } from '../types/user-types.js'; import { useSelector } from '../utils/redux-utils.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; const defaultAnonymousUserEmojiAvatar: ClientEmojiAvatar = { color: selectedThreadColors[4], emoji: '👤', type: 'emoji', }; const defaultEmojiAvatars: $ReadOnlyArray = [ { color: selectedThreadColors[0], emoji: '😀', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '😃', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😄', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😁', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '😆', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🙂', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '😉', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '😊', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '😇', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🥰', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '😍', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🤩', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🥳', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '😝', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😎', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🧐', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🥸', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🤗', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '😤', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🤯', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🤔', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🫡', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🤫', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😮', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '😲', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🤠', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🤑', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '👩‍🚀', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🥷', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '👻', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '👾', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🤖', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😺', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '😸', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '😹', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '😻', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🎩', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '👑', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🐶', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🐱', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐭', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🐹', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🐰', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐻', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐼', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐻‍❄️', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐨', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🐯', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🦁', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐸', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐔', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🐧', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐦', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐤', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🦄', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐝', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🦋', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐬', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐳', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐋', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🦈', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦭', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐘', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🦛', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐓', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🦃', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🦩', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🦔', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐅', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐆', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🦓', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦒', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🦘', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐎', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐕', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐩', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🦮', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐈', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🦚', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦜', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦢', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🕊️', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐇', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🦦', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐿️', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🐉', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🌴', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🌱', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '☘️', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍀', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🍄', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🌿', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🪴', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍁', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '💐', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🌷', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🌹', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🌸', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🌻', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '⭐', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🌟', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🍏', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍎', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🍊', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍋', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍓', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🫐', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍈', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🍒', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🥭', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍍', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🥝', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍅', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🥦', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🥕', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🥐', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🥯', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍞', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🥖', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🥨', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🧀', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🥞', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🧇', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🥓', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍔', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍟', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🍕', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🥗', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍝', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🍜', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🍲', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍛', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍣', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🍱', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🥟', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍤', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🍙', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🍚', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍥', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🍦', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🧁', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🍭', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍩', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🍪', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '☕️', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍵', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '⚽️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏀', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🏈', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '⚾️', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🥎', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🎾', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🏉', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🎱', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🏆', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🎨', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🎤', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🎧', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🎼', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🎹', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🥁', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🎷', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🎺', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🎸', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🪕', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🎻', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🎲', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '♟️', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🎮', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🚗', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🚙', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🚌', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🏎️', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🛻', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🚚', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🚛', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🚘', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🚀', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🚁', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🛶', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '⛵️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🚤', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '⚓', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🏰', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🎡', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '💎', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🔮', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '💈', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🧸', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🎊', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🎉', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🪩', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🚂', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🚆', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🚊', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🛰️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏠', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '⛰️', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🏔️', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🗻', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🏛️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '⛩️', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🧲', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🎁', type: 'emoji' }, ]; function getRandomDefaultEmojiAvatar(): ClientEmojiAvatar { const randomIndex = Math.floor(Math.random() * defaultEmojiAvatars.length); return defaultEmojiAvatars[randomIndex]; } function getDefaultAvatar(hashKey: string, color?: string): ClientEmojiAvatar { let key = hashKey; if (key.startsWith(`${ashoatKeyserverID}|`)) { key = key.slice(`${ashoatKeyserverID}|`.length); } const avatarIndex = stringHash(key) % defaultEmojiAvatars.length; return { ...defaultEmojiAvatars[avatarIndex], color: color ? color : defaultEmojiAvatars[avatarIndex].color, }; } function getAvatarForUser(user: ?GenericUserInfoWithAvatar): ClientAvatar { if (user?.avatar) { return user.avatar; } if (!user?.username) { return defaultAnonymousUserEmojiAvatar; } return getDefaultAvatar(user.username); } function getUserAvatarForThread( - threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ClientAvatar { if (threadInfo.type === threadTypes.PRIVATE) { invariant(viewerID, 'viewerID should be set for PRIVATE threads'); return getAvatarForUser(userInfos[viewerID]); } invariant( threadInfo.type === threadTypes.PERSONAL, 'threadInfo should be a PERSONAL type', ); const memberInfos = threadOtherMembers(threadInfo.members, viewerID) .map(member => userInfos[member.id] && userInfos[member.id]) .filter(Boolean); if (memberInfos.length === 0) { return defaultAnonymousUserEmojiAvatar; } return getAvatarForUser(memberInfos[0]); } function getAvatarForThread( - thread: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, - containingThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + thread: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, + containingThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ): ClientAvatar { if (thread.avatar) { return thread.avatar; } if (containingThreadInfo && containingThreadInfo.id !== genesis.id) { return containingThreadInfo.avatar ? containingThreadInfo.avatar : getDefaultAvatar(containingThreadInfo.id, containingThreadInfo.color); } return getDefaultAvatar(thread.id, thread.color); } function useAvatarForThread( - thread: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + thread: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, ): ClientAvatar { const containingThreadID = thread.containingThreadID; const containingThreadInfo = useSelector(state => containingThreadID ? threadInfoSelector(state)[containingThreadID] : null, ); return getAvatarForThread(thread, containingThreadInfo); } function useENSResolvedAvatar( avatarInfo: ClientAvatar, userInfo: ?GenericUserInfoWithAvatar, ): ResolvedClientAvatar { const ethAddress = React.useMemo( () => getETHAddressForUserInfo(userInfo), [userInfo], ); const ensAvatarURI = useENSAvatar(ethAddress); const resolvedAvatar = React.useMemo(() => { if (avatarInfo.type !== 'ens') { return avatarInfo; } if (ensAvatarURI) { return { type: 'image', uri: ensAvatarURI, }; } return defaultAnonymousUserEmojiAvatar; }, [ensAvatarURI, avatarInfo]); return resolvedAvatar; } export { defaultAnonymousUserEmojiAvatar, defaultEmojiAvatars, getRandomDefaultEmojiAvatar, getDefaultAvatar, getAvatarForUser, getUserAvatarForThread, getAvatarForThread, useAvatarForThread, useENSResolvedAvatar, }; diff --git a/lib/shared/edit-messages-utils.js b/lib/shared/edit-messages-utils.js index 385473a9d..7e551520d 100644 --- a/lib/shared/edit-messages-utils.js +++ b/lib/shared/edit-messages-utils.js @@ -1,86 +1,86 @@ // @flow import * as React from 'react'; import { threadIsPending, threadHasPermission } from './thread-utils.js'; import { sendEditMessageActionTypes, useSendEditMessage, } from '../actions/message-actions.js'; import type { SendEditMessageResult, RobotextMessageInfo, ComposableMessageInfo, RawMessageInfo, } from '../types/message-types'; import { messageTypes } from '../types/message-types-enum.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; -import { type ThreadInfo } from '../types/thread-types.js'; +import { type LegacyThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; function useEditMessage(): ( messageID: string, newText: string, ) => Promise { const callEditMessage = useSendEditMessage(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( (messageID, newText) => { const editMessagePromise = (async () => { const result = await callEditMessage({ targetMessageID: messageID, text: newText, }); return ({ newMessageInfos: result.newMessageInfos, }: { +newMessageInfos: $ReadOnlyArray }); })(); dispatchActionPromise(sendEditMessageActionTypes, editMessagePromise); return editMessagePromise; }, [dispatchActionPromise, callEditMessage], ); } function useCanEditMessage( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const currentUserInfo = useSelector(state => state.currentUserInfo); if (targetMessageInfo.type !== messageTypes.TEXT) { return false; } if (!currentUserInfo || !currentUserInfo.id) { return false; } const currentUserId = currentUserInfo.id; const targetMessageCreatorId = targetMessageInfo.creator.id; if (currentUserId !== targetMessageCreatorId) { return false; } const hasPermission = threadHasPermission( threadInfo, threadPermissions.EDIT_MESSAGE, ); return hasPermission; } function getMessageLabel(hasBeenEdited: ?boolean, threadID: string): ?string { const isPending = threadIsPending(threadID); if (hasBeenEdited && !isPending) { return 'Edited'; } return null; } export { useCanEditMessage, useEditMessage, getMessageLabel }; diff --git a/lib/shared/inline-engagement-utils.js b/lib/shared/inline-engagement-utils.js index 7cfa2a63b..c87ec213d 100644 --- a/lib/shared/inline-engagement-utils.js +++ b/lib/shared/inline-engagement-utils.js @@ -1,28 +1,28 @@ // @flow import type { ReactionInfo } from '../selectors/chat-selectors.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from '../types/thread-types.js'; +import type { LegacyThreadInfo } from '../types/thread-types.js'; function getInlineEngagementSidebarText( - threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ): string { if (!threadInfo) { return ''; } const repliesCount = threadInfo.repliesCount || 1; return `${repliesCount} ${repliesCount > 1 ? 'replies' : 'reply'}`; } function reactionsToRawString(reactions: ReactionInfo): string { const reactionStringParts = []; for (const reaction in reactions) { const reactionInfo = reactions[reaction]; reactionStringParts.push(`${reaction}${reactionInfo.users.length}`); } return reactionStringParts.join(''); } export { getInlineEngagementSidebarText, reactionsToRawString }; diff --git a/lib/shared/mention-utils.js b/lib/shared/mention-utils.js index 864e2b8c3..0feb41afb 100644 --- a/lib/shared/mention-utils.js +++ b/lib/shared/mention-utils.js @@ -1,196 +1,196 @@ // @flow import { oldValidUsernameRegexString } from './account-utils.js'; import SentencePrefixSearchIndex from './sentence-prefix-search-index.js'; import { threadOtherMembers } from './thread-utils.js'; import { stringForUserExplicit } from './user-utils.js'; import type { MinimallyEncodedResolvedThreadInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { - ThreadInfo, + LegacyThreadInfo, ResolvedThreadInfo, ChatMentionCandidates, RelativeMemberInfo, } from '../types/thread-types.js'; import { idSchemaRegex, chatNameMaxLength } from '../utils/validation-utils.js'; export type TypeaheadMatchedStrings = { +textBeforeAtSymbol: string, +query: string, }; export type Selection = { +start: number, +end: number, }; type MentionTypeaheadUserSuggestionItem = { +type: 'user', +userInfo: RelativeMemberInfo, }; type MentionTypeaheadChatSuggestionItem = { +type: 'chat', +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, }; export type MentionTypeaheadSuggestionItem = | MentionTypeaheadUserSuggestionItem | MentionTypeaheadChatSuggestionItem; export type TypeaheadTooltipActionItem = { +key: string, +execute: () => mixed, +actionButtonContent: SuggestionItemType, }; // The simple-markdown package already breaks words out for us, and we are // supposed to only match when the first word of the input matches const markdownUserMentionRegex: RegExp = new RegExp( `^(@(${oldValidUsernameRegexString}))\\b`, ); function isUserMentioned(username: string, text: string): boolean { return new RegExp(`\\B@${username}\\b`, 'i').test(text); } const userMentionsExtractionRegex = new RegExp( `\\B(@(${oldValidUsernameRegexString}))\\b`, 'g', ); const chatMentionRegexString = `([^\\\\]|^)(@\\[\\[(${idSchemaRegex}):((.{0,${chatNameMaxLength}}?)(?!\\\\).|^)\\]\\])`; const chatMentionRegex: RegExp = new RegExp(`^${chatMentionRegexString}`); const globalChatMentionRegex: RegExp = new RegExp(chatMentionRegexString, 'g'); function encodeChatMentionText(text: string): string { return text.replace(/]/g, '\\]'); } function decodeChatMentionText(text: string): string { return text.replace(/\\]/g, ']'); } function getRawChatMention( threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, ): string { return `@[[${threadInfo.id}:${encodeChatMentionText(threadInfo.uiName)}]]`; } function renderChatMentionsWithAltText(text: string): string { return text.replace( globalChatMentionRegex, (...match) => `${match[1]}@${decodeChatMentionText(match[4])}`, ); } function extractUserMentionsFromText(text: string): string[] { const iterator = text.matchAll(userMentionsExtractionRegex); return [...iterator].map(matches => matches[2]); } function getTypeaheadRegexMatches( text: string, selection: Selection, regex: RegExp, ): null | RegExp$matchResult { if ( selection.start === selection.end && (selection.start === text.length || /\s/.test(text[selection.end])) ) { return text.slice(0, selection.start).match(regex); } return null; } function getMentionTypeaheadUserSuggestions( userSearchIndex: SentencePrefixSearchIndex, threadMembers: $ReadOnlyArray, viewerID: ?string, usernamePrefix: string, ): $ReadOnlyArray { const userIDs = userSearchIndex.getSearchResults(usernamePrefix); const usersInThread = threadOtherMembers(threadMembers, viewerID); return usersInThread .filter(user => usernamePrefix.length === 0 || userIDs.includes(user.id)) .sort((userA, userB) => stringForUserExplicit(userA).localeCompare(stringForUserExplicit(userB)), ) .map(userInfo => ({ type: 'user', userInfo })); } function getMentionTypeaheadChatSuggestions( chatSearchIndex: SentencePrefixSearchIndex, chatMentionCandidates: ChatMentionCandidates, chatNamePrefix: string, ): $ReadOnlyArray { const result = []; const threadIDs = chatSearchIndex.getSearchResults(chatNamePrefix); for (const threadID of threadIDs) { if (!chatMentionCandidates[threadID]) { continue; } result.push({ type: 'chat', threadInfo: chatMentionCandidates[threadID], }); } return result; } function getNewTextAndSelection( textBeforeAtSymbol: string, entireText: string, query: string, suggestionText: string, ): { newText: string, newSelectionStart: number, } { const totalMatchLength = textBeforeAtSymbol.length + query.length + 1; // 1 for @ char let newSuffixText = entireText.slice(totalMatchLength); newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText; const newText = textBeforeAtSymbol + suggestionText + newSuffixText; const newSelectionStart = newText.length - newSuffixText.length + 1; return { newText, newSelectionStart }; } function getUserMentionsCandidates( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ): $ReadOnlyArray { if (threadInfo.type !== threadTypes.SIDEBAR) { return threadInfo.members; } if (parentThreadInfo) { return parentThreadInfo.members; } // This scenario should not occur unless the user logs out while looking at a // sidebar. In that scenario, the Redux store may be cleared before ReactNav // finishes transitioning away from the previous screen return []; } export { markdownUserMentionRegex, isUserMentioned, extractUserMentionsFromText, getMentionTypeaheadUserSuggestions, getMentionTypeaheadChatSuggestions, getNewTextAndSelection, getTypeaheadRegexMatches, getUserMentionsCandidates, chatMentionRegex, encodeChatMentionText, decodeChatMentionText, getRawChatMention, renderChatMentionsWithAltText, }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index 78d8bf75c..b59c506b3 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,749 +1,749 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy.js'; import _orderBy from 'lodash/fp/orderBy.js'; import * as React from 'react'; import { codeBlockRegex, type ParserRules } from './markdown.js'; import type { CreationSideEffectsFunc } from './messages/message-spec.js'; import { messageSpecs } from './messages/message-specs.js'; import { threadIsGroupChat } from './thread-utils.js'; import { useStringForUser } from '../hooks/ens-cache.js'; import { contentStringForMediaArray } from '../media/media-utils.js'; import type { ChatMessageInfoItem } from '../selectors/chat-selectors.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors.js'; import { type PlatformDetails, isWebPlatform } from '../types/device-types.js'; import type { Media } from '../types/media-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageTruncationStatus, type MultimediaMessageData, type MessageStore, type ComposableMessageInfo, messageTruncationStatus, type RawComposableMessageInfo, type ThreadMessageInfo, } from '../types/message-types.js'; import type { EditMessageInfo, RawEditMessageInfo, } from '../types/messages/edit.js'; import type { ImagesMessageData } from '../types/messages/images.js'; import type { MediaMessageData } from '../types/messages/media.js'; import type { RawReactionMessageInfo, ReactionMessageInfo, } from '../types/messages/reaction.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js'; +import type { RawThreadInfo, LegacyThreadInfo } from '../types/thread-types.js'; import type { UserInfos } from '../types/user-types.js'; import { extractKeyserverIDFromID } from '../utils/action-utils.js'; import { type EntityText, ET, useEntityTextAsString, } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; const localIDPrefix = 'local'; const defaultMediaMessageOptions = Object.freeze({}); // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, - threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ): EntityText { const messageSpec = messageSpecs[messageInfo.type]; invariant( messageSpec.robotext, `we're not aware of messageType ${messageInfo.type}`, ); return messageSpec.robotext(messageInfo, { threadInfo, parentThreadInfo }); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, - threadInfos: { +[id: string]: ThreadInfo }, + threadInfos: { +[id: string]: LegacyThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; const creator = { id: rawMessageInfo.creatorID, username: creatorInfo ? creatorInfo.username : 'anonymous', isViewer: rawMessageInfo.creatorID === viewerID, }; const createRelativeUserInfos = (userIDs: $ReadOnlyArray) => userIDsToRelativeUserInfos(userIDs, viewerID, userInfos); const createMessageInfoFromRaw = (rawInfo: RawMessageInfo) => createMessageInfo(rawInfo, viewerID, userInfos, threadInfos); const messageSpec = messageSpecs[rawMessageInfo.type]; return messageSpec.createMessageInfo(rawMessageInfo, creator, { threadInfos, createMessageInfoFromRaw, createRelativeUserInfos, }); } type LengthResult = { +local: number, +realized: number, }; function findMessageIDMaxLengths( messageIDs: $ReadOnlyArray, ): LengthResult { const result = { local: 0, realized: 0, }; for (const id of messageIDs) { if (!id) { continue; } if (id.startsWith(localIDPrefix)) { result.local = Math.max(result.local, id.length - localIDPrefix.length); } else { result.realized = Math.max(result.realized, id.length); } } return result; } function extendMessageID(id: ?string, lengths: LengthResult): ?string { if (!id) { return id; } if (id.startsWith(localIDPrefix)) { const zeroPaddedID = id .substr(localIDPrefix.length) .padStart(lengths.local, '0'); return `${localIDPrefix}${zeroPaddedID}`; } return id.padStart(lengths.realized, '0'); } function sortMessageInfoList( messageInfos: $ReadOnlyArray, ): T[] { const lengths = findMessageIDMaxLengths( messageInfos.map(message => message?.id), ); return _orderBy([ 'time', (message: T) => extendMessageID(message?.id, lengths), ])(['desc', 'desc'])(messageInfos); } const sortMessageIDs: (messages: { +[id: string]: RawMessageInfo }) => ( messageIDs: $ReadOnlyArray, ) => string[] = messages => messageIDs => { const lengths = findMessageIDMaxLengths(messageIDs); return _orderBy([ (id: string) => messages[id].time, (id: string) => extendMessageID(id, lengths), ])(['desc', 'desc'])(messageIDs); }; function rawMessageInfoFromMessageData( messageData: MessageData, id: ?string, ): RawMessageInfo { const messageSpec = messageSpecs[messageData.type]; invariant( messageSpec.rawMessageInfoFromMessageData, `we're not aware of messageType ${messageData.type}`, ); return messageSpec.rawMessageInfoFromMessageData(messageData, id); } function mostRecentMessageTimestamp( messageInfos: $ReadOnlyArray, previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (const messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function combineTruncationStatuses( first: MessageTruncationStatus, second: ?MessageTruncationStatus, ): MessageTruncationStatus { if ( first === messageTruncationStatus.EXHAUSTIVE || second === messageTruncationStatus.EXHAUSTIVE ) { return messageTruncationStatus.EXHAUSTIVE; } else if ( first === messageTruncationStatus.UNCHANGED && second !== null && second !== undefined ) { return second; } else { return first; } } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { if (platformDetails && isWebPlatform(platformDetails.platform)) { return [...rawMessageInfos]; } return rawMessageInfos.map(rawMessageInfo => { const { shimUnsupportedMessageInfo } = messageSpecs[rawMessageInfo.type]; if (shimUnsupportedMessageInfo) { return shimUnsupportedMessageInfo(rawMessageInfo, platformDetails); } return rawMessageInfo; }); } type MediaMessageDataCreationInput = { +threadID: string, +creatorID: string, +media: $ReadOnlyArray, +localID?: ?string, +time?: ?number, +sidebarCreation?: ?boolean, ... }; function createMediaMessageData( input: MediaMessageDataCreationInput, options: { +forceMultimediaMessageType?: boolean, } = defaultMediaMessageOptions, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (const singleMedia of input.media) { if (singleMedia.type !== 'photo') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID, sidebarCreation } = input; const { forceMultimediaMessageType = false } = options; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos && !forceMultimediaMessageType) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { messageData = { ...messageData, sidebarCreation }; } } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { messageData = { ...messageData, sidebarCreation }; } } return messageData; } type MediaMessageInfoCreationInput = { ...$Exact, +id?: ?string, }; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, options: { +forceMultimediaMessageType?: boolean, } = defaultMediaMessageOptions, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input, options); const createRawMessageInfo = messageSpecs[messageData.type].rawMessageInfoFromMessageData; invariant( createRawMessageInfo, 'multimedia message spec should have rawMessageInfoFromMessageData', ); const result = createRawMessageInfo(messageData, input.id); invariant( result.type === messageTypes.MULTIMEDIA || result.type === messageTypes.IMAGES, `media messageSpec returned MessageType ${result.type}`, ); return result; } function stripLocalID( rawMessageInfo: | RawComposableMessageInfo | RawReactionMessageInfo | RawEditMessageInfo, ) { const { localID, ...rest } = rawMessageInfo; return rest; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (const rawMessageInfo of input) { if (rawMessageInfo.localID) { invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); output.push(stripLocalID(rawMessageInfo)); } else { output.push(rawMessageInfo); } } return output; } // Normally we call trim() to remove whitespace at the beginning and end of each // message. However, our Markdown parser supports a "codeBlock" format where the // user can indent each line to indicate a code block. If we match the // corresponding RegEx, we'll only trim whitespace off the end. function trimMessage(message: string): string { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageQuote(message: string): string { // add `>` to each line to include empty lines in the quote return message.replace(/^/gm, '> '); } function createMessageReply(message: string): string { return createMessageQuote(message) + '\n\n'; } function getMostRecentNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; return thread?.messageIDs.find(id => !id.startsWith(localIDPrefix)); } function getOldestNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; if (!thread) { return thread; } const { messageIDs } = thread; for (let i = messageIDs.length - 1; i >= 0; i--) { const id = messageIDs[i]; if (!id.startsWith(localIDPrefix)) { return id; } } return undefined; } function getMessageTitle( messageInfo: | ComposableMessageInfo | RobotextMessageInfo | ReactionMessageInfo | EditMessageInfo, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, markdownRules: ParserRules, ): EntityText { const { messageTitle } = messageSpecs[messageInfo.type]; if (messageTitle) { return messageTitle({ messageInfo, threadInfo, markdownRules }); } invariant( messageInfo.type !== messageTypes.TEXT && messageInfo.type !== messageTypes.IMAGES && messageInfo.type !== messageTypes.MULTIMEDIA && messageInfo.type !== messageTypes.REACTION && messageInfo.type !== messageTypes.EDIT_MESSAGE, 'messageTitle can only be auto-generated for RobotextMessageInfo', ); return robotextForMessageInfo(messageInfo, threadInfo, parentThreadInfo); } function mergeThreadMessageInfos( first: ThreadMessageInfo, second: ThreadMessageInfo, messages: { +[id: string]: RawMessageInfo }, ): ThreadMessageInfo { let firstPointer = 0; let secondPointer = 0; const mergedMessageIDs = []; let firstCandidate = first.messageIDs[firstPointer]; let secondCandidate = second.messageIDs[secondPointer]; while (firstCandidate !== undefined || secondCandidate !== undefined) { if (firstCandidate === undefined) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else if (secondCandidate === undefined) { mergedMessageIDs.push(firstCandidate); firstPointer++; } else if (firstCandidate === secondCandidate) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else { const firstMessage = messages[firstCandidate]; const secondMessage = messages[secondCandidate]; invariant( firstMessage && secondMessage, 'message in messageIDs not present in MessageStore', ); if ( (firstMessage.id && secondMessage.id && firstMessage.id === secondMessage.id) || (firstMessage.localID && secondMessage.localID && firstMessage.localID === secondMessage.localID) ) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else if (firstMessage.time < secondMessage.time) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else { mergedMessageIDs.push(firstCandidate); firstPointer++; } } firstCandidate = first.messageIDs[firstPointer]; secondCandidate = second.messageIDs[secondPointer]; } return { messageIDs: mergedMessageIDs, startReached: first.startReached && second.startReached, }; } type MessagePreviewPart = { +text: string, // unread has highest contrast, followed by primary, followed by secondary +style: 'unread' | 'primary' | 'secondary', }; export type MessagePreviewResult = { +message: MessagePreviewPart, +username: ?MessagePreviewPart, }; function useMessagePreview( originalMessageInfo: ?MessageInfo, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, markdownRules: ParserRules, ): ?MessagePreviewResult { let messageInfo; if ( originalMessageInfo && originalMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { messageInfo = originalMessageInfo.sourceMessage; } else { messageInfo = originalMessageInfo; } const { parentThreadID } = threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const hasUsername = threadIsGroupChat(threadInfo) || threadInfo.name !== '' || messageInfo?.creator.isViewer; const shouldDisplayUser = messageInfo?.type === messageTypes.TEXT && hasUsername; const stringForUser = useStringForUser( shouldDisplayUser ? messageInfo?.creator : null, ); const { unread } = threadInfo.currentUser; const username = React.useMemo(() => { if (!shouldDisplayUser) { return null; } invariant( stringForUser, 'useStringForUser should only return falsey if pass null or undefined', ); return { text: stringForUser, style: unread ? 'unread' : 'secondary', }; }, [shouldDisplayUser, stringForUser, unread]); const messageTitleEntityText = React.useMemo(() => { if (!messageInfo) { return messageInfo; } return getMessageTitle( messageInfo, threadInfo, parentThreadInfo, markdownRules, ); }, [messageInfo, threadInfo, parentThreadInfo, markdownRules]); const threadID = threadInfo.id; const entityTextToStringParams = React.useMemo( () => ({ threadID, }), [threadID], ); const messageTitle = useEntityTextAsString( messageTitleEntityText, entityTextToStringParams, ); const isTextMessage = messageInfo?.type === messageTypes.TEXT; const message = React.useMemo(() => { if (messageTitle === null || messageTitle === undefined) { return messageTitle; } let style; if (unread) { style = 'unread'; } else if (isTextMessage) { style = 'primary'; } else { style = 'secondary'; } return { text: messageTitle, style }; }, [messageTitle, unread, isTextMessage]); return React.useMemo(() => { if (!message) { return message; } return { message, username }; }, [message, username]); } function useMessageCreationSideEffectsFunc( messageType: $PropertyType, ): CreationSideEffectsFunc { const messageSpec = messageSpecs[messageType]; invariant(messageSpec, `we're not aware of messageType ${messageType}`); invariant( messageSpec.useCreationSideEffectsFunc, `no useCreationSideEffectsFunc in message spec for ${messageType}`, ); return messageSpec.useCreationSideEffectsFunc(); } function getPinnedContentFromMessage(targetMessage: RawMessageInfo): string { let pinnedContent; if ( targetMessage.type === messageTypes.IMAGES || targetMessage.type === messageTypes.MULTIMEDIA ) { pinnedContent = contentStringForMediaArray(targetMessage.media); } else { pinnedContent = 'a message'; } return pinnedContent; } function modifyItemForResultScreen( item: ChatMessageInfoItem, ): ChatMessageInfoItem { if (item.messageInfoType === 'composable') { return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } return item; } function constructChangeRoleEntityText( affectedUsers: EntityText | string, roleName: ?string, ): EntityText { if (!roleName) { return ET`assigned ${affectedUsers} a new role`; } return ET`assigned ${affectedUsers} the "${roleName}" role`; } function useNextLocalID(): string { const nextLocalID = useSelector(state => state.nextLocalID); return `${localIDPrefix}${nextLocalID}`; } function isInvalidSidebarSource( message: RawMessageInfo | MessageInfo, ): boolean %checks { return ( (message.type === messageTypes.REACTION || message.type === messageTypes.EDIT_MESSAGE || message.type === messageTypes.SIDEBAR_SOURCE || message.type === messageTypes.TOGGLE_PIN) && !messageSpecs[message.type].canBeSidebarSource ); } // Prefer checking isInvalidPinSourceForThread below. This function doesn't // check whether the user is attempting to pin a SIDEBAR_SOURCE in the context // of its parent thread, so it's not suitable for permission checks. We only // use it in the message-fetchers.js code where we don't have access to the // RawThreadInfo and don't need to do permission checks. function isInvalidPinSource( messageInfo: RawMessageInfo | MessageInfo, ): boolean { return !messageSpecs[messageInfo.type].canBePinned; } function isInvalidPinSourceForThread( messageInfo: RawMessageInfo | MessageInfo, - threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, ): boolean { const isValidPinSource = !isInvalidPinSource(messageInfo); const isFirstMessageInSidebar = threadInfo.sourceMessageID === messageInfo.id; return !isValidPinSource || isFirstMessageInSidebar; } function isUnableToBeRenderedIndependently( message: RawMessageInfo | MessageInfo, ): boolean { return !messageSpecs[message.type].canBeRenderedIndependently; } function findNewestMessageTimePerKeyserver( messageInfos: $ReadOnlyArray, ): { [keyserverID: string]: number } { const timePerKeyserver: { [keyserverID: string]: number } = {}; for (const messageInfo of messageInfos) { const keyserverID = extractKeyserverIDFromID(messageInfo.threadID); if ( !timePerKeyserver[keyserverID] || timePerKeyserver[keyserverID] < messageInfo.time ) { timePerKeyserver[keyserverID] = messageInfo.time; } } return timePerKeyserver; } export { localIDPrefix, messageKey, messageID, robotextForMessageInfo, createMessageInfo, sortMessageInfoList, sortMessageIDs, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageQuote, createMessageReply, getMostRecentNonLocalMessageID, getOldestNonLocalMessageID, getMessageTitle, mergeThreadMessageInfos, useMessagePreview, useMessageCreationSideEffectsFunc, getPinnedContentFromMessage, modifyItemForResultScreen, constructChangeRoleEntityText, useNextLocalID, isInvalidSidebarSource, isInvalidPinSource, isInvalidPinSourceForThread, isUnableToBeRenderedIndependently, findNewestMessageTimePerKeyserver, }; diff --git a/lib/shared/messages/add-members-message-spec.js b/lib/shared/messages/add-members-message-spec.js index c7f81f9d4..6a7b6baf2 100644 --- a/lib/shared/messages/add-members-message-spec.js +++ b/lib/shared/messages/add-members-message-spec.js @@ -1,183 +1,183 @@ // @flow import invariant from 'invariant'; import type { CreateMessageInfoParams, MessageSpec, NotificationTextsParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type AddMembersMessageData, type AddMembersMessageInfo, type RawAddMembersMessageInfo, rawAddMembersMessageInfoValidator, } from '../../types/messages/add-members.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { values } from '../../utils/objects.js'; import { notifRobotextForMessageInfo } from '../notif-utils.js'; type AddMembersMessageSpec = MessageSpec< AddMembersMessageData, RawAddMembersMessageInfo, AddMembersMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: AddMembersMessageData | RawAddMembersMessageInfo, ) => string, ... }; export const addMembersMessageSpec: AddMembersMessageSpec = Object.freeze({ messageContentForServerDB( data: AddMembersMessageData | RawAddMembersMessageInfo, ): string { return JSON.stringify(data.addedUserIDs); }, messageContentForClientDB(data: RawAddMembersMessageInfo): string { return addMembersMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawAddMembersMessageInfo { return { type: messageTypes.ADD_MEMBERS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), addedUserIDs: JSON.parse(row.content), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawAddMembersMessageInfo { const content = clientDBMessageInfo.content; invariant( content !== undefined && content !== null, 'content must be defined for AddMembers', ); const rawAddMembersMessageInfo: RawAddMembersMessageInfo = { type: messageTypes.ADD_MEMBERS, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, addedUserIDs: JSON.parse(content), }; return rawAddMembersMessageInfo; }, createMessageInfo( rawMessageInfo: RawAddMembersMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): AddMembersMessageInfo { const addedMembers = params.createRelativeUserInfos( rawMessageInfo.addedUserIDs, ); return { type: messageTypes.ADD_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, addedMembers, }; }, rawMessageInfoFromMessageData( messageData: AddMembersMessageData, id: ?string, ): RawAddMembersMessageInfo { invariant(id, 'RawAddMembersMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: AddMembersMessageInfo): EntityText { const users = messageInfo.addedMembers; invariant(users.length !== 0, 'added who??'); const creator = ET.user({ userInfo: messageInfo.creator }); const addedUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); return ET`${creator} added ${addedUsers}`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, params: NotificationTextsParams, ): Promise { const addedMembersObject: { [string]: RelativeUserInfo } = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); for (const member of messageInfo.addedMembers) { addedMembersObject[member.id] = member; } } const addedMembers = values(addedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, addedMembers }; const { parentThreadInfo } = params; const robotext = notifRobotextForMessageInfo( mergedMessageInfo, threadInfo, parentThreadInfo, ); const merged = ET`${robotext} to ${ET.thread({ display: 'shortName', threadInfo, })}`; return { merged, title: threadInfo.uiName, body: robotext, }; }, notificationCollapseKey(rawMessageInfo: RawAddMembersMessageInfo): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, userIDs(rawMessageInfo: RawAddMembersMessageInfo): $ReadOnlyArray { return rawMessageInfo.addedUserIDs; }, canBeSidebarSource: true, canBePinned: false, validator: rawAddMembersMessageInfoValidator, }); diff --git a/lib/shared/messages/change-role-message-spec.js b/lib/shared/messages/change-role-message-spec.js index 00b491034..c6481a36e 100644 --- a/lib/shared/messages/change-role-message-spec.js +++ b/lib/shared/messages/change-role-message-spec.js @@ -1,253 +1,253 @@ // @flow import invariant from 'invariant'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, type RobotextParams, type NotificationTextsParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type ChangeRoleMessageData, type ChangeRoleMessageInfo, type RawChangeRoleMessageInfo, rawChangeRoleMessageInfoValidator, } from '../../types/messages/change-role.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { entityTextToRawString } from '../../utils/entity-text.js'; import { values } from '../../utils/objects.js'; import { constructChangeRoleEntityText } from '../message-utils.js'; import { notifRobotextForMessageInfo } from '../notif-utils.js'; import { hasMinCodeVersion } from '../version-utils.js'; type ChangeRoleMessageSpec = MessageSpec< ChangeRoleMessageData, RawChangeRoleMessageInfo, ChangeRoleMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: ChangeRoleMessageData | RawChangeRoleMessageInfo, ) => string, ... }; export const changeRoleMessageSpec: ChangeRoleMessageSpec = Object.freeze({ messageContentForServerDB( data: ChangeRoleMessageData | RawChangeRoleMessageInfo, ): string { return JSON.stringify({ userIDs: data.userIDs, newRole: data.newRole, roleName: data.roleName, }); }, messageContentForClientDB(data: RawChangeRoleMessageInfo): string { return changeRoleMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawChangeRoleMessageInfo { const content = JSON.parse(row.content); return { type: messageTypes.CHANGE_ROLE, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), userIDs: content.userIDs, newRole: content.newRole, roleName: content.roleName, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawChangeRoleMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for ChangeRole', ); const content = JSON.parse(clientDBMessageInfo.content); const rawChangeRoleMessageInfo: RawChangeRoleMessageInfo = { type: messageTypes.CHANGE_ROLE, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, userIDs: content.userIDs, newRole: content.newRole, roleName: content.roleName, }; return rawChangeRoleMessageInfo; }, createMessageInfo( rawMessageInfo: RawChangeRoleMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ChangeRoleMessageInfo { const members = params.createRelativeUserInfos(rawMessageInfo.userIDs); return { type: messageTypes.CHANGE_ROLE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, members, newRole: rawMessageInfo.newRole, roleName: rawMessageInfo.roleName, }; }, rawMessageInfoFromMessageData( messageData: ChangeRoleMessageData, id: ?string, ): RawChangeRoleMessageInfo { invariant(id, 'RawChangeRoleMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: ChangeRoleMessageInfo, params: RobotextParams, ): EntityText { const users = messageInfo.members; invariant(users.length !== 0, 'changed whose role??'); const creator = ET.user({ userInfo: messageInfo.creator }); const affectedUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); const { threadInfo, parentThreadInfo } = params; const threadRoleName = threadInfo?.roles[messageInfo.newRole]?.name ?? parentThreadInfo?.roles[messageInfo.newRole]?.name; const messageInfoRoleName = messageInfo.roleName; const roleName = threadRoleName ?? messageInfoRoleName; const constructedEntityText = constructChangeRoleEntityText( affectedUsers, roleName, ); return ET`${creator} ${constructedEntityText}`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, params: NotificationTextsParams, ): Promise { const membersObject: { [string]: RelativeUserInfo } = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.CHANGE_ROLE, 'messageInfo should be messageTypes.CHANGE_ROLE!', ); for (const member of messageInfo.members) { membersObject[member.id] = member; } } const members = values(membersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.CHANGE_ROLE, 'messageInfo should be messageTypes.CHANGE_ROLE!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, members }; const { parentThreadInfo } = params; const robotext = notifRobotextForMessageInfo( mergedMessageInfo, threadInfo, parentThreadInfo, ); const merged = ET`${robotext} of ${ET.thread({ display: 'shortName', threadInfo, })}`; return { merged, title: threadInfo.uiName, body: robotext, }; }, shimUnsupportedMessageInfo( rawMessageInfo: RawChangeRoleMessageInfo, platformDetails: ?PlatformDetails, ): RawChangeRoleMessageInfo | RawUnsupportedMessageInfo { if (hasMinCodeVersion(platformDetails, { native: 251 })) { return rawMessageInfo; } const { id, userIDs } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); const affectedUsers = userIDs.length === 1 ? 'a member' : 'some members'; const roleName = rawMessageInfo.roleName; const constructedEntityText = constructChangeRoleEntityText( affectedUsers, roleName, ); const stringifiedEntityText = entityTextToRawString(constructedEntityText); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: stringifiedEntityText, unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawChangeRoleMessageInfo, ): RawChangeRoleMessageInfo { return unwrapped; }, notificationCollapseKey(rawMessageInfo: RawChangeRoleMessageInfo): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.newRole, ); }, generatesNotifs: async () => pushTypes.NOTIF, canBeSidebarSource: true, canBePinned: false, validator: rawChangeRoleMessageInfoValidator, }); diff --git a/lib/shared/messages/change-settings-message-spec.js b/lib/shared/messages/change-settings-message-spec.js index 4b9a3b9ff..0ddf788b9 100644 --- a/lib/shared/messages/change-settings-message-spec.js +++ b/lib/shared/messages/change-settings-message-spec.js @@ -1,200 +1,200 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec, type RobotextParams, type NotificationTextsParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type ChangeSettingsMessageData, type ChangeSettingsMessageInfo, type RawChangeSettingsMessageInfo, rawChangeSettingsMessageInfoValidator, } from '../../types/messages/change-settings.js'; import type { NotifTexts } from '../../types/notif-types.js'; import { assertThreadType } from '../../types/thread-types-enum.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText } from '../../utils/entity-text.js'; import { validHexColorRegex } from '../account-utils.js'; import { notifRobotextForMessageInfo } from '../notif-utils.js'; import { threadLabel } from '../thread-utils.js'; type ChangeSettingsMessageSpec = MessageSpec< ChangeSettingsMessageData, RawChangeSettingsMessageInfo, ChangeSettingsMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: ChangeSettingsMessageData | RawChangeSettingsMessageInfo, ) => string, ... }; export const changeSettingsMessageSpec: ChangeSettingsMessageSpec = Object.freeze({ messageContentForServerDB( data: ChangeSettingsMessageData | RawChangeSettingsMessageInfo, ): string { return JSON.stringify({ [data.field]: data.value, }); }, messageContentForClientDB(data: RawChangeSettingsMessageInfo): string { return changeSettingsMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawChangeSettingsMessageInfo { const content = JSON.parse(row.content); const field = Object.keys(content)[0]; return { type: messageTypes.CHANGE_SETTINGS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), field, value: content[field], }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawChangeSettingsMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for ChangeSettings', ); const content = JSON.parse(clientDBMessageInfo.content); const field = Object.keys(content)[0]; const rawChangeSettingsMessageInfo: RawChangeSettingsMessageInfo = { type: messageTypes.CHANGE_SETTINGS, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, field, value: content[field], }; return rawChangeSettingsMessageInfo; }, createMessageInfo( rawMessageInfo: RawChangeSettingsMessageInfo, creator: RelativeUserInfo, ): ChangeSettingsMessageInfo { return { type: messageTypes.CHANGE_SETTINGS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, field: rawMessageInfo.field, value: rawMessageInfo.value, }; }, rawMessageInfoFromMessageData( messageData: ChangeSettingsMessageData, id: ?string, ): RawChangeSettingsMessageInfo { invariant(id, 'RawChangeSettingsMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: ChangeSettingsMessageInfo, params: RobotextParams, ): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); const thread = ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, threadType: params.threadInfo?.type, parentThreadID: params.threadInfo?.parentThreadID, possessive: true, }); if ( (messageInfo.field === 'name' || messageInfo.field === 'description') && messageInfo.value.toString() === '' ) { return ET`${creator} cleared ${thread} ${messageInfo.field}`; } if (messageInfo.field === 'avatar') { return ET`${creator} updated ${thread} ${messageInfo.field}`; } let value; if ( messageInfo.field === 'color' && messageInfo.value.toString().match(validHexColorRegex) ) { value = ET.color({ hex: `#${messageInfo.value}` }); } else if (messageInfo.field === 'type') { invariant( typeof messageInfo.value === 'number', 'messageInfo.value should be number for thread type change ', ); const newThreadType = assertThreadType(messageInfo.value); value = threadLabel(newThreadType); } else { value = messageInfo.value.toString(); } return ET`${creator} updated ${thread} ${messageInfo.field} to "${value}"`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, params: NotificationTextsParams, ): Promise { const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.CHANGE_SETTINGS, 'messageInfo should be messageTypes.CHANGE_SETTINGS!', ); const { parentThreadInfo } = params; const body = notifRobotextForMessageInfo( mostRecentMessageInfo, threadInfo, parentThreadInfo, ); return { merged: body, title: threadInfo.uiName, body, }; }, notificationCollapseKey( rawMessageInfo: RawChangeSettingsMessageInfo, ): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.field, ); }, generatesNotifs: async () => pushTypes.NOTIF, canBeSidebarSource: true, canBePinned: false, validator: rawChangeSettingsMessageInfoValidator, }); diff --git a/lib/shared/messages/create-entry-message-spec.js b/lib/shared/messages/create-entry-message-spec.js index 67330dc7a..71ecdb745 100644 --- a/lib/shared/messages/create-entry-message-spec.js +++ b/lib/shared/messages/create-entry-message-spec.js @@ -1,138 +1,138 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type CreateEntryMessageData, type CreateEntryMessageInfo, type RawCreateEntryMessageInfo, rawCreateEntryMessageInfoValidator, } from '../../types/messages/create-entry.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { prettyDate } from '../../utils/date-utils.js'; import { ET, type EntityText } from '../../utils/entity-text.js'; import { notifTextsForEntryCreationOrEdit } from '../notif-utils.js'; type CreateEntryMessageSpec = MessageSpec< CreateEntryMessageData, RawCreateEntryMessageInfo, CreateEntryMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: CreateEntryMessageData | RawCreateEntryMessageInfo, ) => string, ... }; export const createEntryMessageSpec: CreateEntryMessageSpec = Object.freeze({ messageContentForServerDB( data: CreateEntryMessageData | RawCreateEntryMessageInfo, ): string { return JSON.stringify({ entryID: data.entryID, date: data.date, text: data.text, }); }, messageContentForClientDB(data: RawCreateEntryMessageInfo): string { return createEntryMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawCreateEntryMessageInfo { const content = JSON.parse(row.content); return { type: messageTypes.CREATE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawCreateEntryMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for CreateEntry', ); const content = JSON.parse(clientDBMessageInfo.content); const rawCreateEntryMessageInfo: RawCreateEntryMessageInfo = { type: messageTypes.CREATE_ENTRY, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, entryID: content.entryID, date: content.date, text: content.text, }; return rawCreateEntryMessageInfo; }, createMessageInfo( rawMessageInfo: RawCreateEntryMessageInfo, creator: RelativeUserInfo, ): CreateEntryMessageInfo { return { type: messageTypes.CREATE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; }, rawMessageInfoFromMessageData( messageData: CreateEntryMessageData, id: ?string, ): RawCreateEntryMessageInfo { invariant(id, 'RawCreateEntryMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: CreateEntryMessageInfo): EntityText { const date = prettyDate(messageInfo.date); const creator = ET.user({ userInfo: messageInfo.creator }); const { text } = messageInfo; return ET`${creator} created an event scheduled for ${date}: "${text}"`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): Promise { return notifTextsForEntryCreationOrEdit(messageInfos, threadInfo); }, notificationCollapseKey(rawMessageInfo: RawCreateEntryMessageInfo): string { return joinResult(rawMessageInfo.creatorID, rawMessageInfo.entryID); }, generatesNotifs: async () => pushTypes.NOTIF, canBeSidebarSource: true, canBePinned: false, validator: rawCreateEntryMessageInfoValidator, }); diff --git a/lib/shared/messages/create-sidebar-message-spec.js b/lib/shared/messages/create-sidebar-message-spec.js index d982883f5..7321749be 100644 --- a/lib/shared/messages/create-sidebar-message-spec.js +++ b/lib/shared/messages/create-sidebar-message-spec.js @@ -1,229 +1,229 @@ // @flow import invariant from 'invariant'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, type NotificationTextsParams, type RobotextParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type CreateSidebarMessageData, type CreateSidebarMessageInfo, type RawCreateSidebarMessageInfo, rawCreateSidebarMessageInfoValidator, } from '../../types/messages/create-sidebar.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { notifTextsForSidebarCreation } from '../notif-utils.js'; type CreateSidebarMessageSpec = MessageSpec< CreateSidebarMessageData, RawCreateSidebarMessageInfo, CreateSidebarMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: CreateSidebarMessageData | RawCreateSidebarMessageInfo, ) => string, ... }; export const createSidebarMessageSpec: CreateSidebarMessageSpec = Object.freeze( { messageContentForServerDB( data: CreateSidebarMessageData | RawCreateSidebarMessageInfo, ): string { return JSON.stringify({ ...data.initialThreadState, sourceMessageAuthorID: data.sourceMessageAuthorID, }); }, messageContentForClientDB(data: RawCreateSidebarMessageInfo): string { return createSidebarMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawCreateSidebarMessageInfo { const { sourceMessageAuthorID, ...initialThreadState } = JSON.parse( row.content, ); return { type: messageTypes.CREATE_SIDEBAR, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), sourceMessageAuthorID, initialThreadState, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawCreateSidebarMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for CreateSidebar', ); const { sourceMessageAuthorID, ...initialThreadState } = JSON.parse( clientDBMessageInfo.content, ); const rawCreateSidebarMessageInfo: RawCreateSidebarMessageInfo = { type: messageTypes.CREATE_SIDEBAR, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, sourceMessageAuthorID: sourceMessageAuthorID, initialThreadState: initialThreadState, }; return rawCreateSidebarMessageInfo; }, createMessageInfo( rawMessageInfo: RawCreateSidebarMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?CreateSidebarMessageInfo { const { threadInfos } = params; const parentThreadInfo = threadInfos[rawMessageInfo.initialThreadState.parentThreadID]; const sourceMessageAuthor = params.createRelativeUserInfos([ rawMessageInfo.sourceMessageAuthorID, ])[0]; if (!sourceMessageAuthor) { return null; } return { type: messageTypes.CREATE_SIDEBAR, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, sourceMessageAuthor, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, color: rawMessageInfo.initialThreadState.color, otherMembers: params.createRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), ), }, }; }, rawMessageInfoFromMessageData( messageData: CreateSidebarMessageData, id: ?string, ): RawCreateSidebarMessageInfo { invariant(id, 'RawCreateSidebarMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: CreateSidebarMessageInfo, params: RobotextParams, ): EntityText { let text = ET`started ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, threadType: params.threadInfo?.type, parentThreadID: params.threadInfo?.parentThreadID, })}`; const users = messageInfo.initialThreadState.otherMembers.filter( member => member.id !== messageInfo.sourceMessageAuthor.id, ); if (users.length !== 0) { const initialUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); text = ET`${text} and added ${initialUsers}`; } const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} ${text}`; }, unshimMessageInfo( unwrapped: RawCreateSidebarMessageInfo, ): RawCreateSidebarMessageInfo { return unwrapped; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, params: NotificationTextsParams, ): Promise { const createSidebarMessageInfo = messageInfos[0]; invariant( createSidebarMessageInfo.type === messageTypes.CREATE_SIDEBAR, 'first MessageInfo should be messageTypes.CREATE_SIDEBAR!', ); let sidebarSourceMessageInfo; const secondMessageInfo = messageInfos[1]; if ( secondMessageInfo && secondMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { sidebarSourceMessageInfo = secondMessageInfo; } return notifTextsForSidebarCreation({ createSidebarMessageInfo, sidebarSourceMessageInfo, threadInfo, params, }); }, notificationCollapseKey( rawMessageInfo: RawCreateSidebarMessageInfo, ): string { return joinResult(messageTypes.CREATE_SIDEBAR, rawMessageInfo.threadID); }, generatesNotifs: async () => pushTypes.NOTIF, userIDs( rawMessageInfo: RawCreateSidebarMessageInfo, ): $ReadOnlyArray { return rawMessageInfo.initialThreadState.memberIDs; }, threadIDs( rawMessageInfo: RawCreateSidebarMessageInfo, ): $ReadOnlyArray { const { parentThreadID } = rawMessageInfo.initialThreadState; return [parentThreadID]; }, canBeSidebarSource: true, canBePinned: false, validator: rawCreateSidebarMessageInfoValidator, }, ); diff --git a/lib/shared/messages/create-sub-thread-message-spec.js b/lib/shared/messages/create-sub-thread-message-spec.js index 063eb35d7..f7d1c5ba8 100644 --- a/lib/shared/messages/create-sub-thread-message-spec.js +++ b/lib/shared/messages/create-sub-thread-message-spec.js @@ -1,181 +1,181 @@ // @flow import invariant from 'invariant'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, type GeneratesNotifsParams, } from './message-spec.js'; import { assertSingleMessageInfo } from './utils.js'; import { permissionLookup } from '../../permissions/thread-permissions.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type CreateSubthreadMessageData, type CreateSubthreadMessageInfo, type RawCreateSubthreadMessageInfo, rawCreateSubthreadMessageInfoValidator, } from '../../types/messages/create-subthread.js'; import type { NotifTexts } from '../../types/notif-types.js'; import { threadPermissions } from '../../types/thread-permission-types.js'; import { threadTypes } from '../../types/thread-types-enum.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText } from '../../utils/entity-text.js'; import { notifTextsForSubthreadCreation } from '../notif-utils.js'; type CreateSubThreadMessageSpec = MessageSpec< CreateSubthreadMessageData, RawCreateSubthreadMessageInfo, CreateSubthreadMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: CreateSubthreadMessageData | RawCreateSubthreadMessageInfo, ) => string, ... }; export const createSubThreadMessageSpec: CreateSubThreadMessageSpec = Object.freeze({ messageContentForServerDB( data: CreateSubthreadMessageData | RawCreateSubthreadMessageInfo, ): string { return data.childThreadID; }, messageContentForClientDB(data: RawCreateSubthreadMessageInfo): string { return createSubThreadMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): ?RawCreateSubthreadMessageInfo { const subthreadPermissions = row.subthread_permissions; if (!permissionLookup(subthreadPermissions, threadPermissions.KNOW_OF)) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), childThreadID: row.content, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawCreateSubthreadMessageInfo { const content = clientDBMessageInfo.content; invariant( content !== undefined && content !== null, 'content must be defined for CreateSubThread', ); const rawCreateSubthreadMessageInfo: RawCreateSubthreadMessageInfo = { type: messageTypes.CREATE_SUB_THREAD, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, childThreadID: content, }; return rawCreateSubthreadMessageInfo; }, createMessageInfo( rawMessageInfo: RawCreateSubthreadMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?CreateSubthreadMessageInfo { const { threadInfos } = params; const childThreadInfo = threadInfos[rawMessageInfo.childThreadID]; if (!childThreadInfo) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, childThreadInfo, }; }, rawMessageInfoFromMessageData( messageData: CreateSubthreadMessageData, id: ?string, ): RawCreateSubthreadMessageInfo { invariant(id, 'RawCreateSubthreadMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: CreateSubthreadMessageInfo): EntityText { const threadEntity = ET.thread({ display: 'shortName', threadInfo: messageInfo.childThreadInfo, subchannel: true, }); let text; if (messageInfo.childThreadInfo.name) { const childNoun = messageInfo.childThreadInfo.type === threadTypes.SIDEBAR ? 'thread' : 'subchannel'; text = ET`created a ${childNoun} named "${threadEntity}"`; } else { text = ET`created a ${threadEntity}`; } const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} ${text}`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): Promise { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_SUB_THREAD, 'messageInfo should be messageTypes.CREATE_SUB_THREAD!', ); return notifTextsForSubthreadCreation({ creator: messageInfo.creator, threadType: messageInfo.childThreadInfo.type, parentThreadInfo: threadInfo, childThreadName: messageInfo.childThreadInfo.name, childThreadUIName: messageInfo.childThreadInfo.uiName, }); }, generatesNotifs: async ( rawMessageInfo: RawCreateSubthreadMessageInfo, messageData: CreateSubthreadMessageData, params: GeneratesNotifsParams, ) => { const { userNotMemberOfSubthreads } = params; return userNotMemberOfSubthreads.has(rawMessageInfo.childThreadID) ? pushTypes.NOTIF : undefined; }, threadIDs( rawMessageInfo: RawCreateSubthreadMessageInfo, ): $ReadOnlyArray { return [rawMessageInfo.childThreadID]; }, canBeSidebarSource: true, canBePinned: false, validator: rawCreateSubthreadMessageInfoValidator, }); diff --git a/lib/shared/messages/create-thread-message-spec.js b/lib/shared/messages/create-thread-message-spec.js index 15df1c822..7a7e0b595 100644 --- a/lib/shared/messages/create-thread-message-spec.js +++ b/lib/shared/messages/create-thread-message-spec.js @@ -1,219 +1,219 @@ // @flow import invariant from 'invariant'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, type RobotextParams, } from './message-spec.js'; import { assertSingleMessageInfo } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type CreateThreadMessageData, type CreateThreadMessageInfo, type RawCreateThreadMessageInfo, rawCreateThreadMessageInfoValidator, } from '../../types/messages/create-thread.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { notifTextsForSubthreadCreation } from '../notif-utils.js'; import { threadNoun } from '../thread-utils.js'; type CreateThreadMessageSpec = MessageSpec< CreateThreadMessageData, RawCreateThreadMessageInfo, CreateThreadMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: CreateThreadMessageData | RawCreateThreadMessageInfo, ) => string, ... }; export const createThreadMessageSpec: CreateThreadMessageSpec = Object.freeze({ messageContentForServerDB( data: CreateThreadMessageData | RawCreateThreadMessageInfo, ): string { return JSON.stringify(data.initialThreadState); }, messageContentForClientDB(data: RawCreateThreadMessageInfo): string { return createThreadMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawCreateThreadMessageInfo { return { type: messageTypes.CREATE_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), initialThreadState: JSON.parse(row.content), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawCreateThreadMessageInfo { const content = clientDBMessageInfo.content; invariant( content !== undefined && content !== null, 'content must be defined for CreateThread', ); const rawCreateThreadMessageInfo: RawCreateThreadMessageInfo = { type: messageTypes.CREATE_THREAD, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, initialThreadState: JSON.parse(content), }; return rawCreateThreadMessageInfo; }, createMessageInfo( rawMessageInfo: RawCreateThreadMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): CreateThreadMessageInfo { const initialParentThreadID = rawMessageInfo.initialThreadState.parentThreadID; const parentThreadInfo = initialParentThreadID ? params.threadInfos[initialParentThreadID] : null; return { type: messageTypes.CREATE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, type: rawMessageInfo.initialThreadState.type, color: rawMessageInfo.initialThreadState.color, otherMembers: params.createRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), ), }, }; }, rawMessageInfoFromMessageData( messageData: CreateThreadMessageData, id: ?string, ): RawCreateThreadMessageInfo { invariant(id, 'RawCreateThreadMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: CreateThreadMessageInfo, params: RobotextParams, ): EntityText { let text = ET`created ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, threadType: params.threadInfo?.type, parentThreadID: params.threadInfo?.parentThreadID, })}`; const parentThread = messageInfo.initialThreadState.parentThreadInfo; if (parentThread) { text = ET`${text} as a child of ${ET.thread({ display: 'uiName', threadInfo: parentThread, })}`; } if (messageInfo.initialThreadState.name) { text = ET`${text} with the name "${messageInfo.initialThreadState.name}"`; } const users = messageInfo.initialThreadState.otherMembers; if (users.length !== 0) { const initialUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); text = ET`${text} and added ${initialUsers}`; } const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} ${text}`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): Promise { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_THREAD, 'messageInfo should be messageTypes.CREATE_THREAD!', ); const threadType = messageInfo.initialThreadState.type; const parentThreadInfo = messageInfo.initialThreadState.parentThreadInfo; const threadName = messageInfo.initialThreadState.name; if (parentThreadInfo) { return notifTextsForSubthreadCreation({ creator: messageInfo.creator, threadType, parentThreadInfo, childThreadName: threadName, childThreadUIName: threadInfo.uiName, }); } const creator = ET.user({ userInfo: messageInfo.creator }); const prefix = ET`${creator}`; const body = `created a new ${threadNoun(threadType)}`; let merged = ET`${prefix} ${body}`; if (threadName) { merged = ET`${merged} called "${threadName}"`; } return { merged, body, title: threadInfo.uiName, prefix, }; }, generatesNotifs: async () => pushTypes.NOTIF, userIDs(rawMessageInfo: RawCreateThreadMessageInfo): $ReadOnlyArray { return rawMessageInfo.initialThreadState.memberIDs; }, startsThread: true, threadIDs( rawMessageInfo: RawCreateThreadMessageInfo, ): $ReadOnlyArray { const { parentThreadID } = rawMessageInfo.initialThreadState; return parentThreadID ? [parentThreadID] : []; }, canBeSidebarSource: true, canBePinned: false, validator: rawCreateThreadMessageInfoValidator, }); diff --git a/lib/shared/messages/delete-entry-message-spec.js b/lib/shared/messages/delete-entry-message-spec.js index 68c796a20..f5c8f1909 100644 --- a/lib/shared/messages/delete-entry-message-spec.js +++ b/lib/shared/messages/delete-entry-message-spec.js @@ -1,153 +1,153 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec } from './message-spec.js'; import { assertSingleMessageInfo } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type DeleteEntryMessageData, type DeleteEntryMessageInfo, type RawDeleteEntryMessageInfo, rawDeleteEntryMessageInfoValidator, } from '../../types/messages/delete-entry.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { prettyDate } from '../../utils/date-utils.js'; import { ET, type EntityText } from '../../utils/entity-text.js'; type DeleteEntryMessageSpec = MessageSpec< DeleteEntryMessageData, RawDeleteEntryMessageInfo, DeleteEntryMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: DeleteEntryMessageData | RawDeleteEntryMessageInfo, ) => string, ... }; export const deleteEntryMessageSpec: DeleteEntryMessageSpec = Object.freeze({ messageContentForServerDB( data: DeleteEntryMessageData | RawDeleteEntryMessageInfo, ): string { return JSON.stringify({ entryID: data.entryID, date: data.date, text: data.text, }); }, messageContentForClientDB(data: RawDeleteEntryMessageInfo): string { return deleteEntryMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawDeleteEntryMessageInfo { const content = JSON.parse(row.content); return { type: messageTypes.DELETE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawDeleteEntryMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for DeleteEntry', ); const content = JSON.parse(clientDBMessageInfo.content); const rawDeleteEntryMessageInfo: RawDeleteEntryMessageInfo = { type: messageTypes.DELETE_ENTRY, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, entryID: content.entryID, date: content.date, text: content.text, }; return rawDeleteEntryMessageInfo; }, createMessageInfo( rawMessageInfo: RawDeleteEntryMessageInfo, creator: RelativeUserInfo, ): DeleteEntryMessageInfo { return { type: messageTypes.DELETE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; }, rawMessageInfoFromMessageData( messageData: DeleteEntryMessageData, id: ?string, ): RawDeleteEntryMessageInfo { invariant(id, 'RawDeleteEntryMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: DeleteEntryMessageInfo): EntityText { const date = prettyDate(messageInfo.date); const creator = ET.user({ userInfo: messageInfo.creator }); const { text } = messageInfo; return ET`${creator} deleted an event scheduled for ${date}: "${text}"`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): Promise { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.DELETE_ENTRY, 'messageInfo should be messageTypes.DELETE_ENTRY!', ); const thread = ET.thread({ display: 'shortName', threadInfo }); const creator = ET.user({ userInfo: messageInfo.creator }); const date = prettyDate(messageInfo.date); const prefix = ET`${creator}`; let body = ET`deleted an event in ${thread}`; body = ET`${body} scheduled for ${date}: "${messageInfo.text}"`; const merged = ET`${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; }, generatesNotifs: async () => pushTypes.NOTIF, canBeSidebarSource: true, canBePinned: false, validator: rawDeleteEntryMessageInfoValidator, }); diff --git a/lib/shared/messages/edit-entry-message-spec.js b/lib/shared/messages/edit-entry-message-spec.js index f0afd05f9..82a07d17d 100644 --- a/lib/shared/messages/edit-entry-message-spec.js +++ b/lib/shared/messages/edit-entry-message-spec.js @@ -1,138 +1,138 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type EditEntryMessageData, type EditEntryMessageInfo, type RawEditEntryMessageInfo, rawEditEntryMessageInfoValidator, } from '../../types/messages/edit-entry.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { prettyDate } from '../../utils/date-utils.js'; import { ET, type EntityText } from '../../utils/entity-text.js'; import { notifTextsForEntryCreationOrEdit } from '../notif-utils.js'; type EditEntryMessageSpec = MessageSpec< EditEntryMessageData, RawEditEntryMessageInfo, EditEntryMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: EditEntryMessageData | RawEditEntryMessageInfo, ) => string, ... }; export const editEntryMessageSpec: EditEntryMessageSpec = Object.freeze({ messageContentForServerDB( data: EditEntryMessageData | RawEditEntryMessageInfo, ): string { return JSON.stringify({ entryID: data.entryID, date: data.date, text: data.text, }); }, messageContentForClientDB(data: RawEditEntryMessageInfo): string { return editEntryMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawEditEntryMessageInfo { const content = JSON.parse(row.content); return { type: messageTypes.EDIT_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawEditEntryMessageInfo { invariant( clientDBMessageInfo.content !== null && clientDBMessageInfo.content !== undefined, 'content must be defined for EditEntry', ); const content = JSON.parse(clientDBMessageInfo.content); const rawEditEntryMessageInfo: RawEditEntryMessageInfo = { type: messageTypes.EDIT_ENTRY, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, entryID: content.entryID, date: content.date, text: content.text, }; return rawEditEntryMessageInfo; }, createMessageInfo( rawMessageInfo: RawEditEntryMessageInfo, creator: RelativeUserInfo, ): EditEntryMessageInfo { return { type: messageTypes.EDIT_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; }, rawMessageInfoFromMessageData( messageData: EditEntryMessageData, id: ?string, ): RawEditEntryMessageInfo { invariant(id, 'RawEditEntryMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: EditEntryMessageInfo): EntityText { const date = prettyDate(messageInfo.date); const creator = ET.user({ userInfo: messageInfo.creator }); const { text } = messageInfo; return ET`${creator} updated the text of an event scheduled for ${date}: "${text}"`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): Promise { return notifTextsForEntryCreationOrEdit(messageInfos, threadInfo); }, notificationCollapseKey(rawMessageInfo: RawEditEntryMessageInfo): string { return joinResult(rawMessageInfo.creatorID, rawMessageInfo.entryID); }, generatesNotifs: async () => pushTypes.NOTIF, canBeSidebarSource: true, canBePinned: false, validator: rawEditEntryMessageInfoValidator, }); diff --git a/lib/shared/messages/join-thread-message-spec.js b/lib/shared/messages/join-thread-message-spec.js index 1d182efce..8015106f1 100644 --- a/lib/shared/messages/join-thread-message-spec.js +++ b/lib/shared/messages/join-thread-message-spec.js @@ -1,127 +1,127 @@ // @flow import invariant from 'invariant'; import type { MessageSpec, RobotextParams } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type JoinThreadMessageData, type JoinThreadMessageInfo, type RawJoinThreadMessageInfo, rawJoinThreadMessageInfoValidator, } from '../../types/messages/join-thread.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { values } from '../../utils/objects.js'; export const joinThreadMessageSpec: MessageSpec< JoinThreadMessageData, RawJoinThreadMessageInfo, JoinThreadMessageInfo, > = Object.freeze({ rawMessageInfoFromServerDBRow(row: Object): RawJoinThreadMessageInfo { return { type: messageTypes.JOIN_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawJoinThreadMessageInfo { const rawJoinThreadMessageInfo: RawJoinThreadMessageInfo = { type: messageTypes.JOIN_THREAD, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, }; return rawJoinThreadMessageInfo; }, createMessageInfo( rawMessageInfo: RawJoinThreadMessageInfo, creator: RelativeUserInfo, ): JoinThreadMessageInfo { return { type: messageTypes.JOIN_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, }; }, rawMessageInfoFromMessageData( messageData: JoinThreadMessageData, id: ?string, ): RawJoinThreadMessageInfo { invariant(id, 'RawJoinThreadMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: JoinThreadMessageInfo, params: RobotextParams, ): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} joined ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, threadType: params.threadInfo?.type, parentThreadID: params.threadInfo?.parentThreadID, })}`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): Promise { const joinerArray: { [string]: RelativeUserInfo } = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.JOIN_THREAD, 'messageInfo should be messageTypes.JOIN_THREAD!', ); joinerArray[messageInfo.creator.id] = messageInfo.creator; } const joiners = values(joinerArray); const joiningUsers = pluralizeEntityText( joiners.map(user => ET`${ET.user({ userInfo: user })}`), ); const body = ET`${joiningUsers} joined`; const thread = ET.thread({ display: 'shortName', threadInfo }); const merged = ET`${body} ${thread}`; return { merged, title: threadInfo.uiName, body, }; }, notificationCollapseKey(rawMessageInfo: RawJoinThreadMessageInfo): string { return joinResult(rawMessageInfo.type, rawMessageInfo.threadID); }, canBeSidebarSource: true, canBePinned: false, validator: rawJoinThreadMessageInfoValidator, }); diff --git a/lib/shared/messages/leave-thread-message-spec.js b/lib/shared/messages/leave-thread-message-spec.js index 9f7db1c67..b266bd1a0 100644 --- a/lib/shared/messages/leave-thread-message-spec.js +++ b/lib/shared/messages/leave-thread-message-spec.js @@ -1,127 +1,127 @@ // @flow import invariant from 'invariant'; import type { MessageSpec, RobotextParams } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type LeaveThreadMessageData, type LeaveThreadMessageInfo, type RawLeaveThreadMessageInfo, rawLeaveThreadMessageInfoValidator, } from '../../types/messages/leave-thread.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { values } from '../../utils/objects.js'; export const leaveThreadMessageSpec: MessageSpec< LeaveThreadMessageData, RawLeaveThreadMessageInfo, LeaveThreadMessageInfo, > = Object.freeze({ rawMessageInfoFromServerDBRow(row: Object): RawLeaveThreadMessageInfo { return { type: messageTypes.LEAVE_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawLeaveThreadMessageInfo { const rawLeaveThreadMessageInfo: RawLeaveThreadMessageInfo = { type: messageTypes.LEAVE_THREAD, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, }; return rawLeaveThreadMessageInfo; }, createMessageInfo( rawMessageInfo: RawLeaveThreadMessageInfo, creator: RelativeUserInfo, ): LeaveThreadMessageInfo { return { type: messageTypes.LEAVE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, }; }, rawMessageInfoFromMessageData( messageData: LeaveThreadMessageData, id: ?string, ): RawLeaveThreadMessageInfo { invariant(id, 'RawLeaveThreadMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: LeaveThreadMessageInfo, params: RobotextParams, ): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} left ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, threadType: params.threadInfo?.type, parentThreadID: params.threadInfo?.parentThreadID, })}`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): Promise { const leaverBeavers: { [string]: RelativeUserInfo } = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.LEAVE_THREAD, 'messageInfo should be messageTypes.LEAVE_THREAD!', ); leaverBeavers[messageInfo.creator.id] = messageInfo.creator; } const leavers = values(leaverBeavers); const leavingUsers = pluralizeEntityText( leavers.map(user => ET`${ET.user({ userInfo: user })}`), ); const body = ET`${leavingUsers} left`; const thread = ET.thread({ display: 'shortName', threadInfo }); const merged = ET`${body} ${thread}`; return { merged, title: threadInfo.uiName, body, }; }, notificationCollapseKey(rawMessageInfo: RawLeaveThreadMessageInfo): string { return joinResult(rawMessageInfo.type, rawMessageInfo.threadID); }, canBeSidebarSource: true, canBePinned: false, validator: rawLeaveThreadMessageInfoValidator, }); diff --git a/lib/shared/messages/message-spec.js b/lib/shared/messages/message-spec.js index 18d4c2de1..213b6d06a 100644 --- a/lib/shared/messages/message-spec.js +++ b/lib/shared/messages/message-spec.js @@ -1,123 +1,123 @@ // @flow import type { TType } from 'tcomb'; import type { PlatformDetails } from '../../types/device-types.js'; import type { Media } from '../../types/media-types.js'; import type { MessageInfo, ClientDBMessageInfo, RawComposableMessageInfo, RawMessageInfo, RawRobotextMessageInfo, } from '../../types/message-types.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { MinimallyEncodedThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo, UserInfo } from '../../types/user-types.js'; import type { EntityText } from '../../utils/entity-text.js'; import { type ParserRules } from '../markdown.js'; export type MessageTitleParam = { +messageInfo: Info, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +markdownRules: ParserRules, }; export type RawMessageInfoFromServerDBRowParams = { +localID: ?string, +media?: $ReadOnlyArray, +derivedMessages?: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, }; export type CreateMessageInfoParams = { - +threadInfos: { +[id: string]: ThreadInfo }, + +threadInfos: { +[id: string]: LegacyThreadInfo }, +createMessageInfoFromRaw: (rawInfo: RawMessageInfo) => ?MessageInfo, +createRelativeUserInfos: ( userIDs: $ReadOnlyArray, ) => RelativeUserInfo[], }; export type RobotextParams = { - +threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, - +parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + +parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, }; export type NotificationTextsParams = { +notifTargetUserInfo: UserInfo, - +parentThreadInfo: ?ThreadInfo, + +parentThreadInfo: ?LegacyThreadInfo, }; export type GeneratesNotifsParams = { +notifTargetUserID: string, +userNotMemberOfSubthreads: Set, +fetchMessageInfoByID: (messageID: string) => Promise, }; export const pushTypes = Object.freeze({ NOTIF: 'notif', RESCIND: 'rescind', }); export type PushType = $Values; export type CreationSideEffectsFunc = ( messageInfo: RawInfo, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ) => Promise; export type MessageSpec = { +messageContentForServerDB?: (data: Data | RawInfo) => string, +messageContentForClientDB?: (data: RawInfo) => string, +messageTitle?: (param: MessageTitleParam) => EntityText, +rawMessageInfoFromServerDBRow?: ( row: Object, params: RawMessageInfoFromServerDBRowParams, ) => ?RawInfo, +rawMessageInfoFromClientDB: ( clientDBMessageInfo: ClientDBMessageInfo, ) => RawInfo, +createMessageInfo: ( rawMessageInfo: RawInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ) => ?Info, +rawMessageInfoFromMessageData?: (messageData: Data, id: ?string) => RawInfo, +robotext?: (messageInfo: Info, params: RobotextParams) => EntityText, +shimUnsupportedMessageInfo?: ( rawMessageInfo: RawInfo, platformDetails: ?PlatformDetails, ) => RawInfo | RawUnsupportedMessageInfo, +unshimMessageInfo?: ( unwrapped: RawInfo, messageInfo: RawMessageInfo, ) => ?RawMessageInfo, +notificationTexts?: ( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, params: NotificationTextsParams, ) => Promise, +notificationCollapseKey?: ( rawMessageInfo: RawInfo, messageData: Data, ) => ?string, +generatesNotifs?: ( rawMessageInfo: RawInfo, messageData: Data, params: GeneratesNotifsParams, ) => Promise, +userIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +startsThread?: boolean, +threadIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +includedInRepliesCount?: boolean, +canBeSidebarSource: boolean, +canBePinned: boolean, +canBeRenderedIndependently?: boolean, +parseDerivedMessages?: (row: Object, requiredIDs: Set) => void, +useCreationSideEffectsFunc?: () => CreationSideEffectsFunc, +validator: TType, }; diff --git a/lib/shared/messages/multimedia-message-spec.js b/lib/shared/messages/multimedia-message-spec.js index cb3d07cbe..1ac6e0663 100644 --- a/lib/shared/messages/multimedia-message-spec.js +++ b/lib/shared/messages/multimedia-message-spec.js @@ -1,381 +1,381 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec, type MessageTitleParam, type RawMessageInfoFromServerDBRowParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import { versionSpecificMediaMessageFormat, isMediaBlobServiceHosted, contentStringForMediaArray, multimediaMessagePreview, } from '../../media/media-utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { assertMessageType, messageTypes, } from '../../types/message-types-enum.js'; import { isMediaMessageType, rawMultimediaMessageInfoValidator, } from '../../types/message-types.js'; import type { MessageInfo, RawMessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import type { ImagesMessageData, RawImagesMessageInfo, ImagesMessageInfo, } from '../../types/messages/images.js'; import type { MediaMessageData, MediaMessageInfo, RawMediaMessageInfo, } from '../../types/messages/media.js'; import { getMediaMessageServerDBContentsFromMedia } from '../../types/messages/media.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET } from '../../utils/entity-text.js'; import { translateClientDBMediaInfosToMedia, translateClientDBMediaInfoToImage, } from '../../utils/message-ops-utils.js'; import { createMediaMessageInfo } from '../message-utils.js'; import { threadIsGroupChat } from '../thread-utils.js'; import { FUTURE_CODE_VERSION, hasMinCodeVersion } from '../version-utils.js'; type MultimediaMessageSpec = MessageSpec< MediaMessageData | ImagesMessageData, RawMediaMessageInfo | RawImagesMessageInfo, MediaMessageInfo | ImagesMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: | MediaMessageData | ImagesMessageData | RawMediaMessageInfo | RawImagesMessageInfo, ) => string, ... }; export const multimediaMessageSpec: MultimediaMessageSpec = Object.freeze({ messageContentForServerDB( data: | MediaMessageData | ImagesMessageData | RawMediaMessageInfo | RawImagesMessageInfo, ): string { if (data.type === messageTypes.MULTIMEDIA) { return JSON.stringify( getMediaMessageServerDBContentsFromMedia(data.media), ); } const mediaIDs = data.media.map(media => parseInt(media.id, 10)); return JSON.stringify(mediaIDs); }, messageContentForClientDB( data: RawMediaMessageInfo | RawImagesMessageInfo, ): string { return multimediaMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawImagesMessageInfo | RawMediaMessageInfo { const messageType = assertMessageType(parseInt(clientDBMessageInfo.type)); invariant( isMediaMessageType(messageType), 'message must be of type IMAGES or MULTIMEDIA', ); invariant( clientDBMessageInfo.media_infos !== null && clientDBMessageInfo.media_infos !== undefined, `media_infos must be defined`, ); let rawMessageInfo: RawImagesMessageInfo | RawMediaMessageInfo = messageType === messageTypes.IMAGES ? { type: messageTypes.IMAGES, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: clientDBMessageInfo.media_infos?.map( translateClientDBMediaInfoToImage, ) ?? [], } : { type: messageTypes.MULTIMEDIA, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: translateClientDBMediaInfosToMedia(clientDBMessageInfo), }; if (clientDBMessageInfo.local_id) { rawMessageInfo = { ...rawMessageInfo, localID: clientDBMessageInfo.local_id, }; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawMessageInfo = { ...rawMessageInfo, id: clientDBMessageInfo.id, }; } return rawMessageInfo; }, messageTitle({ messageInfo, }: MessageTitleParam) { const creator = ET.user({ userInfo: messageInfo.creator }); const preview = multimediaMessagePreview(messageInfo); return ET`${creator} ${preview}`; }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawMediaMessageInfo | RawImagesMessageInfo { const { localID, media } = params; invariant(media, 'Media should be provided'); return createMediaMessageInfo({ threadID: row.threadID.toString(), creatorID: row.creatorID.toString(), media, id: row.id.toString(), localID, time: row.time, }); }, createMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, creator: RelativeUserInfo, ): ?(MediaMessageInfo | ImagesMessageInfo) { if (rawMessageInfo.type === messageTypes.IMAGES) { let messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { let messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; } return undefined; }, rawMessageInfoFromMessageData( messageData: MediaMessageData | ImagesMessageData, id: ?string, ): RawMediaMessageInfo | RawImagesMessageInfo { const { sidebarCreation, ...rest } = messageData; if (rest.type === messageTypes.IMAGES && id) { return ({ ...rest, id }: RawImagesMessageInfo); } else if (rest.type === messageTypes.IMAGES) { return ({ ...rest }: RawImagesMessageInfo); } else if (id) { return ({ ...rest, id }: RawMediaMessageInfo); } else { return ({ ...rest }: RawMediaMessageInfo); } }, shimUnsupportedMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, platformDetails: ?PlatformDetails, ): RawMediaMessageInfo | RawImagesMessageInfo | RawUnsupportedMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { return rawMessageInfo; } const messageInfo = versionSpecificMediaMessageFormat( rawMessageInfo, platformDetails, ); const containsBlobServiceMedia = messageInfo.media.some( isMediaBlobServiceHosted, ); const containsEncryptedMedia = messageInfo.media.some( media => media.type === 'encrypted_photo' || media.type === 'encrypted_video', ); if ( !containsBlobServiceMedia && !containsEncryptedMedia && hasMinCodeVersion(platformDetails, { native: 158 }) ) { return messageInfo; } if ( !containsBlobServiceMedia && hasMinCodeVersion(platformDetails, { native: 205 }) ) { return messageInfo; } if (hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION })) { return messageInfo; } const { id } = messageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: messageInfo.threadID, creatorID: messageInfo.creatorID, time: messageInfo.time, robotext: multimediaMessagePreview(messageInfo), unsupportedMessageInfo: messageInfo, }; }, unshimMessageInfo( unwrapped: RawMediaMessageInfo | RawImagesMessageInfo, messageInfo: RawMessageInfo, ): ?RawMessageInfo { if (unwrapped.type === messageTypes.IMAGES) { return { ...unwrapped, media: unwrapped.media.map(media => { if (media.dimensions) { return media; } const dimensions = preDimensionUploads[media.id]; invariant( dimensions, 'only four photos were uploaded before dimensions were calculated, ' + `and ${media.id} was not one of them`, ); return { ...media, dimensions }; }), }; } else if (unwrapped.type === messageTypes.MULTIMEDIA) { for (const media of unwrapped.media) { if (isMediaBlobServiceHosted(media)) { return messageInfo; } const { type } = media; if ( type !== 'photo' && type !== 'video' && type !== 'encrypted_photo' && type !== 'encrypted_video' ) { return messageInfo; } } } return undefined; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): Promise { const media = []; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, 'messageInfo should be multimedia type!', ); for (const singleMedia of messageInfo.media) { media.push(singleMedia); } } const contentString = contentStringForMediaArray(media); const creator = ET.user({ userInfo: messageInfos[0].creator }); let body, merged; if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { body = `sent you ${contentString}`; merged = body; } else { body = `sent ${contentString}`; const thread = ET.thread({ display: 'shortName', threadInfo }); merged = ET`${body} to ${thread}`; } merged = ET`${creator} ${merged}`; return { merged, body, title: threadInfo.uiName, prefix: ET`${creator}`, }; }, notificationCollapseKey( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, ): string { // We use the legacy constant here to collapse both types into one return joinResult( messageTypes.IMAGES, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, generatesNotifs: async ( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, messageData: MediaMessageData | ImagesMessageData, ) => (messageData.sidebarCreation ? undefined : pushTypes.NOTIF), includedInRepliesCount: true, canBeSidebarSource: true, canBePinned: true, validator: rawMultimediaMessageInfoValidator, }); // Four photos were uploaded before dimensions were calculated server-side, // and delivered to clients without dimensions in the MultimediaMessageInfo. const preDimensionUploads = { '156642': { width: 1440, height: 1080 }, '156649': { width: 720, height: 803 }, '156794': { width: 720, height: 803 }, '156877': { width: 574, height: 454 }, }; diff --git a/lib/shared/messages/reaction-message-spec.js b/lib/shared/messages/reaction-message-spec.js index 537b3e0fe..9a6a1646e 100644 --- a/lib/shared/messages/reaction-message-spec.js +++ b/lib/shared/messages/reaction-message-spec.js @@ -1,228 +1,228 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec, type MessageTitleParam, type GeneratesNotifsParams, } from './message-spec.js'; import { assertSingleMessageInfo, joinResult } from './utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { assertMessageType, messageTypes, } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type ReactionMessageData, type RawReactionMessageInfo, type ReactionMessageInfo, rawReactionMessageInfoValidator, } from '../../types/messages/reaction.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET } from '../../utils/entity-text.js'; import { threadIsGroupChat } from '../thread-utils.js'; import { hasMinCodeVersion } from '../version-utils.js'; export const reactionMessageSpec: MessageSpec< ReactionMessageData, RawReactionMessageInfo, ReactionMessageInfo, > = Object.freeze({ messageContentForServerDB( data: ReactionMessageData | RawReactionMessageInfo, ): string { return JSON.stringify({ reaction: data.reaction, action: data.action, }); }, messageContentForClientDB(data: RawReactionMessageInfo): string { return JSON.stringify({ targetMessageID: data.targetMessageID, reaction: data.reaction, action: data.action, }); }, messageTitle({ messageInfo }: MessageTitleParam) { const creator = ET.user({ userInfo: messageInfo.creator }); const preview = messageInfo.action === 'add_reaction' ? `reacted ${messageInfo.reaction} to a message` : 'unreacted to a message'; return ET`${creator} ${preview}`; }, rawMessageInfoFromServerDBRow(row: Object): RawReactionMessageInfo { invariant( row.targetMessageID !== null && row.targetMessageID !== undefined, 'targetMessageID should be set', ); const content = JSON.parse(row.content); return { type: messageTypes.REACTION, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), targetMessageID: row.targetMessageID.toString(), reaction: content.reaction, action: content.action, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawReactionMessageInfo { const messageType = assertMessageType(parseInt(clientDBMessageInfo.type)); invariant( messageType === messageTypes.REACTION, 'message must be of type REACTION', ); invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for Reaction', ); const content = JSON.parse(clientDBMessageInfo.content); const rawReactionMessageInfo: RawReactionMessageInfo = { type: messageTypes.REACTION, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, targetMessageID: content.targetMessageID, reaction: content.reaction, action: content.action, }; return rawReactionMessageInfo; }, createMessageInfo( rawMessageInfo: RawReactionMessageInfo, creator: RelativeUserInfo, ): ReactionMessageInfo { return { type: messageTypes.REACTION, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, targetMessageID: rawMessageInfo.targetMessageID, reaction: rawMessageInfo.reaction, action: rawMessageInfo.action, }; }, rawMessageInfoFromMessageData( messageData: ReactionMessageData, id: ?string, ): RawReactionMessageInfo { invariant(id, 'RawReactionMessageInfo needs id'); return { ...messageData, id }; }, shimUnsupportedMessageInfo( rawMessageInfo: RawReactionMessageInfo, platformDetails: ?PlatformDetails, ): RawReactionMessageInfo | RawUnsupportedMessageInfo { if (hasMinCodeVersion(platformDetails, { native: 167 })) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'reacted to a message', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo(unwrapped: RawReactionMessageInfo): RawReactionMessageInfo { return unwrapped; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): Promise { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.REACTION, 'messageInfo should be reaction type', ); const creator = ET.user({ userInfo: messageInfo.creator }); const body = messageInfo.action === 'add_reaction' ? `reacted ${messageInfo.reaction} to your message` : 'unreacted to your message'; let merged = ET`${creator} ${body}`; if (threadInfo.name || threadIsGroupChat(threadInfo)) { const thread = ET.thread({ display: 'shortName', threadInfo }); merged = ET`${merged} in ${thread}`; } return { merged, body, title: threadInfo.uiName, prefix: ET`${creator}`, }; }, notificationCollapseKey(rawMessageInfo: RawReactionMessageInfo): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.targetMessageID, ); }, generatesNotifs: async ( rawMessageInfo: RawReactionMessageInfo, messageData: ReactionMessageData, params: GeneratesNotifsParams, ) => { const { action } = rawMessageInfo; const { notifTargetUserID, fetchMessageInfoByID } = params; const targetMessageInfo = await fetchMessageInfoByID( rawMessageInfo.targetMessageID, ); if (targetMessageInfo?.creatorID !== notifTargetUserID) { return undefined; } return action === 'add_reaction' ? pushTypes.NOTIF : pushTypes.RESCIND; }, canBeSidebarSource: false, canBePinned: false, canBeRenderedIndependently: false, validator: rawReactionMessageInfoValidator, }); diff --git a/lib/shared/messages/remove-members-message-spec.js b/lib/shared/messages/remove-members-message-spec.js index f9a5ae8f5..0ffbe9b18 100644 --- a/lib/shared/messages/remove-members-message-spec.js +++ b/lib/shared/messages/remove-members-message-spec.js @@ -1,189 +1,189 @@ // @flow import invariant from 'invariant'; import type { CreateMessageInfoParams, MessageSpec, NotificationTextsParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type RawRemoveMembersMessageInfo, type RemoveMembersMessageData, type RemoveMembersMessageInfo, rawRemoveMembersMessageInfoValidator, } from '../../types/messages/remove-members.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { values } from '../../utils/objects.js'; import { notifRobotextForMessageInfo } from '../notif-utils.js'; type RemoveMembersMessageSpec = MessageSpec< RemoveMembersMessageData, RawRemoveMembersMessageInfo, RemoveMembersMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: RemoveMembersMessageData | RawRemoveMembersMessageInfo, ) => string, ... }; export const removeMembersMessageSpec: RemoveMembersMessageSpec = Object.freeze( { messageContentForServerDB( data: RemoveMembersMessageData | RawRemoveMembersMessageInfo, ): string { return JSON.stringify(data.removedUserIDs); }, messageContentForClientDB(data: RawRemoveMembersMessageInfo): string { return removeMembersMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawRemoveMembersMessageInfo { return { type: messageTypes.REMOVE_MEMBERS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), removedUserIDs: JSON.parse(row.content), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawRemoveMembersMessageInfo { const content = clientDBMessageInfo.content; invariant( content !== undefined && content !== null, 'content must be defined for RemoveMembers', ); const rawRemoveMembersMessageInfo: RawRemoveMembersMessageInfo = { type: messageTypes.REMOVE_MEMBERS, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, removedUserIDs: JSON.parse(content), }; return rawRemoveMembersMessageInfo; }, createMessageInfo( rawMessageInfo: RawRemoveMembersMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): RemoveMembersMessageInfo { const removedMembers = params.createRelativeUserInfos( rawMessageInfo.removedUserIDs, ); return { type: messageTypes.REMOVE_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, removedMembers, }; }, rawMessageInfoFromMessageData( messageData: RemoveMembersMessageData, id: ?string, ): RawRemoveMembersMessageInfo { invariant(id, 'RawRemoveMembersMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: RemoveMembersMessageInfo): EntityText { const users = messageInfo.removedMembers; invariant(users.length !== 0, 'added who??'); const creator = ET.user({ userInfo: messageInfo.creator }); const removedUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); return ET`${creator} removed ${removedUsers}`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, params: NotificationTextsParams, ): Promise { const removedMembersObject: { [string]: RelativeUserInfo } = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); for (const member of messageInfo.removedMembers) { removedMembersObject[member.id] = member; } } const removedMembers = values(removedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, removedMembers }; const { parentThreadInfo } = params; const robotext = notifRobotextForMessageInfo( mergedMessageInfo, threadInfo, parentThreadInfo, ); const merged = ET`${robotext} from ${ET.thread({ display: 'shortName', threadInfo, })}`; return { merged, title: threadInfo.uiName, body: robotext, }; }, notificationCollapseKey( rawMessageInfo: RawRemoveMembersMessageInfo, ): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, userIDs( rawMessageInfo: RawRemoveMembersMessageInfo, ): $ReadOnlyArray { return rawMessageInfo.removedUserIDs; }, canBeSidebarSource: true, canBePinned: false, validator: rawRemoveMembersMessageInfoValidator, }, ); diff --git a/lib/shared/messages/restore-entry-message-spec.js b/lib/shared/messages/restore-entry-message-spec.js index 94cd84bf5..6bbe7c0f3 100644 --- a/lib/shared/messages/restore-entry-message-spec.js +++ b/lib/shared/messages/restore-entry-message-spec.js @@ -1,153 +1,153 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec } from './message-spec.js'; import { assertSingleMessageInfo } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type RawRestoreEntryMessageInfo, type RestoreEntryMessageData, type RestoreEntryMessageInfo, rawRestoreEntryMessageInfoValidator, } from '../../types/messages/restore-entry.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { prettyDate } from '../../utils/date-utils.js'; import { ET, type EntityText } from '../../utils/entity-text.js'; type RestoreEntryMessageSpec = MessageSpec< RestoreEntryMessageData, RawRestoreEntryMessageInfo, RestoreEntryMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: RestoreEntryMessageData | RawRestoreEntryMessageInfo, ) => string, ... }; export const restoreEntryMessageSpec: RestoreEntryMessageSpec = Object.freeze({ messageContentForServerDB( data: RestoreEntryMessageData | RawRestoreEntryMessageInfo, ): string { return JSON.stringify({ entryID: data.entryID, date: data.date, text: data.text, }); }, messageContentForClientDB(data: RawRestoreEntryMessageInfo): string { return restoreEntryMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawRestoreEntryMessageInfo { const content = JSON.parse(row.content); return { type: messageTypes.RESTORE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawRestoreEntryMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for RestoreEntry', ); const content = JSON.parse(clientDBMessageInfo.content); const rawRestoreEntryMessageInfo: RawRestoreEntryMessageInfo = { type: messageTypes.RESTORE_ENTRY, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, entryID: content.entryID, date: content.date, text: content.text, }; return rawRestoreEntryMessageInfo; }, createMessageInfo( rawMessageInfo: RawRestoreEntryMessageInfo, creator: RelativeUserInfo, ): RestoreEntryMessageInfo { return { type: messageTypes.RESTORE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; }, rawMessageInfoFromMessageData( messageData: RestoreEntryMessageData, id: ?string, ): RawRestoreEntryMessageInfo { invariant(id, 'RawRestoreEntryMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: RestoreEntryMessageInfo): EntityText { const date = prettyDate(messageInfo.date); const creator = ET.user({ userInfo: messageInfo.creator }); const { text } = messageInfo; return ET`${creator} restored an event scheduled for ${date}: "${text}"`; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): Promise { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.RESTORE_ENTRY, 'messageInfo should be messageTypes.RESTORE_ENTRY!', ); const thread = ET.thread({ display: 'shortName', threadInfo }); const creator = ET.user({ userInfo: messageInfo.creator }); const date = prettyDate(messageInfo.date); const prefix = ET`${creator}`; let body = ET`restored an event in ${thread}`; body = ET`${body} scheduled for ${date}: "${messageInfo.text}"`; const merged = ET`${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; }, generatesNotifs: async () => pushTypes.NOTIF, canBeSidebarSource: true, canBePinned: false, validator: rawRestoreEntryMessageInfoValidator, }); diff --git a/lib/shared/messages/text-message-spec.js b/lib/shared/messages/text-message-spec.js index b37d29374..c7f070cd2 100644 --- a/lib/shared/messages/text-message-spec.js +++ b/lib/shared/messages/text-message-spec.js @@ -1,332 +1,332 @@ // @flow import invariant from 'invariant'; import * as SimpleMarkdown from 'simple-markdown'; import { pushTypes, type MessageSpec, type RawMessageInfoFromServerDBRowParams, type NotificationTextsParams, } from './message-spec.js'; import { assertSingleMessageInfo, joinResult } from './utils.js'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from '../../actions/thread-actions.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type RawTextMessageInfo, type TextMessageData, type TextMessageInfo, rawTextMessageInfoValidator, } from '../../types/messages/text.js'; import type { MinimallyEncodedThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { NotifTexts } from '../../types/notif-types.js'; import { threadTypes } from '../../types/thread-types-enum.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { useDispatchActionPromise } from '../../utils/action-utils.js'; import { ET } from '../../utils/entity-text.js'; import { type ASTNode, type SingleASTNode, stripSpoilersFromNotifications, stripSpoilersFromMarkdownAST, } from '../markdown.js'; import { isUserMentioned, renderChatMentionsWithAltText, } from '../mention-utils.js'; import { notifTextsForSidebarCreation } from '../notif-utils.js'; import { threadIsGroupChat, extractNewMentionedParentMembers, } from '../thread-utils.js'; /** * most of the markdown leaves contain `content` field * (it is an array or a string) apart from lists, * which have `items` field (that holds an array) */ const rawTextFromMarkdownAST = (node: ASTNode): string => { if (Array.isArray(node)) { return node.map(rawTextFromMarkdownAST).join(''); } const { content, items } = node; if (content && typeof content === 'string') { return content; } else if (items) { return rawTextFromMarkdownAST(items); } else if (content) { return rawTextFromMarkdownAST(content); } return ''; }; const getFirstNonQuotedRawLine = ( nodes: $ReadOnlyArray, ): string => { let result = 'message'; for (const node of nodes) { if (node.type === 'blockQuote') { result = 'quoted message'; } else { const rawText = rawTextFromMarkdownAST(node); if (!rawText || !rawText.replace(/\s/g, '')) { // handles the case of an empty(or containing only white spaces) // new line that usually occurs between a quote and the rest // of the message(we don't want it as a title, thus continue) continue; } return rawText; } } return result; }; type TextMessageSpec = MessageSpec< TextMessageData, RawTextMessageInfo, TextMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: TextMessageData | RawTextMessageInfo, ) => string, ... }; export const textMessageSpec: TextMessageSpec = Object.freeze({ messageContentForServerDB( data: TextMessageData | RawTextMessageInfo, ): string { return data.text; }, messageContentForClientDB(data: RawTextMessageInfo): string { return textMessageSpec.messageContentForServerDB(data); }, messageTitle({ messageInfo, markdownRules }) { const { text } = messageInfo; const parser = SimpleMarkdown.parserFor(markdownRules); const ast = stripSpoilersFromMarkdownAST( parser(text, { disableAutoBlockNewlines: true }), ); return ET`${getFirstNonQuotedRawLine(ast).trim()}`; }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawTextMessageInfo { let rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), text: row.content, }; if (params.localID) { rawTextMessageInfo = { ...rawTextMessageInfo, localID: params.localID }; } return rawTextMessageInfo; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawTextMessageInfo { let rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, text: clientDBMessageInfo.content ?? '', }; if (clientDBMessageInfo.local_id) { rawTextMessageInfo = { ...rawTextMessageInfo, localID: clientDBMessageInfo.local_id, }; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawTextMessageInfo = { ...rawTextMessageInfo, id: clientDBMessageInfo.id, }; } return rawTextMessageInfo; }, createMessageInfo( rawMessageInfo: RawTextMessageInfo, creator: RelativeUserInfo, ): TextMessageInfo { let messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; }, rawMessageInfoFromMessageData( messageData: TextMessageData, id: ?string, ): RawTextMessageInfo { const { sidebarCreation, ...rest } = messageData; if (id) { return { ...rest, id }; } else { return { ...rest }; } }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, params: NotificationTextsParams, ): Promise { // We special-case sidebarCreations. Usually we don't send any notifs in // that case to avoid a double-notif, but we need to update the original // notif if somebody was @-tagged in this message if ( messageInfos.length === 3 && messageInfos[2].type === messageTypes.SIDEBAR_SOURCE && messageInfos[1].type === messageTypes.CREATE_SIDEBAR ) { const sidebarSourceMessageInfo = messageInfos[2]; const createSidebarMessageInfo = messageInfos[1]; const sourceMessage = messageInfos[2].sourceMessage; const { username } = params.notifTargetUserInfo; if (!username) { // If we couldn't fetch the username for some reason, we won't be able // to extract @-mentions anyways, so we'll give up on updating the notif return null; } if ( sourceMessage.type === messageTypes.TEXT && isUserMentioned(username, sourceMessage.text) ) { // If the notif target was already mentioned in the source message, // there's no need to update the notif return null; } const messageInfo = messageInfos[0]; invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); if (!isUserMentioned(username, messageInfo.text)) { // We only need to update the notif if the notif target is mentioned return null; } return notifTextsForSidebarCreation({ createSidebarMessageInfo, sidebarSourceMessageInfo, firstSidebarMessageInfo: messageInfo, threadInfo, params, }); } const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); const transformedNotificationText = stripSpoilersFromNotifications( renderChatMentionsWithAltText(messageInfo.text), ); if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { const thread = ET.thread({ display: 'uiName', threadInfo }); return { merged: ET`${thread}: ${transformedNotificationText}`, body: transformedNotificationText, title: threadInfo.uiName, }; } else { const creator = ET.user({ userInfo: messageInfo.creator }); const thread = ET.thread({ display: 'shortName', threadInfo }); return { merged: ET`${creator} to ${thread}: ${transformedNotificationText}`, body: transformedNotificationText, title: threadInfo.uiName, prefix: ET`${creator}:`, }; } }, notificationCollapseKey( rawMessageInfo: RawTextMessageInfo, messageData: TextMessageData, ): ?string { if (!messageData.sidebarCreation) { return null; } return joinResult(messageTypes.CREATE_SIDEBAR, rawMessageInfo.threadID); }, generatesNotifs: async () => pushTypes.NOTIF, includedInRepliesCount: true, useCreationSideEffectsFunc: () => { const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); return async ( messageInfo: RawTextMessageInfo, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ) => { if (threadInfo.type !== threadTypes.SIDEBAR) { return; } invariant(parentThreadInfo, 'all sidebars should have a parent thread'); const mentionedNewMembers = extractNewMentionedParentMembers( messageInfo.text, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return; } const newMemberIDs = mentionedNewMembers.map(({ id }) => id); const addMembersPromise = callChangeThreadSettings({ threadID: threadInfo.id, changes: { newMemberIDs }, }); dispatchActionPromise(changeThreadSettingsActionTypes, addMembersPromise); await addMembersPromise; }; }, canBeSidebarSource: true, canBePinned: true, validator: rawTextMessageInfoValidator, }); diff --git a/lib/shared/messages/update-relationship-message-spec.js b/lib/shared/messages/update-relationship-message-spec.js index db7be299e..785674344 100644 --- a/lib/shared/messages/update-relationship-message-spec.js +++ b/lib/shared/messages/update-relationship-message-spec.js @@ -1,177 +1,177 @@ // @flow import invariant from 'invariant'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, } from './message-spec.js'; import { assertSingleMessageInfo } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type RawUpdateRelationshipMessageInfo, type UpdateRelationshipMessageData, type UpdateRelationshipMessageInfo, rawUpdateRelationshipMessageInfoValidator, } from '../../types/messages/update-relationship.js'; import type { NotifTexts } from '../../types/notif-types.js'; -import type { ThreadInfo } from '../../types/thread-types.js'; +import type { LegacyThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText } from '../../utils/entity-text.js'; type UpdateRelationshipMessageSpec = MessageSpec< UpdateRelationshipMessageData, RawUpdateRelationshipMessageInfo, UpdateRelationshipMessageInfo, > & { // We need to explicitly type this as non-optional so that // it can be referenced from messageContentForClientDB below +messageContentForServerDB: ( data: UpdateRelationshipMessageData | RawUpdateRelationshipMessageInfo, ) => string, ... }; export const updateRelationshipMessageSpec: UpdateRelationshipMessageSpec = Object.freeze({ messageContentForServerDB( data: UpdateRelationshipMessageData | RawUpdateRelationshipMessageInfo, ): string { return JSON.stringify({ operation: data.operation, targetID: data.targetID, }); }, messageContentForClientDB(data: RawUpdateRelationshipMessageInfo): string { return updateRelationshipMessageSpec.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow( row: Object, ): RawUpdateRelationshipMessageInfo { const content = JSON.parse(row.content); return { type: messageTypes.UPDATE_RELATIONSHIP, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), targetID: content.targetID, operation: content.operation, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawUpdateRelationshipMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for UpdateRelationship', ); const content = JSON.parse(clientDBMessageInfo.content); const rawUpdateRelationshipMessageInfo: RawUpdateRelationshipMessageInfo = { type: messageTypes.UPDATE_RELATIONSHIP, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, targetID: content.targetID, operation: content.operation, }; return rawUpdateRelationshipMessageInfo; }, createMessageInfo( rawMessageInfo: RawUpdateRelationshipMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?UpdateRelationshipMessageInfo { const target = params.createRelativeUserInfos([ rawMessageInfo.targetID, ])[0]; if (!target) { return null; } return { type: messageTypes.UPDATE_RELATIONSHIP, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, target, time: rawMessageInfo.time, operation: rawMessageInfo.operation, }; }, rawMessageInfoFromMessageData( messageData: UpdateRelationshipMessageData, id: ?string, ): RawUpdateRelationshipMessageInfo { invariant(id, 'RawUpdateRelationshipMessageInfo needs id'); return { ...messageData, id }; }, // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return robotext(messageInfo: UpdateRelationshipMessageInfo): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); if (messageInfo.operation === 'request_sent') { const target = ET.user({ userInfo: messageInfo.target }); return ET`${creator} sent ${target} a friend request`; } else if (messageInfo.operation === 'request_accepted') { const targetPossessive = ET.user({ userInfo: messageInfo.target, possessive: true, }); return ET`${creator} accepted ${targetPossessive} friend request`; } invariant( false, `Invalid operation ${messageInfo.operation} ` + `of message with type ${messageInfo.type}`, ); }, unshimMessageInfo( unwrapped: RawUpdateRelationshipMessageInfo, ): RawUpdateRelationshipMessageInfo { return unwrapped; }, async notificationTexts( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): Promise { const messageInfo = assertSingleMessageInfo(messageInfos); const creator = ET.user({ userInfo: messageInfo.creator }); const prefix = ET`${creator}`; const title = threadInfo.uiName; const body = messageInfo.operation === 'request_sent' ? 'sent you a friend request' : 'accepted your friend request'; const merged = ET`${prefix} ${body}`; return { merged, body, title, prefix, }; }, generatesNotifs: async () => pushTypes.NOTIF, canBeSidebarSource: true, canBePinned: false, validator: rawUpdateRelationshipMessageInfoValidator, }); diff --git a/lib/shared/notif-utils.js b/lib/shared/notif-utils.js index ae6915e8a..f20f4e49d 100644 --- a/lib/shared/notif-utils.js +++ b/lib/shared/notif-utils.js @@ -1,327 +1,327 @@ // @flow import invariant from 'invariant'; import { isUserMentioned } from './mention-utils.js'; import { robotextForMessageInfo } from './message-utils.js'; import type { NotificationTextsParams } from './messages/message-spec.js'; import { messageSpecs } from './messages/message-specs.js'; import { threadNoun } from './thread-utils.js'; import { type MessageType, messageTypes } from '../types/message-types-enum.js'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type MessageData, type SidebarSourceMessageInfo, } from '../types/message-types.js'; import type { CreateSidebarMessageInfo } from '../types/messages/create-sidebar.js'; import type { TextMessageInfo } from '../types/messages/text.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { NotifTexts, ResolvedNotifTexts } from '../types/notif-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; -import { type ThreadInfo } from '../types/thread-types.js'; +import { type LegacyThreadInfo } from '../types/thread-types.js'; import type { RelativeUserInfo, UserInfo } from '../types/user-types.js'; import { prettyDate } from '../utils/date-utils.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { ET, getEntityTextAsString, type EntityText, type ThreadEntity, } from '../utils/entity-text.js'; import { promiseAll } from '../utils/promises.js'; import { trimText } from '../utils/text-utils.js'; async function notifTextsForMessageInfo( messageInfos: MessageInfo[], - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: LegacyThreadInfo, + parentThreadInfo: ?LegacyThreadInfo, notifTargetUserInfo: UserInfo, getENSNames: ?GetENSNames, ): Promise { const fullNotifTexts = await fullNotifTextsForMessageInfo( messageInfos, threadInfo, parentThreadInfo, notifTargetUserInfo, getENSNames, ); if (!fullNotifTexts) { return fullNotifTexts; } const merged = trimText(fullNotifTexts.merged, 300); const body = trimText(fullNotifTexts.body, 300); const title = trimText(fullNotifTexts.title, 100); if (!fullNotifTexts.prefix) { return { merged, body, title }; } const prefix = trimText(fullNotifTexts.prefix, 50); return { merged, body, title, prefix }; } function notifTextsForEntryCreationOrEdit( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): NotifTexts { const hasCreateEntry = messageInfos.some( messageInfo => messageInfo.type === messageTypes.CREATE_ENTRY, ); const messageInfo = messageInfos[0]; const thread = ET.thread({ display: 'shortName', threadInfo }); const creator = ET.user({ userInfo: messageInfo.creator }); const prefix = ET`${creator}`; if (!hasCreateEntry) { invariant( messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.EDIT_ENTRY!', ); const date = prettyDate(messageInfo.date); let body = ET`updated the text of an event in ${thread}`; body = ET`${body} scheduled for ${date}: "${messageInfo.text}"`; const merged = ET`${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } invariant( messageInfo.type === messageTypes.CREATE_ENTRY || messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.CREATE_ENTRY/EDIT_ENTRY!', ); const date = prettyDate(messageInfo.date); let body = ET`created an event in ${thread}`; body = ET`${body} scheduled for ${date}: "${messageInfo.text}"`; const merged = ET`${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } type NotifTextsForSubthreadCreationInput = { +creator: RelativeUserInfo, +threadType: ThreadType, - +parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +childThreadName: ?string, +childThreadUIName: string | ThreadEntity, }; function notifTextsForSubthreadCreation( input: NotifTextsForSubthreadCreationInput, ): NotifTexts { const { creator, threadType, parentThreadInfo, childThreadName, childThreadUIName, } = input; const prefix = ET`${ET.user({ userInfo: creator })}`; let body: string | EntityText = `created a new ${threadNoun( threadType, parentThreadInfo.id, )}`; if (parentThreadInfo.name && parentThreadInfo.type !== threadTypes.GENESIS) { body = ET`${body} in ${parentThreadInfo.name}`; } let merged = ET`${prefix} ${body}`; if (childThreadName) { merged = ET`${merged} called "${childThreadName}"`; } return { merged, body, title: childThreadUIName, prefix, }; } type NotifTextsForSidebarCreationInput = { +createSidebarMessageInfo: CreateSidebarMessageInfo, +sidebarSourceMessageInfo?: ?SidebarSourceMessageInfo, +firstSidebarMessageInfo?: ?TextMessageInfo, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +params: NotificationTextsParams, }; function notifTextsForSidebarCreation( input: NotifTextsForSidebarCreationInput, ): NotifTexts { const { sidebarSourceMessageInfo, createSidebarMessageInfo, firstSidebarMessageInfo, threadInfo, params, } = input; const creator = ET.user({ userInfo: createSidebarMessageInfo.creator }); const prefix = ET`${creator}`; const initialName = createSidebarMessageInfo.initialThreadState.name; const sourceMessageAuthorPossessive = ET.user({ userInfo: createSidebarMessageInfo.sourceMessageAuthor, possessive: true, }); let body: string | EntityText = 'started a thread in response to'; body = ET`${body} ${sourceMessageAuthorPossessive} message`; const { username } = params.notifTargetUserInfo; if ( username && sidebarSourceMessageInfo && sidebarSourceMessageInfo.sourceMessage.type === messageTypes.TEXT && isUserMentioned(username, sidebarSourceMessageInfo.sourceMessage.text) ) { body = ET`${body} that tagged you`; } else if ( username && firstSidebarMessageInfo && isUserMentioned(username, firstSidebarMessageInfo.text) ) { body = ET`${body} and tagged you`; } else if (initialName) { body = ET`${body} "${initialName}"`; } return { merged: ET`${prefix} ${body}`, body, title: threadInfo.uiName, prefix, }; } function mostRecentMessageInfoType( messageInfos: $ReadOnlyArray, ): MessageType { if (messageInfos.length === 0) { throw new Error('expected MessageInfo, but none present!'); } return messageInfos[0].type; } async function fullNotifTextsForMessageInfo( messageInfos: $ReadOnlyArray, - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: LegacyThreadInfo, + parentThreadInfo: ?LegacyThreadInfo, notifTargetUserInfo: UserInfo, getENSNames: ?GetENSNames, ): Promise { const mostRecentType = mostRecentMessageInfoType(messageInfos); const messageSpec = messageSpecs[mostRecentType]; invariant( messageSpec.notificationTexts, `we're not aware of messageType ${mostRecentType}`, ); const unresolvedNotifTexts = await messageSpec.notificationTexts( messageInfos, threadInfo, { notifTargetUserInfo, parentThreadInfo }, ); if (!unresolvedNotifTexts) { return unresolvedNotifTexts; } const resolveToString = async ( entityText: string | EntityText, ): Promise => { if (typeof entityText === 'string') { return entityText; } const notifString = await getEntityTextAsString(entityText, getENSNames, { prefixThisThreadNounWith: 'your', }); invariant( notifString !== null && notifString !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return notifString; }; let promises = { merged: resolveToString(unresolvedNotifTexts.merged), body: resolveToString(unresolvedNotifTexts.body), title: resolveToString(ET`${unresolvedNotifTexts.title}`), }; if (unresolvedNotifTexts.prefix) { promises = { ...promises, prefix: resolveToString(unresolvedNotifTexts.prefix), }; } return await promiseAll(promises); } function notifRobotextForMessageInfo( messageInfo: RobotextMessageInfo, - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: LegacyThreadInfo, + parentThreadInfo: ?LegacyThreadInfo, ): EntityText { const robotext = robotextForMessageInfo( messageInfo, threadInfo, parentThreadInfo, ); return robotext.map(entity => { if ( typeof entity !== 'string' && entity.type === 'thread' && entity.id === threadInfo.id ) { return ET.thread({ display: 'shortName', threadInfo, possessive: entity.possessive, }); } return entity; }); } function getNotifCollapseKey( rawMessageInfo: RawMessageInfo, messageData: MessageData, ): ?string { const messageSpec = messageSpecs[rawMessageInfo.type]; return ( messageSpec.notificationCollapseKey?.(rawMessageInfo, messageData) ?? null ); } type Unmerged = $ReadOnly<{ body: string, title: string, prefix?: string, ... }>; type Merged = { body: string, title: string, }; function mergePrefixIntoBody(unmerged: Unmerged): Merged { const { body, title, prefix } = unmerged; const merged = prefix ? `${prefix} ${body}` : body; return { body: merged, title }; } export { notifRobotextForMessageInfo, notifTextsForMessageInfo, notifTextsForEntryCreationOrEdit, notifTextsForSubthreadCreation, notifTextsForSidebarCreation, getNotifCollapseKey, mergePrefixIntoBody, }; diff --git a/lib/shared/reaction-utils.js b/lib/shared/reaction-utils.js index add397ac4..83de00f07 100644 --- a/lib/shared/reaction-utils.js +++ b/lib/shared/reaction-utils.js @@ -1,110 +1,110 @@ // @flow import _sortBy from 'lodash/fp/sortBy.js'; import * as React from 'react'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { threadHasPermission } from './thread-utils.js'; import { stringForUserExplicit } from './user-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import type { ReactionInfo } from '../selectors/chat-selectors.js'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; -import { type ThreadInfo } from '../types/thread-types.js'; +import { type LegacyThreadInfo } from '../types/thread-types.js'; import { useSelector } from '../utils/redux-utils.js'; function useViewerAlreadySelectedMessageReactions( reactions: ReactionInfo, ): $ReadOnlyArray { return React.useMemo(() => { const alreadySelectedEmojis = []; for (const reaction in reactions) { const reactionInfo = reactions[reaction]; if (reactionInfo.viewerReacted) { alreadySelectedEmojis.push(reaction); } } return alreadySelectedEmojis; }, [reactions]); } export type MessageReactionListInfo = { +id: string, +isViewer: boolean, +reaction: string, +username: string, }; function useMessageReactionsList( reactions: ReactionInfo, ): $ReadOnlyArray { const withoutENSNames = React.useMemo(() => { const result = []; for (const reaction in reactions) { const reactionInfo = reactions[reaction]; reactionInfo.users.forEach(user => { result.push({ ...user, username: stringForUserExplicit(user), reaction, }); }); } const sortByNumReactions = (reactionInfo: MessageReactionListInfo) => { const numOfReactions = reactions[reactionInfo.reaction].users.length; return numOfReactions ? -numOfReactions : 0; }; return _sortBy( ([sortByNumReactions, 'username']: $ReadOnlyArray< ((reactionInfo: MessageReactionListInfo) => mixed) | string, >), )(result); }, [reactions]); return useENSNames(withoutENSNames); } function useCanCreateReactionFromMessage( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const targetMessageCreatorRelationship = useSelector( state => state.userStore.userInfos[targetMessageInfo.creator.id] ?.relationshipStatus, ); if ( !targetMessageInfo.id || threadInfo.sourceMessageID === targetMessageInfo.id ) { return false; } const creatorRelationshipHasBlock = targetMessageCreatorRelationship && relationshipBlockedInEitherDirection(targetMessageCreatorRelationship); const hasPermission = threadHasPermission( threadInfo, threadPermissions.REACT_TO_MESSAGE, ); return hasPermission && !creatorRelationshipHasBlock; } export { useViewerAlreadySelectedMessageReactions, useMessageReactionsList, useCanCreateReactionFromMessage, }; diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index a4b864cd5..339ba2e25 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,388 +1,388 @@ // @flow import * as React from 'react'; import { messageID } from './message-utils.js'; import SearchIndex from './search-index.js'; import { userIsMember, threadMemberHasPermission, getContainingThreadID, } from './thread-utils.js'; import { useSearchMessages as useSearchMessagesAction, searchMessagesActionTypes, } from '../actions/message-actions.js'; import { searchUsers, searchUsersActionTypes, } from '../actions/user-actions.js'; import { ENSCacheContext } from '../components/ens-cache-provider.react.js'; import genesis from '../facts/genesis.js'; import type { ChatMessageInfoItem, MessageListData, } from '../selectors/chat-selectors.js'; import type { MessageInfo, RawMessageInfo } from '../types/message-types.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; -import { type ThreadInfo } from '../types/thread-types.js'; +import { type LegacyThreadInfo } from '../types/thread-types.js'; import type { AccountUserInfo, UserListItem, GlobalAccountUserInfo, } from '../types/user-types.js'; import { useServerCall, useDispatchActionPromise, } from '../utils/action-utils.js'; import { isValidENSName } from '../utils/ens-helpers.js'; import { values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; const notFriendNotice = 'not friend'; function getPotentialMemberItems({ text, userInfos, searchIndex, excludeUserIDs, includeServerSearchUsers, inputParentThreadInfo, inputCommunityThreadInfo, threadType, }: { +text: string, +userInfos: { +[id: string]: AccountUserInfo }, +searchIndex: SearchIndex, +excludeUserIDs: $ReadOnlyArray, +includeServerSearchUsers?: $ReadOnlyArray, - +inputParentThreadInfo?: ?ThreadInfo | ?MinimallyEncodedThreadInfo, - +inputCommunityThreadInfo?: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +inputParentThreadInfo?: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + +inputCommunityThreadInfo?: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, +threadType?: ?ThreadType, }): UserListItem[] { const communityThreadInfo = inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis.id ? inputCommunityThreadInfo : null; const parentThreadInfo = inputParentThreadInfo && inputParentThreadInfo.id !== genesis.id ? inputParentThreadInfo : null; const containgThreadID = threadType ? getContainingThreadID(parentThreadInfo, threadType) : null; let containingThreadInfo = null; if (containgThreadID === parentThreadInfo?.id) { containingThreadInfo = parentThreadInfo; } else if (containgThreadID === communityThreadInfo?.id) { containingThreadInfo = communityThreadInfo; } const results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, } = {}; const appendUserInfo = ( userInfo: AccountUserInfo | GlobalAccountUserInfo, ) => { const { id } = userInfo; if (excludeUserIDs.includes(id) || id in results) { return; } if ( communityThreadInfo && !threadMemberHasPermission( communityThreadInfo, id, threadPermissions.KNOW_OF, ) ) { return; } results[id] = { ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, id), isMemberOfContainingThread: userIsMember(containingThreadInfo, id), }; }; if (text === '') { for (const id in userInfos) { appendUserInfo(userInfos[id]); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo(userInfos[id]); } } if (includeServerSearchUsers) { for (const userInfo of includeServerSearchUsers) { appendUserInfo(userInfo); } } const blockedRelationshipsStatuses = new Set([ userRelationshipStatus.BLOCKED_BY_VIEWER, userRelationshipStatus.BLOCKED_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ]); let userResults = values(results); if (text === '') { userResults = userResults.filter(userInfo => { if (!containingThreadInfo) { return userInfo.relationshipStatus === userRelationshipStatus.FRIEND; } if (!userInfo.isMemberOfContainingThread) { return false; } const { relationshipStatus } = userInfo; if (!relationshipStatus) { return true; } return !blockedRelationshipsStatuses.has(relationshipStatus); }); } const nonFriends = []; const blockedUsers = []; const friends = []; const containingThreadMembers = []; const parentThreadMembers = []; for (const userResult of userResults) { const { relationshipStatus } = userResult; if ( relationshipStatus && blockedRelationshipsStatuses.has(relationshipStatus) ) { blockedUsers.push(userResult); } else if (userResult.isMemberOfParentThread) { parentThreadMembers.push(userResult); } else if (userResult.isMemberOfContainingThread) { containingThreadMembers.push(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friends.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = parentThreadMembers .concat(containingThreadMembers) .concat(friends) .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfContainingThread, isMemberOfParentThread, relationshipStatus, ...result }) => { let notice, alert; const username = result.username; if ( relationshipStatus && blockedRelationshipsStatuses.has(relationshipStatus) ) { notice = 'user is blocked'; alert = { title: 'User is blocked', text: `Before you add ${username} to this chat, ` + 'you’ll need to unblock them. You can do this from the Block List ' + 'in the Profile tab.', }; } else if (!isMemberOfContainingThread && containingThreadInfo) { if (threadType !== threadTypes.SIDEBAR) { notice = 'not in community'; alert = { title: 'Not in community', text: 'You can only add members of the community to this chat', }; } else { notice = 'not in parent chat'; alert = { title: 'Not in parent chat', text: 'You can only add members of the parent chat to a thread', }; } } else if ( !containingThreadInfo && relationshipStatus !== userRelationshipStatus.FRIEND ) { notice = notFriendNotice; alert = { title: 'Not a friend', text: `Before you add ${username} to this chat, ` + 'you’ll need to send them a friend request. ' + 'You can do this from the Friend List in the Profile tab.', }; } else if (parentThreadInfo && !isMemberOfParentThread) { notice = 'not in parent chat'; } if (notice) { result = { ...result, notice }; } if (alert) { result = { ...result, alert }; } return result; }, ); } function useSearchMessages(): ( query: string, threadID: string, onResultsReceived: ( messages: $ReadOnlyArray, endReached: boolean, queryID: number, threadID: string, ) => mixed, queryID: number, cursor?: ?string, ) => void { const callSearchMessages = useSearchMessagesAction(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( (query, threadID, onResultsReceived, queryID, cursor) => { const searchMessagesPromise = (async () => { if (query === '') { onResultsReceived([], true, queryID, threadID); return; } const { messages, endReached } = await callSearchMessages({ query, threadID, cursor, }); onResultsReceived(messages, endReached, queryID, threadID); })(); dispatchActionPromise(searchMessagesActionTypes, searchMessagesPromise); }, [callSearchMessages, dispatchActionPromise], ); } function useForwardLookupSearchText(originalText: string): string { const cacheContext = React.useContext(ENSCacheContext); const { ensCache } = cacheContext; const lowercaseText = originalText.toLowerCase(); const [usernameToSearch, setUsernameToSearch] = React.useState(lowercaseText); React.useEffect(() => { (async () => { if (!ensCache || !isValidENSName(lowercaseText)) { setUsernameToSearch(lowercaseText); return; } const address = await ensCache.getAddressForName(lowercaseText); if (address) { setUsernameToSearch(address); } else { setUsernameToSearch(lowercaseText); } })(); }, [ensCache, lowercaseText]); return usernameToSearch; } function useSearchUsers( usernameInputText: string, ): $ReadOnlyArray { const currentUserID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const forwardLookupSearchText = useForwardLookupSearchText(usernameInputText); const [serverSearchResults, setServerSearchResults] = React.useState< $ReadOnlyArray, >([]); const callSearchUsers = useServerCall(searchUsers); const dispatchActionPromise = useDispatchActionPromise(); React.useEffect(() => { const searchUsersPromise = (async () => { if (forwardLookupSearchText.length === 0) { setServerSearchResults([]); } else { try { const { userInfos } = await callSearchUsers(forwardLookupSearchText); setServerSearchResults( userInfos.filter(({ id }) => id !== currentUserID), ); } catch (err) { setServerSearchResults([]); } } })(); dispatchActionPromise(searchUsersActionTypes, searchUsersPromise); }, [ callSearchUsers, currentUserID, dispatchActionPromise, forwardLookupSearchText, ]); return serverSearchResults; } function filterChatMessageInfosForSearch( chatMessageInfos: MessageListData, translatedSearchResults: $ReadOnlyArray, ): ?(ChatMessageInfoItem[]) { if (!chatMessageInfos) { return null; } const idSet = new Set(translatedSearchResults.map(messageID)); const uniqueChatMessageInfoItemsMap = new Map(); for (const item of chatMessageInfos) { if (item.itemType !== 'message' || item.messageInfoType !== 'composable') { continue; } const id = messageID(item.messageInfo); if (idSet.has(id)) { uniqueChatMessageInfoItemsMap.set(id, item); } } const sortedChatMessageInfoItems: ChatMessageInfoItem[] = []; for (let i = 0; i < translatedSearchResults.length; i++) { const id = messageID(translatedSearchResults[i]); const match = uniqueChatMessageInfoItemsMap.get(id); if (match) { sortedChatMessageInfoItems.push(match); } } return sortedChatMessageInfoItems; } export { getPotentialMemberItems, notFriendNotice, useSearchMessages, useSearchUsers, filterChatMessageInfosForSearch, useForwardLookupSearchText, }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 4a34c1380..98f3fc810 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1890 +1,1892 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omitBy from 'lodash/fp/omitBy.js'; import * as React from 'react'; import { generatePendingThreadColor } from './color-utils.js'; import { type ParserRules } from './markdown.js'; import { extractUserMentionsFromText } from './mention-utils.js'; import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { useForwardLookupSearchText } from './search-utils.js'; import threadWatcher from './thread-watcher.js'; import { fetchMostRecentMessagesActionTypes, useFetchMostRecentMessages, } from '../actions/message-actions.js'; import type { RemoveUsersFromThreadInput } from '../actions/thread-actions'; 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 { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { hasPermission, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, } from '../permissions/minimally-encoded-thread-permissions.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, threadInfosSelectorForThreadType, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import { getUserAvatarForThread } from '../shared/avatar-utils.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RobotextMessageInfo, type ComposableMessageInfo, } from '../types/message-types.js'; import type { MinimallyEncodedRawThreadInfo, MinimallyEncodedThreadCurrentUserInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { decodeMinimallyEncodedRoleInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissionPropagationPrefixes, threadPermissions, configurableCommunityPermissions, type ThreadPermission, type ThreadPermissionsInfo, type UserSurfacedPermission, threadPermissionFilterPrefixes, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, } from '../types/thread-types-enum.js'; import { type LegacyRawThreadInfo, - type ThreadInfo, + type LegacyThreadInfo, type MemberInfo, type ServerThreadInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ClientNewThreadRequest, type NewThreadResult, type ChangeThreadSettingsPayload, type UserProfileThreadInfo, type RelativeMemberInfo, type RawThreadInfo, } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo } from '../types/update-types.js'; import type { GlobalAccountUserInfo, UserInfos, AccountUserInfo, LoggedInUserInfo, UserInfo, } 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, type UserEntity, } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { trimText } from '../utils/text-utils.js'; import { pendingThreadIDRegex } from '../utils/validation-utils.js'; function threadHasPermission( - threadInfo: ?(ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), 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.minimallyEncoded) { return hasPermission(threadInfo.currentUser.permissions, permission); } return permissionLookup(threadInfo.currentUser.permissions, permission); } function viewerIsMember( - threadInfo: ?(ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), ): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome( - threadInfo: ?(ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), ): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList( - threadInfo: ?(ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), ): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel( - threadInfo: ?(ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } function threadIsChannel( - threadInfo: ?(ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), ): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } function threadIsSidebar( - threadInfo: ?(ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), ): boolean { return threadInfo?.type === threadTypes.SIDEBAR; } function threadInBackgroundChatList( - threadInfo: ?(ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( - threadInfo: ?(ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), ): 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 | MinimallyEncodedThreadInfo), + threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( - threadInfo: ?(ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), 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< - T: ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, + T: LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, >(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 | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( - threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, ) { return threadMembersWithoutAddedAshoat(threadInfo).length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function threadIsPendingSidebar(threadID: ?string): boolean { return !!threadID?.startsWith('pending/sidebar/'); } function getSingleOtherUser( - threadInfo: ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, 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}`; } 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 UserIDAndUsername = { +id: string, +username: string, ... }; type CreatePendingThreadArgs = { +viewerID: string, +threadType: ThreadType, +members: $ReadOnlyArray, - +parentThreadInfo?: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +parentThreadInfo?: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, -}: CreatePendingThreadArgs): ThreadInfo { +}: CreatePendingThreadArgs): LegacyThreadInfo { const now = Date.now(); if (!members.some(member => member.id === viewerID)) { throw new Error( 'createPendingThread should be called with the viewer as a member', ); } const memberIDs = members.map(member => member.id); 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: members.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, pinnedCount: 0, }; const userInfos: { [string]: UserInfo } = {}; for (const member of members) { const { id, username } = member; userInfos[id] = { id, username }; } return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } type PendingPersonalThread = { - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, +pendingPersonalThreadUserInfo: UserInfo, }; function createPendingPersonalThread( loggedInUserInfo: LoggedInUserInfo, userID: string, username: string, ): PendingPersonalThread { const pendingPersonalThreadUserInfo = { id: userID, username: username, }; const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PERSONAL, members: [loggedInUserInfo, pendingPersonalThreadUserInfo], }); return { threadInfo, pendingPersonalThreadUserInfo }; } function createPendingThreadItem( loggedInUserInfo: LoggedInUserInfo, user: UserIDAndUsername, ): ChatThreadItem { const { threadInfo, pendingPersonalThreadUserInfo } = createPendingPersonalThread(loggedInUserInfo, user.id, user.username); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo, }; } // Returns map from lowercase username to AccountUserInfo function memberLowercaseUsernameMap( members: $ReadOnlyArray, ): Map { const memberMap = new Map(); for (const member of members) { const { id, role, username } = member; if (!role || !username) { continue; } memberMap.set(username.toLowerCase(), { id, username }); } return memberMap; } // Returns map from user ID to AccountUserInfo function extractMentionedMembers( text: string, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): Map { const memberMap = memberLowercaseUsernameMap(threadInfo.members); const mentions = extractUserMentionsFromText(text); const mentionedMembers = new Map(); for (const mention of mentions) { const userInfo = memberMap.get(mention.toLowerCase()); if (userInfo) { mentionedMembers.set(userInfo.id, userInfo); } } return mentionedMembers; } // When a member of the parent is mentioned in a sidebar, // they will be automatically added to that sidebar function extractNewMentionedParentMembers( messageText: string, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): AccountUserInfo[] { const mentionedMembersOfParent = extractMentionedMembers( messageText, parentThreadInfo, ); for (const member of threadInfo.members) { if (member.role) { mentionedMembersOfParent.delete(member.id); } } return [...mentionedMembersOfParent.values()]; } type SharedCreatePendingSidebarInput = { +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, - +parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +loggedInUserInfo: LoggedInUserInfo, }; type BaseCreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +messageTitle: string, }; function baseCreatePendingSidebar( input: BaseCreatePendingSidebarInput, -): ThreadInfo { +): LegacyThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, messageTitle, } = input; const { color, type: parentThreadType } = parentThreadInfo; const threadName = trimText(messageTitle, 30); const initialMembers = new Map(); const { id: viewerID, username: viewerUsername } = loggedInUserInfo; initialMembers.set(viewerID, { id: viewerID, username: viewerUsername }); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { const { id: sourceAuthorID, username: sourceAuthorUsername } = sourceMessageInfo.creator; invariant( sourceAuthorUsername, 'sourceAuthorUsername should be set in createPendingSidebar', ); const initialMemberUserInfo = { 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 = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } if (sourceMessageInfo.type === messageTypes.TEXT) { const mentionedMembersOfParent = extractMentionedMembers( sourceMessageInfo.text, parentThreadInfo, ); for (const [memberID, member] of mentionedMembersOfParent) { initialMembers.set(memberID, member); } } 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 { +): LegacyThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = entityTextToRawString(messageTitleEntityText, { ignoreViewer: true, }); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } type CreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, +getENSNames: ?GetENSNames, }; async function createPendingSidebar( input: CreatePendingSidebarInput, -): Promise { +): Promise { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, getENSNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, 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, loggedInUserInfo, }); } 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 | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +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 = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, +filterVoicedInAnnouncementChannelsPermission?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?LegacyRawThreadInfo { const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterManageInviteLinksPermission = options?.filterManageInviteLinksPermission; const filterVoicedInAnnouncementChannelsPermission = options?.filterVoicedInAnnouncementChannelsPermission; const filterThreadPermissions = _omitBy( (v, k) => (filterThreadEditAvatarPermission && [ threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, ].includes(k)) || (excludePinInfo && [ threadPermissions.MANAGE_PINS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_PINS, ].includes(k)) || (filterManageInviteLinksPermission && [threadPermissions.MANAGE_INVITE_LINKS].includes(k)) || (filterVoicedInAnnouncementChannelsPermission && [ threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, ].includes(k)), ); const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( serverThreadInfo.id === genesis.id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadPermissions(serverMember.permissions); 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 = filterThreadPermissions( getAllThreadPermissions(null, serverThreadInfo.id), ); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rolesWithFilteredThreadPermissions = _mapValues(role => ({ ...role, permissions: filterThreadPermissions(role.permissions), }))(serverThreadInfo.roles); let rawThreadInfo: any = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: rolesWithFilteredThreadPermissions, currentUser, repliesCount: serverThreadInfo.repliesCount, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (serverThreadInfo.avatar) { rawThreadInfo = { ...rawThreadInfo, avatar: serverThreadInfo.avatar }; } if (!excludePinInfo) { return { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } return rawThreadInfo; } function threadUIName( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } let threadMembers: $ReadOnlyArray; let memberEntities: $ReadOnlyArray; // Branching below is to appease flow if (threadInfo.minimallyEncoded) { threadMembers = threadInfo.members.filter(memberInfo => memberInfo.role); memberEntities = threadMembers.map(member => ET.user({ userInfo: member })); } else { threadMembers = threadInfo.members.filter(memberInfo => memberInfo.role); memberEntities = threadMembers.map(member => ET.user({ userInfo: member })); } return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: memberEntities, ifJustViewer: threadInfo.type === threadTypes.PRIVATE ? 'viewer_username' : 'just_you_string', }; } function threadInfoFromRawThreadInfo( rawThreadInfo: LegacyRawThreadInfo, viewerID: ?string, userInfos: UserInfos, -): ThreadInfo { - let threadInfo: ThreadInfo = { +): LegacyThreadInfo { + let threadInfo: LegacyThreadInfo = { 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, avatar, pinnedCount } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } if (avatar) { threadInfo = { ...threadInfo, avatar }; } else if ( rawThreadInfo.type === threadTypes.PERSONAL || rawThreadInfo.type === threadTypes.PRIVATE ) { threadInfo = { ...threadInfo, avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } return threadInfo; } function getCurrentUser( - threadInfo: LegacyRawThreadInfo | ThreadInfo, + threadInfo: LegacyRawThreadInfo | LegacyThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function getMinimallyEncodedCurrentUser( threadInfo: MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, viewerID: ?string, userInfos: UserInfos, ): MinimallyEncodedThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } const decodedPermissions = threadPermissionsFromBitmaskHex( threadInfo.currentUser.permissions, ); const updatedPermissions = { ...decodedPermissions, ...disabledPermissions, }; const encodedUpdatedPermissions = permissionsToBitmaskHex(updatedPermissions); return { ...threadInfo.currentUser, permissions: encodedUpdatedPermissions, }; } function threadIsWithBlockedUserOnly( - threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, 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 | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( - threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } 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 memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, - threadInfo: ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, ): 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 { if (memberInfo.minimallyEncoded) { return hasPermission(memberInfo.permissions, threadPermissions.CHANGE_ROLE); } return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo): boolean { return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?( | RawThreadInfo - | ThreadInfo + | LegacyThreadInfo | ServerThreadInfo | MinimallyEncodedThreadInfo ), ): boolean { if (!threadInfo) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( - threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, ) { 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 }, }), {}, ); // 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, parentThreadID: ?string): string { if (threadType === threadTypes.SIDEBAR) { return 'thread'; } else if ( threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD && parentThreadID === genesis.id ) { return 'chat'; } 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 | ?MinimallyEncodedThreadInfo) { +function useWatchThread( + threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, +) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); 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 | ?MinimallyEncodedThreadInfo; +) => ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo; // TODO (atul): Parameterize function once `createPendingThread` is updated. function useExistingThreadInfoFinder( - baseThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + baseThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( ( params: ExistingThreadInfoFinderParams, - ): ?ThreadInfo | ?MinimallyEncodedThreadInfo => { + ): ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!loggedInUserInfo || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } const viewerID = loggedInUserInfo?.id; 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: [loggedInUserInfo, ...userInfoInputArray], }) : baseThreadInfo; if (updatedThread.minimallyEncoded) { return { ...updatedThread, currentUser: getMinimallyEncodedCurrentUser( updatedThread, viewerID, userInfos, ), }; } else { return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; } }, [ baseThreadInfo, threadInfos, loggedInUserInfo, 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 + | LegacyThreadInfo | MinimallyEncodedThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } if (member.minimallyEncoded) { return hasPermission(member.permissions, permission); } return permissionLookup(member.permissions, permission); } return false; } function useCanCreateSidebarFromMessage( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); if ( !messageInfo.id || threadInfo.sourceMessageID === messageInfo.id || isInvalidSidebarSource(messageInfo) ) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; const creatorRelationshipHasBlock = messageCreatorRelationship && relationshipBlockedInEitherDirection(messageCreatorRelationship); const hasCreateSidebarsPermission = threadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); return hasCreateSidebarsPermission && !creatorRelationshipHasBlock; } function useSidebarExistsOrCanBeCreated( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } function checkIfDefaultMembersAreVoiced( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): 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]; const defaultRolePermissions = defaultRole.minimallyEncoded ? decodeMinimallyEncodedRoleInfo(defaultRole).permissions : defaultRole.permissions; return !!defaultRolePermissions[threadPermissions.VOICED]; } const draftKeySuffix = '/message_composer'; function draftKeyFromThreadID(threadID: string): string { return `${threadID}${draftKeySuffix}`; } function getContainingThreadID( parentThreadInfo: | ?ServerThreadInfo | RawThreadInfo - | ThreadInfo + | LegacyThreadInfo | MinimallyEncodedThreadInfo, 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 + | LegacyThreadInfo | MinimallyEncodedThreadInfo, ): ?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, + threadFilter: LegacyThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, loggedInUserInfo: ?LoggedInUserInfo, ): $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: ChatThreadItem[] = [ ...privateThreads, ...personalThreads, ...otherThreads, ]; if (loggedInUserInfo) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(loggedInUserInfo, user), ), ); } return chatItems; } type ThreadListSearchResult = { +threadSearchResults: $ReadOnlySet, +usersSearchResults: $ReadOnlyArray, }; function useThreadListSearch( searchText: string, viewerID: ?string, ): ThreadListSearchResult { const callSearchUsers = useServerCall(searchUserCall); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); const forwardLookupSearchText = useForwardLookupSearchText(searchText); const searchUsers = React.useCallback( async (usernamePrefix: string) => { if (usernamePrefix.length === 0) { return ([]: GlobalAccountUserInfo[]); } 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< $ReadOnlyArray, >([]); const threadSearchIndex = useGlobalThreadSearchIndex(); React.useEffect(() => { (async () => { const results = threadSearchIndex.getSearchResults(searchText); setThreadSearchResults(new Set(results)); const usersResults = await searchUsers(forwardLookupSearchText); setUsersSearchResults(usersResults); })(); }, [searchText, forwardLookupSearchText, threadSearchIndex, searchUsers]); return { threadSearchResults, usersSearchResults }; } function removeMemberFromThread( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, memberInfo: RelativeMemberInfo, dispatchActionPromise: DispatchActionPromise, removeUserFromThreadServerCall: ( input: RemoveUsersFromThreadInput, ) => Promise, ) { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( removeUsersFromThreadActionTypes, removeUserFromThreadServerCall({ threadID: threadInfo.id, memberIDs: [memberInfo.id], }), { customKeyName }, ); } function switchMemberAdminRoleInThread( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, 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 | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { 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 (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) { result.push('change_role'); } if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || threadInfo.roles[role]?.isDefault) ) { result.push('remove_user'); } return result; } function patchThreadInfoToIncludeMentionedMembersOfParent( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, messageText: string, viewerID: string, -): ThreadInfo | MinimallyEncodedThreadInfo { +): LegacyThreadInfo | MinimallyEncodedThreadInfo { const members: UserIDAndUsername[] = threadInfo.members .map(({ id, username }) => username ? ({ id, username }: UserIDAndUsername) : null, ) .filter(Boolean); const mentionedNewMembers = extractNewMentionedParentMembers( messageText, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return threadInfo; } members.push(...mentionedNewMembers); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members, parentThreadInfo, threadColor: threadInfo.color, name: threadInfo.name, sourceMessageID: threadInfo.sourceMessageID, }); } function threadInfoInsideCommunity( - threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, communityID: string, ): boolean { return threadInfo.community === communityID || threadInfo.id === communityID; } type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): RoleAndMemberCount { return React.useMemo(() => { const roleIDsToNames: { [string]: string } = {}; Object.keys(threadInfo.roles).forEach(roleID => { roleIDsToNames[roleID] = threadInfo.roles[roleID].name; }); const roleNamesToMemberCount: RoleAndMemberCount = {}; threadInfo.members.forEach(({ role: roleID }) => { invariant(roleID, 'Community member should have a role'); const roleName = roleIDsToNames[roleID]; roleNamesToMemberCount[roleName] = (roleNamesToMemberCount[roleName] ?? 0) + 1; }); // For all community roles with no members, add them to the list with 0 Object.keys(roleIDsToNames).forEach(roleName => { if (roleNamesToMemberCount[roleIDsToNames[roleName]] === undefined) { roleNamesToMemberCount[roleIDsToNames[roleName]] = 0; } }); return roleNamesToMemberCount; }, [threadInfo]); } type RoleUserSurfacedPermissions = { +[roleName: string]: $ReadOnlySet, }; // Iterates through the existing roles in the community and 'reverse maps' // the set of permission literals for each role to user-facing permission enums // to help pre-populate the permission checkboxes when editing roles. function useRoleUserSurfacedPermissions( - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): RoleUserSurfacedPermissions { return React.useMemo(() => { const roleNamesToPermissions: { [string]: Set } = {}; Object.keys(threadInfo.roles).forEach(roleID => { const roleName = threadInfo.roles[roleID].name; const rolePermissions = Object.keys(threadInfo.roles[roleID].permissions); const setOfUserSurfacedPermissions = new Set(); rolePermissions.forEach(rolePermission => { const userSurfacedPermission = Object.keys( configurableCommunityPermissions, ).find(key => configurableCommunityPermissions[key].has(rolePermission), ); if (userSurfacedPermission) { setOfUserSurfacedPermissions.add(userSurfacedPermission); } }); roleNamesToPermissions[roleName] = setOfUserSurfacedPermissions; }); return roleNamesToPermissions; }, [threadInfo]); } function communityOrThreadNoun( - threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, ): string { return threadTypeIsCommunityRoot(threadInfo.type) ? 'community' : threadNoun(threadInfo.type, threadInfo.parentThreadID); } function getThreadsToDeleteText( - threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, ): string { return `${ threadTypeIsCommunityRoot(threadInfo.type) ? 'Subchannels and threads' : 'Threads' } within this ${communityOrThreadNoun(threadInfo)}`; } function useUserProfileThreadInfo(userInfo: ?UserInfo): ?UserProfileThreadInfo { const userID = userInfo?.id; const username = userInfo?.username; const loggedInUserInfo = useLoggedInUserInfo(); const isViewerProfile = loggedInUserInfo?.id === userID; const privateThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PERSONAL, ); const personalThreadInfos = useSelector(personalThreadInfosSelector); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return React.useMemo(() => { if (!loggedInUserInfo || !userID || !username) { return null; } if (isViewerProfile) { - const privateThreadInfo: ?ThreadInfo = privateThreadInfos[0]; + const privateThreadInfo: ?LegacyThreadInfo = privateThreadInfos[0]; return privateThreadInfo ? { threadInfo: privateThreadInfo } : null; } if (usersWithPersonalThread.has(userID)) { - const personalThreadInfo: ?ThreadInfo = personalThreadInfos.find( + const personalThreadInfo: ?LegacyThreadInfo = personalThreadInfos.find( threadInfo => userID === getSingleOtherUser(threadInfo, loggedInUserInfo.id), ); return personalThreadInfo ? { threadInfo: personalThreadInfo } : null; } const pendingPersonalThreadInfo = createPendingPersonalThread( loggedInUserInfo, userID, username, ); return pendingPersonalThreadInfo; }, [ isViewerProfile, loggedInUserInfo, personalThreadInfos, privateThreadInfos, userID, username, usersWithPersonalThread, ]); } export { threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, createUnresolvedPendingSidebar, extractNewMentionedParentMembers, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getCurrentUser, getMinimallyEncodedCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useThreadListSearch, removeMemberFromThread, switchMemberAdminRoleInThread, getAvailableThreadMemberActions, threadMembersWithoutAddedAshoat, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useUserProfileThreadInfo, }; diff --git a/lib/shared/user-utils.js b/lib/shared/user-utils.js index a68026d5b..56eb3c07a 100644 --- a/lib/shared/user-utils.js +++ b/lib/shared/user-utils.js @@ -1,53 +1,53 @@ // @flow import { memberHasAdminPowers } from './thread-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { RawThreadInfo, ServerThreadInfo, - ThreadInfo, + LegacyThreadInfo, } from '../types/thread-types.js'; import type { UserInfo } from '../types/user-types.js'; import { useSelector } from '../utils/redux-utils.js'; function stringForUser( user: ?{ +username?: ?string, +isViewer?: ?boolean, ... }, ): string { if (user?.isViewer) { return 'you'; } return stringForUserExplicit(user); } function stringForUserExplicit(user: ?{ +username: ?string, ... }): string { if (user?.username) { return user.username; } return 'anonymous'; } function useKeyserverAdmin( community: - | ThreadInfo + | LegacyThreadInfo | RawThreadInfo | ServerThreadInfo | MinimallyEncodedThreadInfo, ): ?UserInfo { const userInfos = useSelector(state => state.userStore.userInfos); // This hack only works as long as there is only one admin // Linear task to revert this: // https://linear.app/comm/issue/ENG-1707/revert-fix-getting-the-keyserver-admin-info const admin = community.members.find(memberHasAdminPowers); const adminUserInfo = admin ? userInfos[admin.id] : undefined; const [adminUserInfoWithENSName] = useENSNames([adminUserInfo]); return adminUserInfoWithENSName; } export { stringForUser, stringForUserExplicit, useKeyserverAdmin }; diff --git a/lib/types/messages/create-sidebar.js b/lib/types/messages/create-sidebar.js index 5d474ebde..d073ca161 100644 --- a/lib/types/messages/create-sidebar.js +++ b/lib/types/messages/create-sidebar.js @@ -1,58 +1,58 @@ // @flow import t, { type TInterface } from 'tcomb'; import { tID, tNumber, tShape } from '../../utils/validation-utils.js'; import { messageTypes } from '../message-types-enum.js'; -import type { ThreadInfo } from '../thread-types.js'; +import type { LegacyThreadInfo } from '../thread-types.js'; import type { RelativeUserInfo } from '../user-types.js'; export type CreateSidebarMessageData = { +type: 18, +threadID: string, +creatorID: string, +time: number, +sourceMessageAuthorID: string, +initialThreadState: { +name: ?string, +parentThreadID: string, +color: string, +memberIDs: string[], }, }; export type RawCreateSidebarMessageInfo = { ...CreateSidebarMessageData, id: string, }; export const rawCreateSidebarMessageInfoValidator: TInterface = tShape({ type: tNumber(messageTypes.CREATE_SIDEBAR), threadID: tID, creatorID: t.String, time: t.Number, sourceMessageAuthorID: t.String, initialThreadState: tShape({ name: t.maybe(t.String), parentThreadID: tID, color: t.String, memberIDs: t.list(t.String), }), id: tID, }); export type CreateSidebarMessageInfo = { +type: 18, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, +sourceMessageAuthor: RelativeUserInfo, +initialThreadState: { +name: ?string, - +parentThreadInfo: ThreadInfo, + +parentThreadInfo: LegacyThreadInfo, +color: string, +otherMembers: RelativeUserInfo[], }, }; diff --git a/lib/types/messages/create-subthread.js b/lib/types/messages/create-subthread.js index b35a5f685..8feae98f3 100644 --- a/lib/types/messages/create-subthread.js +++ b/lib/types/messages/create-subthread.js @@ -1,40 +1,40 @@ // @flow import t, { type TInterface } from 'tcomb'; import { tID, tNumber, tShape } from '../../utils/validation-utils.js'; import { messageTypes } from '../message-types-enum.js'; -import type { ThreadInfo } from '../thread-types.js'; +import type { LegacyThreadInfo } from '../thread-types.js'; import type { RelativeUserInfo } from '../user-types.js'; export type CreateSubthreadMessageData = { type: 3, threadID: string, creatorID: string, time: number, childThreadID: string, }; export type RawCreateSubthreadMessageInfo = { ...CreateSubthreadMessageData, id: string, }; export const rawCreateSubthreadMessageInfoValidator: TInterface = tShape({ type: tNumber(messageTypes.CREATE_SUB_THREAD), threadID: tID, creatorID: t.String, time: t.Number, childThreadID: tID, id: tID, }); export type CreateSubthreadMessageInfo = { type: 3, id: string, threadID: string, creator: RelativeUserInfo, time: number, - childThreadInfo: ThreadInfo, + childThreadInfo: LegacyThreadInfo, }; diff --git a/lib/types/messages/create-thread.js b/lib/types/messages/create-thread.js index 82cc0c270..0f5682fc3 100644 --- a/lib/types/messages/create-thread.js +++ b/lib/types/messages/create-thread.js @@ -1,65 +1,65 @@ // @flow import t, { type TInterface } from 'tcomb'; import { values } from '../../utils/objects.js'; import { tID, tNumber, tShape, tNumEnum, } from '../../utils/validation-utils.js'; import { messageTypes } from '../message-types-enum.js'; import { threadTypes, type ThreadType } from '../thread-types-enum.js'; -import { type ThreadInfo } from '../thread-types.js'; +import { type LegacyThreadInfo } from '../thread-types.js'; import type { RelativeUserInfo } from '../user-types.js'; export type CreateThreadMessageData = { type: 1, threadID: string, creatorID: string, time: number, initialThreadState: { type: ThreadType, name: ?string, parentThreadID: ?string, color: string, memberIDs: string[], }, }; export type RawCreateThreadMessageInfo = { ...CreateThreadMessageData, id: string, }; export const rawCreateThreadMessageInfoValidator: TInterface = tShape({ type: tNumber(messageTypes.CREATE_THREAD), threadID: tID, creatorID: t.String, time: t.Number, initialThreadState: tShape({ type: tNumEnum(values(threadTypes)), name: t.maybe(t.String), parentThreadID: t.maybe(tID), color: t.String, memberIDs: t.list(t.String), }), id: tID, }); export type CreateThreadMessageInfo = { type: 1, id: string, threadID: string, creator: RelativeUserInfo, time: number, initialThreadState: { type: ThreadType, name: ?string, - parentThreadInfo: ?ThreadInfo, + parentThreadInfo: ?LegacyThreadInfo, color: string, otherMembers: RelativeUserInfo[], }, }; diff --git a/lib/types/minimally-encoded-thread-permissions-types.js b/lib/types/minimally-encoded-thread-permissions-types.js index aaccbc72c..a302837b5 100644 --- a/lib/types/minimally-encoded-thread-permissions-types.js +++ b/lib/types/minimally-encoded-thread-permissions-types.js @@ -1,210 +1,210 @@ // @flow import _mapValues from 'lodash/fp/mapValues.js'; import type { LegacyMemberInfo, LegacyRawThreadInfo, LegacyRelativeMemberInfo, LegacyRoleInfo, ThreadCurrentUserInfo, - ThreadInfo, + LegacyThreadInfo, } from './thread-types.js'; import { decodeThreadRolePermissionsBitmaskArray, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, threadRolePermissionsBlobToBitmaskArray, } from '../permissions/minimally-encoded-thread-permissions.js'; export type MinimallyEncodedRoleInfo = $ReadOnly<{ ...LegacyRoleInfo, +minimallyEncoded: true, +permissions: $ReadOnlyArray, }>; const minimallyEncodeRoleInfo = ( roleInfo: LegacyRoleInfo, ): MinimallyEncodedRoleInfo => ({ ...roleInfo, minimallyEncoded: true, permissions: threadRolePermissionsBlobToBitmaskArray(roleInfo.permissions), }); const decodeMinimallyEncodedRoleInfo = ( minimallyEncodedRoleInfo: MinimallyEncodedRoleInfo, ): LegacyRoleInfo => { const { minimallyEncoded, ...rest } = minimallyEncodedRoleInfo; return { ...rest, permissions: decodeThreadRolePermissionsBitmaskArray( minimallyEncodedRoleInfo.permissions, ), }; }; export type MinimallyEncodedThreadCurrentUserInfo = $ReadOnly<{ ...ThreadCurrentUserInfo, +minimallyEncoded: true, +permissions: string, }>; const minimallyEncodeThreadCurrentUserInfo = ( threadCurrentUserInfo: ThreadCurrentUserInfo, ): MinimallyEncodedThreadCurrentUserInfo => ({ ...threadCurrentUserInfo, minimallyEncoded: true, permissions: permissionsToBitmaskHex(threadCurrentUserInfo.permissions), }); const decodeMinimallyEncodedThreadCurrentUserInfo = ( minimallyEncodedThreadCurrentUserInfo: MinimallyEncodedThreadCurrentUserInfo, ): ThreadCurrentUserInfo => { const { minimallyEncoded, ...rest } = minimallyEncodedThreadCurrentUserInfo; return { ...rest, permissions: threadPermissionsFromBitmaskHex( minimallyEncodedThreadCurrentUserInfo.permissions, ), }; }; export type MinimallyEncodedMemberInfo = $ReadOnly<{ ...LegacyMemberInfo, +minimallyEncoded: true, +permissions: string, }>; const minimallyEncodeMemberInfo = ( memberInfo: LegacyMemberInfo, ): MinimallyEncodedMemberInfo => ({ ...memberInfo, minimallyEncoded: true, permissions: permissionsToBitmaskHex(memberInfo.permissions), }); const decodeMinimallyEncodedMemberInfo = ( minimallyEncodedMemberInfo: MinimallyEncodedMemberInfo, ): LegacyMemberInfo => { const { minimallyEncoded, ...rest } = minimallyEncodedMemberInfo; return { ...rest, permissions: threadPermissionsFromBitmaskHex( minimallyEncodedMemberInfo.permissions, ), }; }; export type MinimallyEncodedRelativeMemberInfo = $ReadOnly<{ ...MinimallyEncodedMemberInfo, +username: ?string, +isViewer: boolean, }>; const minimallyEncodeRelativeMemberInfo = ( relativeMemberInfo: LegacyRelativeMemberInfo, ): MinimallyEncodedRelativeMemberInfo => ({ ...relativeMemberInfo, minimallyEncoded: true, permissions: permissionsToBitmaskHex(relativeMemberInfo.permissions), }); const decodeMinimallyEncodedRelativeMemberInfo = ( minimallyEncodedRelativeMemberInfo: MinimallyEncodedRelativeMemberInfo, ): LegacyRelativeMemberInfo => { const { minimallyEncoded, ...rest } = minimallyEncodedRelativeMemberInfo; return { ...rest, permissions: threadPermissionsFromBitmaskHex( minimallyEncodedRelativeMemberInfo.permissions, ), }; }; export type MinimallyEncodedRawThreadInfo = $ReadOnly<{ ...LegacyRawThreadInfo, +minimallyEncoded: true, +members: $ReadOnlyArray, +roles: { +[id: string]: MinimallyEncodedRoleInfo }, +currentUser: MinimallyEncodedThreadCurrentUserInfo, }>; const minimallyEncodeRawThreadInfo = ( rawThreadInfo: LegacyRawThreadInfo, ): MinimallyEncodedRawThreadInfo => { const { members, roles, currentUser, ...rest } = rawThreadInfo; return { ...rest, minimallyEncoded: true, members: members.map(minimallyEncodeMemberInfo), roles: _mapValues(minimallyEncodeRoleInfo)(roles), currentUser: minimallyEncodeThreadCurrentUserInfo(currentUser), }; }; const decodeMinimallyEncodedRawThreadInfo = ( minimallyEncodedRawThreadInfo: MinimallyEncodedRawThreadInfo, ): LegacyRawThreadInfo => { const { minimallyEncoded, members, roles, currentUser, ...rest } = minimallyEncodedRawThreadInfo; return { ...rest, members: members.map(decodeMinimallyEncodedMemberInfo), roles: _mapValues(decodeMinimallyEncodedRoleInfo)(roles), currentUser: decodeMinimallyEncodedThreadCurrentUserInfo(currentUser), }; }; export type MinimallyEncodedThreadInfo = $ReadOnly<{ - ...ThreadInfo, + ...LegacyThreadInfo, +minimallyEncoded: true, +members: $ReadOnlyArray, +roles: { +[id: string]: MinimallyEncodedRoleInfo }, +currentUser: MinimallyEncodedThreadCurrentUserInfo, }>; const minimallyEncodeThreadInfo = ( - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, ): MinimallyEncodedThreadInfo => { const { members, roles, currentUser, ...rest } = threadInfo; return { ...rest, minimallyEncoded: true, members: members.map(minimallyEncodeRelativeMemberInfo), roles: _mapValues(minimallyEncodeRoleInfo)(roles), currentUser: minimallyEncodeThreadCurrentUserInfo(currentUser), }; }; const decodeMinimallyEncodedThreadInfo = ( minimallyEncodedThreadInfo: MinimallyEncodedThreadInfo, -): ThreadInfo => { +): LegacyThreadInfo => { const { minimallyEncoded, members, roles, currentUser, ...rest } = minimallyEncodedThreadInfo; return { ...rest, members: members.map(decodeMinimallyEncodedRelativeMemberInfo), roles: _mapValues(decodeMinimallyEncodedRoleInfo)(roles), currentUser: decodeMinimallyEncodedThreadCurrentUserInfo(currentUser), }; }; export type MinimallyEncodedResolvedThreadInfo = $ReadOnly<{ ...MinimallyEncodedThreadInfo, +uiName: string, }>; export { minimallyEncodeRoleInfo, decodeMinimallyEncodedRoleInfo, minimallyEncodeThreadCurrentUserInfo, decodeMinimallyEncodedThreadCurrentUserInfo, minimallyEncodeMemberInfo, decodeMinimallyEncodedMemberInfo, minimallyEncodeRawThreadInfo, decodeMinimallyEncodedRawThreadInfo, minimallyEncodeRelativeMemberInfo, decodeMinimallyEncodedRelativeMemberInfo, minimallyEncodeThreadInfo, decodeMinimallyEncodedThreadInfo, }; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index f63055039..4a230f3fd 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,492 +1,493 @@ // @flow import t, { type TInterface } from 'tcomb'; import { type AvatarDBContent, type ClientAvatar, clientAvatarValidator, type UpdateUserAvatarRequest, } from './avatar-types.js'; import type { CalendarQuery } from './entry-types.js'; import type { Media } from './media-types.js'; import type { MessageTruncationStatuses, RawMessageInfo, } from './message-types.js'; import type { MinimallyEncodedMemberInfo, MinimallyEncodedRawThreadInfo, MinimallyEncodedRelativeMemberInfo, MinimallyEncodedResolvedThreadInfo, MinimallyEncodedRoleInfo, } from './minimally-encoded-thread-permissions-types.js'; import { type ThreadSubscription, threadSubscriptionValidator, } from './subscription-types.js'; import { type ThreadPermissionsInfo, threadPermissionsInfoValidator, type ThreadRolePermissionsBlob, threadRolePermissionsBlobValidator, type UserSurfacedPermission, } from './thread-permission-types.js'; import { type ThreadType, threadTypeValidator } from './thread-types-enum.js'; import type { ClientUpdateInfo, ServerUpdateInfo } from './update-types.js'; import type { UserInfo, UserInfos } from './user-types.js'; import { type ThreadEntity, threadEntityValidator, } from '../utils/entity-text.js'; import { tID, tShape } from '../utils/validation-utils.js'; export type LegacyMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +isSender: boolean, }; export const legacyMemberInfoValidator: TInterface = tShape({ id: t.String, role: t.maybe(tID), permissions: threadPermissionsInfoValidator, isSender: t.Boolean, }); export type MemberInfo = LegacyMemberInfo | MinimallyEncodedMemberInfo; export type LegacyRelativeMemberInfo = $ReadOnly<{ ...LegacyMemberInfo, +username: ?string, +isViewer: boolean, }>; const legacyRelativeMemberInfoValidator = tShape({ ...legacyMemberInfoValidator.meta.props, username: t.maybe(t.String), isViewer: t.Boolean, }); export type RelativeMemberInfo = | LegacyRelativeMemberInfo | MinimallyEncodedRelativeMemberInfo; export type LegacyRoleInfo = { +id: string, +name: string, +permissions: ThreadRolePermissionsBlob, +isDefault: boolean, }; export const legacyRoleInfoValidator: TInterface = tShape({ id: tID, name: t.String, permissions: threadRolePermissionsBlobValidator, isDefault: t.Boolean, }); export type RoleInfo = LegacyRoleInfo | MinimallyEncodedRoleInfo; export type ThreadCurrentUserInfo = { +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, }; export const threadCurrentUserInfoValidator: TInterface = tShape({ role: t.maybe(tID), permissions: threadPermissionsInfoValidator, subscription: threadSubscriptionValidator, unread: t.maybe(t.Boolean), }); export type LegacyRawThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: LegacyRoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type LegacyRawThreadInfos = { +[id: string]: LegacyRawThreadInfo, }; export const legacyRawThreadInfoValidator: TInterface = tShape({ id: tID, type: threadTypeValidator, name: t.maybe(t.String), avatar: t.maybe(clientAvatarValidator), description: t.maybe(t.String), color: t.String, creationTime: t.Number, parentThreadID: t.maybe(tID), containingThreadID: t.maybe(tID), community: t.maybe(tID), members: t.list(legacyMemberInfoValidator), roles: t.dict(tID, legacyRoleInfoValidator), currentUser: threadCurrentUserInfoValidator, sourceMessageID: t.maybe(tID), repliesCount: t.Number, pinnedCount: t.maybe(t.Number), }); export type RawThreadInfo = LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo; -export type ThreadInfo = { +export type LegacyThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +uiName: string | ThreadEntity, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: LegacyRoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; -export const threadInfoValidator: TInterface = tShape({ - id: tID, - type: threadTypeValidator, - name: t.maybe(t.String), - uiName: t.union([t.String, threadEntityValidator]), - avatar: t.maybe(clientAvatarValidator), - description: t.maybe(t.String), - color: t.String, - creationTime: t.Number, - parentThreadID: t.maybe(tID), - containingThreadID: t.maybe(tID), - community: t.maybe(tID), - members: t.list(legacyRelativeMemberInfoValidator), - roles: t.dict(tID, legacyRoleInfoValidator), - currentUser: threadCurrentUserInfoValidator, - sourceMessageID: t.maybe(tID), - repliesCount: t.Number, - pinnedCount: t.maybe(t.Number), -}); +export const legacyThreadInfoValidator: TInterface = + tShape({ + id: tID, + type: threadTypeValidator, + name: t.maybe(t.String), + uiName: t.union([t.String, threadEntityValidator]), + avatar: t.maybe(clientAvatarValidator), + description: t.maybe(t.String), + color: t.String, + creationTime: t.Number, + parentThreadID: t.maybe(tID), + containingThreadID: t.maybe(tID), + community: t.maybe(tID), + members: t.list(legacyRelativeMemberInfoValidator), + roles: t.dict(tID, legacyRoleInfoValidator), + currentUser: threadCurrentUserInfoValidator, + sourceMessageID: t.maybe(tID), + repliesCount: t.Number, + pinnedCount: t.maybe(t.Number), + }); export type ResolvedThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +uiName: string, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: LegacyRoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ServerMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, +isSender: boolean, }; export type ServerThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +avatar?: AvatarDBContent, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +depth: number, +members: $ReadOnlyArray, +roles: { +[id: string]: LegacyRoleInfo }, +sourceMessageID?: string, +repliesCount: number, +pinnedCount: number, }; export type ThreadStore = { +threadInfos: LegacyRawThreadInfos, }; export const threadStoreValidator: TInterface = tShape({ threadInfos: t.dict(tID, legacyRawThreadInfoValidator), }); export type ClientDBThreadInfo = { +id: string, +type: number, +name: ?string, +avatar?: ?string, +description: ?string, +color: string, +creationTime: string, +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: string, +roles: string, +currentUser: string, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ThreadDeletionRequest = { +threadID: string, +accountPassword?: empty, }; export type RemoveMembersRequest = { +threadID: string, +memberIDs: $ReadOnlyArray, }; export type RoleChangeRequest = { +threadID: string, +memberIDs: $ReadOnlyArray, +role: string, }; export type ChangeThreadSettingsResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, }; export type ChangeThreadSettingsPayload = { +threadID: string, +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, }; export type LeaveThreadRequest = { +threadID: string, }; export type LeaveThreadResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type LeaveThreadPayload = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type ThreadChanges = Partial<{ +type: ThreadType, +name: string, +description: string, +color: string, +parentThreadID: ?string, +newMemberIDs: $ReadOnlyArray, +avatar: UpdateUserAvatarRequest, }>; export type UpdateThreadRequest = { +threadID: string, +changes: ThreadChanges, +accountPassword?: empty, }; export type BaseNewThreadRequest = { +id?: ?string, +name?: ?string, +description?: ?string, +color?: ?string, +parentThreadID?: ?string, +initialMemberIDs?: ?$ReadOnlyArray, +ghostMemberIDs?: ?$ReadOnlyArray, }; type NewThreadRequest = | { +type: 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12, ...BaseNewThreadRequest, } | { +type: 5, +sourceMessageID: string, ...BaseNewThreadRequest, }; export type ClientNewThreadRequest = { ...NewThreadRequest, +calendarQuery: CalendarQuery, }; export type ServerNewThreadRequest = { ...NewThreadRequest, +calendarQuery?: ?CalendarQuery, }; export type NewThreadResponse = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +userInfos: UserInfos, +newThreadID: string, }; export type NewThreadResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +userInfos: UserInfos, +newThreadID: string, }; export type ServerThreadJoinRequest = { +threadID: string, +calendarQuery?: ?CalendarQuery, +inviteLinkSecret?: string, }; export type ClientThreadJoinRequest = { +threadID: string, +calendarQuery: CalendarQuery, +inviteLinkSecret?: string, }; export type ThreadJoinResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: UserInfos, }; export type ThreadJoinPayload = { +updatesResult: { newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: $ReadOnlyArray, }; export type ThreadFetchMediaResult = { +media: $ReadOnlyArray, }; export type ThreadFetchMediaRequest = { +threadID: string, +limit: number, +offset: number, }; export type SidebarInfo = { - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, +lastUpdatedTime: number, +mostRecentNonLocalMessage: ?string, }; export type ToggleMessagePinRequest = { +messageID: string, +action: 'pin' | 'unpin', }; export type ToggleMessagePinResult = { +newMessageInfos: $ReadOnlyArray, +threadID: string, }; type CreateRoleAction = { +community: string, +name: string, +permissions: $ReadOnlyArray, +action: 'create_role', }; type EditRoleAction = { +community: string, +existingRoleID: string, +name: string, +permissions: $ReadOnlyArray, +action: 'edit_role', }; export type RoleModificationRequest = CreateRoleAction | EditRoleAction; export type RoleModificationResult = { +threadInfo: LegacyRawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleModificationPayload = { +threadInfo: LegacyRawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionRequest = { +community: string, +roleID: string, }; export type RoleDeletionResult = { +threadInfo: LegacyRawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionPayload = { +threadInfo: LegacyRawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3; // We can show a max of 5 sidebars inline underneath their parent // in the chat tab if every one of the displayed sidebars is unread export const maxUnreadSidebars = 5; export type ThreadStoreThreadInfos = LegacyRawThreadInfos; export type ChatMentionCandidates = { +[id: string]: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, }; export type ChatMentionCandidatesObj = { +[id: string]: ChatMentionCandidates, }; export type UserProfileThreadInfo = { - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, }; diff --git a/lib/utils/drawer-utils.react.js b/lib/utils/drawer-utils.react.js index 71ea12551..ab053c4f0 100644 --- a/lib/utils/drawer-utils.react.js +++ b/lib/utils/drawer-utils.react.js @@ -1,127 +1,132 @@ // @flow import { values } from './objects.js'; import { threadInFilterList, threadIsChannel } from '../shared/thread-utils.js'; import type { MinimallyEncodedResolvedThreadInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { communitySubthreads } from '../types/thread-types-enum.js'; import type { - ThreadInfo, + LegacyThreadInfo, ResolvedThreadInfo, RawThreadInfo, } from '../types/thread-types.js'; type WritableCommunityDrawerItemData = { - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, itemChildren: $ReadOnlyArray>, hasSubchannelsButton: boolean, labelStyle: T, }; export type CommunityDrawerItemData = $ReadOnly< WritableCommunityDrawerItemData, >; function createRecursiveDrawerItemsData( childThreadInfosMap: { - +[id: string]: $ReadOnlyArray, + +[id: string]: $ReadOnlyArray< + LegacyThreadInfo | MinimallyEncodedThreadInfo, + >, }, communities: $ReadOnlyArray< ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, >, labelStyles: $ReadOnlyArray, maxDepth: number, ): $ReadOnlyArray> { const result: $ReadOnlyArray< WritableCommunityDrawerItemData, > = communities.map(community => ({ threadInfo: community, itemChildren: [], labelStyle: labelStyles[0], hasSubchannelsButton: false, })); let queue = result.map(item => [item, 0]); for (let i = 0; i < queue.length; i++) { const [item, lvl] = queue[i]; const itemChildThreadInfos = childThreadInfosMap[item.threadInfo.id] ?? []; if (lvl < maxDepth) { item.itemChildren = itemChildThreadInfos .filter(childItem => communitySubthreads.includes(childItem.type)) .map(childItem => ({ threadInfo: childItem, itemChildren: [], labelStyle: labelStyles[Math.min(lvl + 1, labelStyles.length - 1)], hasSubchannelsButton: lvl + 1 === maxDepth && threadHasSubchannels(childItem, childThreadInfosMap), })); queue = queue.concat( item.itemChildren.map(childItem => [childItem, lvl + 1]), ); } } return result; } -function threadHasSubchannels( +function threadHasSubchannels( threadInfo: T, childThreadInfosMap: { +[id: string]: $ReadOnlyArray, }, ): boolean { if (!childThreadInfosMap[threadInfo.id]?.length) { return false; } return childThreadInfosMap[threadInfo.id].some(thread => threadIsChannel(thread), ); } function appendSuffix< T: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, >(chats: $ReadOnlyArray): T[] { const result: T[] = []; const names = new Map(); for (const chat of chats) { let name = chat.uiName; const numberOfOccurrences = names.get(name); names.set(name, (numberOfOccurrences ?? 0) + 1); if (numberOfOccurrences) { name = `${name} (${numberOfOccurrences.toString()})`; } // Branching to appease `flow`. if (chat.minimallyEncoded) { result.push({ ...chat, uiName: name }); } else { result.push({ ...chat, uiName: name }); } } return result; } function filterThreadIDsBelongingToCommunity( communityID: string, threadInfosObj: { - +[id: string]: ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, + +[id: string]: + | LegacyThreadInfo + | RawThreadInfo + | MinimallyEncodedThreadInfo, }, ): $ReadOnlySet { const threadInfos = values(threadInfosObj); const threadIDs = threadInfos .filter( thread => (thread.community === communityID || thread.id === communityID) && threadInFilterList(thread), ) .map(item => item.id); return new Set(threadIDs); } export { createRecursiveDrawerItemsData, appendSuffix, filterThreadIDsBelongingToCommunity, }; diff --git a/lib/utils/entity-helpers.js b/lib/utils/entity-helpers.js index 3d62c0f86..58209a59d 100644 --- a/lib/utils/entity-helpers.js +++ b/lib/utils/entity-helpers.js @@ -1,144 +1,147 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ET, useENSNamesForEntityText, entityTextToRawString, } from './entity-text.js'; import type { UseENSNamesOptions } from '../hooks/ens-cache.js'; import type { MinimallyEncodedResolvedThreadInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo, ResolvedThreadInfo } from '../types/thread-types.js'; +import type { + LegacyThreadInfo, + ResolvedThreadInfo, +} from '../types/thread-types.js'; import { values } from '../utils/objects.js'; function useResolvedThreadInfos( - threadInfos: $ReadOnlyArray, + threadInfos: $ReadOnlyArray, options?: ?UseENSNamesOptions, ): $ReadOnlyArray { const entityText = React.useMemo( () => threadInfos.map(threadInfo => threadInfo.uiName), [threadInfos], ); const withENSNames = useENSNamesForEntityText(entityText, options); 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]; // Branching to appease `flow`. if (threadInfo.minimallyEncoded) { return { ...threadInfo, uiName: entityTextToRawString([resolvedThreadEntity]), }; } else { return { ...threadInfo, uiName: entityTextToRawString([resolvedThreadEntity]), }; } }), [threadInfos, withENSNames], ); } function useResolvedOptionalThreadInfos( - threadInfos: ?$ReadOnlyArray, + 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 }, + threadInfosObj: { +[id: string]: LegacyThreadInfo }, options?: ?UseENSNamesOptions, ): { +[id: string]: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo } { const threadInfosArray = React.useMemo( () => values(threadInfosObj), [threadInfosObj], ); const resolvedThreadInfosArray = useResolvedThreadInfos( threadInfosArray, options, ); return React.useMemo(() => { const obj: { [string]: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, } = {}; for (const resolvedThreadInfo of resolvedThreadInfosArray) { obj[resolvedThreadInfo.id] = resolvedThreadInfo; } return obj; }, [resolvedThreadInfosArray]); } function useResolvedThreadInfo( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo { const resolutionInput = React.useMemo(() => [threadInfo], [threadInfo]); const [resolvedThreadInfo] = useResolvedThreadInfos(resolutionInput); return resolvedThreadInfo; } function useResolvedOptionalThreadInfo( - threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ): ?ResolvedThreadInfo | ?MinimallyEncodedResolvedThreadInfo { 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/entity-text.js b/lib/utils/entity-text.js index aaee7b9a5..2375090ef 100644 --- a/lib/utils/entity-text.js +++ b/lib/utils/entity-text.js @@ -1,593 +1,599 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import t, { type TInterface, type TUnion } from 'tcomb'; import type { GetENSNames } from './ens-helpers.js'; import { tID, tShape, tString } from './validation-utils.js'; import { useENSNames, type UseENSNamesOptions } from '../hooks/ens-cache.js'; import { threadNoun } from '../shared/thread-utils.js'; import { stringForUser } from '../shared/user-utils.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { type ThreadType, threadTypes, threadTypeValidator, } from '../types/thread-types-enum.js'; -import type { ThreadInfo, RawThreadInfo } from '../types/thread-types.js'; +import type { LegacyThreadInfo, RawThreadInfo } from '../types/thread-types.js'; import { basePluralize } from '../utils/text-utils.js'; export type UserEntity = { +type: 'user', +id: string, +username?: ?string, +isViewer?: ?boolean, +possessive?: ?boolean, // eg. `user's` instead of `user` }; export const userEntityValidator: TInterface = tShape({ type: tString('user'), id: t.String, username: t.maybe(t.String), isViewer: t.maybe(t.Boolean), possessive: t.maybe(t.Boolean), }); // Comments explain how thread name will appear from user4's perspective export type ThreadEntity = | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or 'user1, user2, and user3' +display: 'uiName', // If uiName is EntityText, then at render time ThreadEntity will be // replaced with a pluralized list of uiName's UserEntities +uiName: $ReadOnlyArray | string, // If name isn't set and uiName is an array with only the viewer, then // just_you_string displays "just you" but viewer_username displays the // viewer's ENS-resolved username. Defaults to just_you_string +ifJustViewer?: 'just_you_string' | 'viewer_username', } | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or eg. 'this thread' or 'this chat' +display: 'shortName', +threadType?: ?ThreadType, +parentThreadID?: ?string, +alwaysDisplayShortName?: ?boolean, // don't default to name +subchannel?: ?boolean, // short name should be "subchannel" +possessive?: ?boolean, // eg. `this thread's` instead of `this thread` }; export const threadEntityValidator: TUnion = t.union([ tShape({ type: tString('thread'), id: tID, name: t.maybe(t.String), display: tString('uiName'), uiName: t.union([t.list(userEntityValidator), t.String]), ifJustViewer: t.maybe(t.enums.of(['just_you_string', 'viewer_username'])), }), tShape({ type: tString('thread'), id: tID, name: t.maybe(t.String), display: tString('shortName'), threadType: t.maybe(threadTypeValidator), parentThreadID: t.maybe(tID), alwaysDisplayShortName: t.maybe(t.Boolean), subchannel: t.maybe(t.Boolean), possessive: t.maybe(t.Boolean), }), ]); type ColorEntity = { +type: 'color', +hex: string, }; type EntityTextComponent = UserEntity | ThreadEntity | ColorEntity | string; export type EntityText = $ReadOnlyArray; const entityTextFunction = ( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => { const result: EntityTextComponent[] = []; for (let i = 0; i < strings.length; i++) { const str = strings[i]; if (str) { result.push(str); } const entity = entities[i]; if (!entity) { continue; } if (typeof entity === 'string') { const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string') { result[result.length - 1] = lastResult + entity; } else { result.push(entity); } } else if (Array.isArray(entity)) { const [firstEntity, ...restOfEntity] = entity; const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string' && typeof firstEntity === 'string') { result[result.length - 1] = lastResult + firstEntity; } else if (firstEntity) { result.push(firstEntity); } result.push(...restOfEntity); } else { result.push(entity); } } return result; }; // defaults to shortName type EntityTextThreadInput = | { +display: 'uiName', - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, } | { +display?: 'shortName', - +threadInfo: ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: + | LegacyThreadInfo + | RawThreadInfo + | MinimallyEncodedThreadInfo, +subchannel?: ?boolean, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', - +threadInfo: ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: + | LegacyThreadInfo + | RawThreadInfo + | MinimallyEncodedThreadInfo, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', +threadID: string, +parentThreadID?: ?string, +threadType?: ?ThreadType, +possessive?: ?boolean, }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return entityTextFunction.thread = (input: EntityTextThreadInput) => { if (input.display === 'uiName') { const { threadInfo } = input; if (typeof threadInfo.uiName !== 'string') { return threadInfo.uiName; } return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: threadInfo.uiName, }; } if (input.display === 'alwaysDisplayShortName' && input.threadID) { const { threadID, threadType, parentThreadID, possessive } = input; return { type: 'thread', id: threadID, name: undefined, display: 'shortName', threadType, parentThreadID, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'alwaysDisplayShortName' && input.threadInfo) { const { threadInfo, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, parentThreadID: threadInfo.parentThreadID, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'shortName' || !input.display) { const { threadInfo, subchannel, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, parentThreadID: threadInfo.parentThreadID, subchannel, possessive, }; } invariant( false, `ET.thread passed unexpected display type: ${input.display}`, ); }; type EntityTextUserInput = { +userInfo: { +id: string, +username?: ?string, +isViewer?: ?boolean, ... }, +possessive?: ?boolean, }; entityTextFunction.user = (input: EntityTextUserInput) => ({ type: 'user', id: input.userInfo.id, username: input.userInfo.username, isViewer: input.userInfo.isViewer, possessive: input.possessive, }); type EntityTextColorInput = { +hex: string }; entityTextFunction.color = (input: EntityTextColorInput) => ({ type: 'color', hex: input.hex, }); // ET is a JS tag function used in template literals, eg. ET`something` // It allows you to compose raw text and "entities" together type EntityTextFunction = (( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => EntityText) & { +thread: EntityTextThreadInput => ThreadEntity, +user: EntityTextUserInput => UserEntity, +color: EntityTextColorInput => ColorEntity, ... }; const ET: EntityTextFunction = entityTextFunction; type MakePossessiveInput = { +str: string, +isViewer?: ?boolean }; function makePossessive(input: MakePossessiveInput) { if (input.isViewer) { return 'your'; } return `${input.str}’s`; } function getNameForThreadEntity( entity: ThreadEntity, params?: ?EntityTextToRawStringParams, ): string { const { name: userGeneratedName, display } = entity; if (entity.display === 'uiName') { if (userGeneratedName) { return userGeneratedName; } const { uiName } = entity; if (typeof uiName === 'string') { return uiName; } let userEntities = uiName; if (!params?.ignoreViewer) { const viewerFilteredUserEntities = userEntities.filter( innerEntity => !innerEntity.isViewer, ); if (viewerFilteredUserEntities.length > 0) { userEntities = viewerFilteredUserEntities; } else if (entity.ifJustViewer === 'viewer_username') { // We pass ignoreViewer to entityTextToRawString in order // to prevent it from rendering the viewer as "you" params = { ...params, ignoreViewer: true }; } else { return 'just you'; } } const pluralized = pluralizeEntityText( userEntities.map(innerEntity => [innerEntity]), ); return entityTextToRawString(pluralized, params); } invariant( entity.display === 'shortName', `getNameForThreadEntity can't handle thread entity display ${display}`, ); let { name } = entity; if (!name || entity.alwaysDisplayShortName) { const threadType = entity.threadType ?? threadTypes.PERSONAL; const { parentThreadID } = entity; const noun = entity.subchannel ? 'subchannel' : threadNoun(threadType, parentThreadID); if (entity.id === params?.threadID) { const prefixThisThreadNounWith = params?.prefixThisThreadNounWith === 'your' ? 'your' : 'this'; name = `${prefixThisThreadNounWith} ${noun}`; } else { name = `a ${noun}`; } } if (entity.possessive) { name = makePossessive({ str: name }); } return name; } function getNameForUserEntity( entity: UserEntity, ignoreViewer: ?boolean, ): string { const isViewer = entity.isViewer && !ignoreViewer; const entityWithIsViewerIgnored = { ...entity, isViewer }; const str = stringForUser(entityWithIsViewerIgnored); if (!entityWithIsViewerIgnored.possessive) { return str; } return makePossessive({ str, isViewer }); } type EntityTextToRawStringParams = { +threadID?: ?string, +ignoreViewer?: ?boolean, +prefixThisThreadNounWith?: ?('this' | 'your'), }; function entityTextToRawString( entityText: EntityText, params?: ?EntityTextToRawStringParams, ): string { // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return const textParts = entityText.map(entity => { if (typeof entity === 'string') { return entity; } else if (entity.type === 'thread') { return getNameForThreadEntity(entity, params); } else if (entity.type === 'color') { return entity.hex; } else if (entity.type === 'user') { return getNameForUserEntity(entity, params?.ignoreViewer); } else { invariant( false, `entityTextToRawString can't handle entity type ${entity.type}`, ); } }); return textParts.join(''); } type RenderFunctions = { +renderText: ({ +text: string }) => React.Node, +renderThread: ({ +id: string, +name: string }) => React.Node, +renderUser: ({ +userID: string, +usernameText: string }) => React.Node, +renderColor: ({ +hex: string }) => React.Node, }; function entityTextToReact( entityText: EntityText, threadID: string, renderFuncs: RenderFunctions, ): React.Node { const { renderText, renderThread, renderUser, renderColor } = renderFuncs; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return return entityText.map((entity, i) => { const key = `text${i}`; if (typeof entity === 'string') { return ( {renderText({ text: entity })} ); } else if (entity.type === 'thread') { const { id } = entity; const name = getNameForThreadEntity(entity, { threadID }); if (id === threadID) { return name; } else { return ( {renderThread({ id, name })} ); } } else if (entity.type === 'color') { return ( {renderColor({ hex: entity.hex })} ); } else if (entity.type === 'user') { const userID = entity.id; const usernameText = getNameForUserEntity(entity); return ( {renderUser({ userID, usernameText })} ); } else { invariant( false, `entityTextToReact can't handle entity type ${entity.type}`, ); } }); } function pluralizeEntityText( nouns: $ReadOnlyArray, maxNumberOfNouns: number = 3, ): EntityText { return basePluralize( nouns, maxNumberOfNouns, (a: EntityText | string, b: ?EntityText | string) => b ? ET`${a}${b}` : ET`${a}`, ); } type TextEntity = { +type: 'text', +text: string }; type ShadowUserEntity = { +type: 'shadowUser', +username: string, +originalUsername: string, }; type EntityTextComponentAsObject = | UserEntity | ThreadEntity | ColorEntity | TextEntity | ShadowUserEntity; function entityTextToObjects( entityText: EntityText, ): EntityTextComponentAsObject[] { const objs: EntityTextComponentAsObject[] = []; for (const entity of entityText) { if (typeof entity === 'string') { objs.push({ type: 'text', text: entity }); continue; } objs.push(entity); if ( entity.type === 'thread' && entity.display === 'uiName' && typeof entity.uiName !== 'string' ) { for (const innerEntity of entity.uiName) { if (typeof innerEntity === 'string' || innerEntity.type !== 'user') { continue; } const { username } = innerEntity; if (username) { objs.push({ type: 'shadowUser', originalUsername: username, username, }); } } } } return objs; } function entityTextFromObjects( objects: $ReadOnlyArray, ): EntityText { const shadowUserMap = new Map(); for (const obj of objects) { if (obj.type === 'shadowUser' && obj.username !== obj.originalUsername) { shadowUserMap.set(obj.originalUsername, obj.username); } } return objects .map(entity => { if (entity.type === 'text') { return entity.text; } else if (entity.type === 'shadowUser') { return null; } else if ( entity.type === 'thread' && entity.display === 'uiName' && typeof entity.uiName !== 'string' ) { const uiName: UserEntity[] = []; let changeOccurred = false; for (const innerEntity of entity.uiName) { if (typeof innerEntity === 'string' || innerEntity.type !== 'user') { uiName.push(innerEntity); continue; } const { username } = innerEntity; if (!username) { uiName.push(innerEntity); continue; } const ensName = shadowUserMap.get(username); if (!ensName) { uiName.push(innerEntity); continue; } changeOccurred = true; uiName.push({ ...innerEntity, username: ensName, }); } if (!changeOccurred) { return entity; } return { ...entity, uiName, }; } else { return entity; } }) .filter(Boolean); } function useENSNamesForEntityText( entityText: ?EntityText, options?: ?UseENSNamesOptions, ): ?EntityText { const allObjects = React.useMemo( () => (entityText ? entityTextToObjects(entityText) : []), [entityText], ); const objectsWithENSNames = useENSNames(allObjects, options); return React.useMemo( () => entityText ? entityTextFromObjects(objectsWithENSNames) : entityText, [entityText, objectsWithENSNames], ); } function useEntityTextAsString( entityText: ?EntityText, params?: EntityTextToRawStringParams, ): ?string { const withENSNames = useENSNamesForEntityText(entityText); return React.useMemo(() => { if (!withENSNames) { return withENSNames; } return entityTextToRawString(withENSNames, params); }, [withENSNames, params]); } async function getEntityTextAsString( entityText: ?EntityText, getENSNames: ?GetENSNames, params?: EntityTextToRawStringParams, ): Promise { if (!entityText) { return entityText; } let resolvedEntityText = entityText; if (getENSNames) { const allObjects = entityTextToObjects(entityText); const objectsWithENSNames = await getENSNames(allObjects); resolvedEntityText = entityTextFromObjects(objectsWithENSNames); } return entityTextToRawString(resolvedEntityText, params); } export { ET, entityTextToRawString, entityTextToReact, pluralizeEntityText, useENSNamesForEntityText, useEntityTextAsString, getEntityTextAsString, }; diff --git a/lib/utils/role-utils.js b/lib/utils/role-utils.js index 951e625b8..283dc5fad 100644 --- a/lib/utils/role-utils.js +++ b/lib/utils/role-utils.js @@ -1,128 +1,128 @@ // @flow import * as React from 'react'; import { useSelector } from './redux-utils.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { configurableCommunityPermissions, type ThreadRolePermissionsBlob, type UserSurfacedPermission, } from '../types/thread-permission-types.js'; import type { - ThreadInfo, + LegacyThreadInfo, RoleInfo, RelativeMemberInfo, } from '../types/thread-types'; import { threadTypes } from '../types/thread-types-enum.js'; function constructRoleDeletionMessagePrompt( defaultRoleName: string, memberCount: number, ): string { let message; if (memberCount === 0) { message = 'Are you sure you want to delete this role?'; } else { const messageNoun = memberCount === 1 ? 'member' : 'members'; const messageVerb = memberCount === 1 ? 'is' : 'are'; message = `There ${messageVerb} currently ${memberCount} ${messageNoun} with ` + `this role. Deleting the role will automatically assign the ` + `${messageNoun} affected to the ${defaultRoleName} role.`; } return message; } type RoleDeletableAndEditableStatus = { +isDeletable: boolean, +isEditable: boolean, }; function useRoleDeletableAndEditableStatus( roleName: string, defaultRoleID: string, existingRoleID: string, ): RoleDeletableAndEditableStatus { return React.useMemo(() => { const canDelete = roleName !== 'Admins' && defaultRoleID !== existingRoleID; const canEdit = roleName !== 'Admins'; return { isDeletable: canDelete, isEditable: canEdit, }; }, [roleName, defaultRoleID, existingRoleID]); } function useRolesFromCommunityThreadInfo( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, memberInfos: $ReadOnlyArray, ): $ReadOnlyMap { // Our in-code system has chat-specific roles, while the // user-surfaced system has roles only for communities. We retrieve roles // from the top-level community thread for accuracy, with a rare fallback // for potential issues reading memberInfos, primarily in GENESIS threads. // The special case is GENESIS threads, since per prior discussion // (see context: https://linear.app/comm/issue/ENG-4077/), we don't really // support roles for it. Also with GENESIS, the list of members are not // populated in the community root. So in this case to prevent crashing, we // should just return the role name from the current thread info. const { community } = threadInfo; const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const topMostThreadInfo = communityThreadInfo || threadInfo; const roleMap = new Map(); if (topMostThreadInfo.type === threadTypes.GENESIS) { memberInfos.forEach(memberInfo => roleMap.set( memberInfo.id, memberInfo.role ? threadInfo.roles[memberInfo.role] : null, ), ); return roleMap; } const { members: memberInfosFromTopMostThreadInfo, roles } = topMostThreadInfo; memberInfosFromTopMostThreadInfo.forEach(memberInfo => { roleMap.set(memberInfo.id, memberInfo.role ? roles[memberInfo.role] : null); }); return roleMap; } function toggleUserSurfacedPermission( rolePermissions: ThreadRolePermissionsBlob, userSurfacedPermission: UserSurfacedPermission, ): ThreadRolePermissionsBlob { const userSurfacedPermissionSet = Array.from( configurableCommunityPermissions[userSurfacedPermission], ); const currentRolePermissions = { ...rolePermissions }; const roleHasPermission = userSurfacedPermissionSet.every( permission => currentRolePermissions[permission], ); if (roleHasPermission) { for (const permission of userSurfacedPermissionSet) { delete currentRolePermissions[permission]; } } else { for (const permission of userSurfacedPermissionSet) { currentRolePermissions[permission] = true; } } return currentRolePermissions; } export { constructRoleDeletionMessagePrompt, useRoleDeletableAndEditableStatus, useRolesFromCommunityThreadInfo, toggleUserSurfacedPermission, }; diff --git a/lib/utils/toggle-pin-utils.js b/lib/utils/toggle-pin-utils.js index 41a7fcc0b..79c142191 100644 --- a/lib/utils/toggle-pin-utils.js +++ b/lib/utils/toggle-pin-utils.js @@ -1,23 +1,23 @@ // @flow import { isInvalidPinSourceForThread } from '../shared/message-utils.js'; import { threadHasPermission } from '../shared/thread-utils.js'; import type { RawMessageInfo, MessageInfo } from '../types/message-types.js'; import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; -import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js'; +import type { RawThreadInfo, LegacyThreadInfo } from '../types/thread-types.js'; function canToggleMessagePin( messageInfo: RawMessageInfo | MessageInfo, - threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, ): boolean { const isValidMessage = !isInvalidPinSourceForThread(messageInfo, threadInfo); const hasManagePinsPermission = threadHasPermission( threadInfo, threadPermissions.MANAGE_PINS, ); return isValidMessage && hasManagePinsPermission; } export { canToggleMessagePin }; diff --git a/native/avatars/edit-thread-avatar.react.js b/native/avatars/edit-thread-avatar.react.js index a310ea574..a8c3a8dbb 100644 --- a/native/avatars/edit-thread-avatar.react.js +++ b/native/avatars/edit-thread-avatar.react.js @@ -1,122 +1,125 @@ // @flow import { useNavigation } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, TouchableOpacity, View } from 'react-native'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; +import type { + RawThreadInfo, + LegacyThreadInfo, +} from 'lib/types/thread-types.js'; import { useNativeSetThreadAvatar, useSelectFromGalleryAndUpdateThreadAvatar, useShowAvatarActionSheet, } from './avatar-hooks.js'; import EditAvatarBadge from './edit-avatar-badge.react.js'; import ThreadAvatar from './thread-avatar.react.js'; import { EmojiThreadAvatarCreationRouteName, ThreadAvatarCameraModalRouteName, } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, +disabled?: boolean, }; function EditThreadAvatar(props: Props): React.Node { const styles = useStyles(unboundStyles); const { threadInfo, disabled } = props; const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { threadAvatarSaveInProgress } = editThreadAvatarContext; const nativeSetThreadAvatar = useNativeSetThreadAvatar(); const selectFromGalleryAndUpdateThreadAvatar = useSelectFromGalleryAndUpdateThreadAvatar(); const { navigate } = useNavigation(); const navigateToThreadEmojiAvatarCreation = React.useCallback(() => { navigate<'EmojiThreadAvatarCreation'>({ name: EmojiThreadAvatarCreationRouteName, params: { threadInfo, }, }); }, [navigate, threadInfo]); const selectFromGallery = React.useCallback( () => selectFromGalleryAndUpdateThreadAvatar(threadInfo.id), [selectFromGalleryAndUpdateThreadAvatar, threadInfo.id], ); const navigateToCamera = React.useCallback(() => { navigate<'ThreadAvatarCameraModal'>({ name: ThreadAvatarCameraModalRouteName, params: { threadID: threadInfo.id }, }); }, [navigate, threadInfo.id]); const removeAvatar = React.useCallback( () => nativeSetThreadAvatar(threadInfo.id, { type: 'remove' }), [nativeSetThreadAvatar, threadInfo.id], ); const actionSheetConfig = React.useMemo(() => { const configOptions = [ { id: 'emoji', onPress: navigateToThreadEmojiAvatarCreation }, { id: 'image', onPress: selectFromGallery }, { id: 'camera', onPress: navigateToCamera }, ]; if (threadInfo.avatar) { configOptions.push({ id: 'remove', onPress: removeAvatar }); } return configOptions; }, [ navigateToCamera, navigateToThreadEmojiAvatarCreation, removeAvatar, selectFromGallery, threadInfo.avatar, ]); const showAvatarActionSheet = useShowAvatarActionSheet(actionSheetConfig); let spinner; if (threadAvatarSaveInProgress) { spinner = ( ); } return ( {spinner} {!disabled ? : null} ); } const unboundStyles = { spinnerContainer: { position: 'absolute', alignItems: 'center', justifyContent: 'center', top: 0, bottom: 0, left: 0, right: 0, }, }; export default EditThreadAvatar; diff --git a/native/avatars/thread-avatar.react.js b/native/avatars/thread-avatar.react.js index aa1bc98b2..eca9e6613 100644 --- a/native/avatars/thread-avatar.react.js +++ b/native/avatars/thread-avatar.react.js @@ -1,62 +1,62 @@ // @flow import * as React from 'react'; import { useAvatarForThread, useENSResolvedAvatar, } from 'lib/shared/avatar-utils.js'; import { getSingleOtherUser } from 'lib/shared/thread-utils.js'; import type { AvatarSize } from 'lib/types/avatar-types.js'; import type { MinimallyEncodedResolvedThreadInfo, MinimallyEncodedThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { - ThreadInfo, + LegacyThreadInfo, ResolvedThreadInfo, RawThreadInfo, } from 'lib/types/thread-types.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +threadInfo: | RawThreadInfo - | ThreadInfo + | LegacyThreadInfo | ResolvedThreadInfo | MinimallyEncodedThreadInfo | MinimallyEncodedResolvedThreadInfo, +size: AvatarSize, }; function ThreadAvatar(props: Props): React.Node { const { threadInfo, size } = props; const avatarInfo = useAvatarForThread(threadInfo); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); let displayUserIDForThread; if (threadInfo.type === threadTypes.PRIVATE) { displayUserIDForThread = viewerID; } else if (threadInfo.type === threadTypes.PERSONAL) { displayUserIDForThread = getSingleOtherUser(threadInfo, viewerID); } const displayUser = useSelector(state => displayUserIDForThread ? state.userStore.userInfos[displayUserIDForThread] : null, ); const resolvedThreadAvatar = useENSResolvedAvatar(avatarInfo, displayUser); return ; } export default ThreadAvatar; diff --git a/native/calendar/calendar.react.js b/native/calendar/calendar.react.js index 74dd52503..7eb709729 100644 --- a/native/calendar/calendar.react.js +++ b/native/calendar/calendar.react.js @@ -1,1115 +1,1115 @@ // @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, useUpdateCalendarQuery, } from 'lib/actions/entry-actions.js'; import type { UpdateCalendarQueryInput } from 'lib/actions/entry-actions.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.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 type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { 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 { ashoatKeyserverID } from 'lib/utils/validation-utils.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, + threadInfo: LegacyThreadInfo, }; type ExtraData = { +activeEntries: { +[key: string]: boolean }, +visibleEntries: { +[key: string]: boolean }, }; 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: {}, }; 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: $ReadOnly, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateCalendarQuery: ( input: UpdateCalendarQueryInput, ) => 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: Map = new Map(); constructor(props: Props) { super(props); this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.state = { listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, currentlyEditing: [], }; } componentDidMount() { this.appStateListener = NativeAppState.addEventListener( 'change', this.handleAppStateChange, ); this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); this.props.navigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { if (this.appStateListener) { this.appStateListener.remove(); this.appStateListener = null; } if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } this.props.navigation.removeListener('tabPress', this.onTabPress); } handleAppStateChange = (nextAppState: ?string) => { const lastState = this.currentState; this.currentState = nextAppState; if ( !lastState || !lastState.match(/inactive|background/) || this.currentState !== 'active' ) { // We're only handling foregrounding here return; } if (Date.now() - this.lastCalendarReset < 500) { // If the calendar got reset right before this callback triggered, that // indicates we should reset the scroll position this.lastCalendarReset = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that the calendar is about to get reset. We // record a timestamp here so we can scrollToToday there. this.lastForegrounded = Date.now(); } }; onTabPress = () => { if (this.props.navigation.isFocused()) { this.scrollToToday(); } }; componentDidUpdate(prevProps: Props, prevState: State) { if (!this.props.listData && this.props.listData !== prevProps.listData) { this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.setState({ listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, }); this.firstScrollComplete = false; this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; } const { loadingStatus, connectionStatus } = this.props; const { loadingStatus: prevLoadingStatus, connectionStatus: prevConnectionStatus, } = prevProps; if ( (loadingStatus === 'error' && prevLoadingStatus === 'loading') || (connectionStatus === 'connected' && prevConnectionStatus !== 'connected') ) { this.loadMoreAbove(); this.loadMoreBelow(); } const lastLDWH = prevState.listDataWithHeights; const newLDWH = this.state.listDataWithHeights; if (!newLDWH) { return; } else if (!lastLDWH) { if (!this.props.calendarActive) { // FlatList has an initialScrollIndex prop, which is usually close to // centering but can be off when there is a particularly large Entry in // the list. scrollToToday lets us actually center, but gets overriden // by initialScrollIndex if we call it right after the FlatList mounts sleep(50).then(() => this.scrollToToday()); } return; } if (newLDWH.length < lastLDWH.length) { this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; if (this.flatList) { if (!this.props.calendarActive) { // If the currentCalendarQuery gets reset we scroll to the center this.scrollToToday(); } else if (Date.now() - this.lastForegrounded < 500) { // If the app got foregrounded right before the calendar got reset, // that indicates we should reset the scroll position this.lastForegrounded = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that we got triggered before the // foreground callback. Let's record a timestamp here so we can call // scrollToToday there this.lastCalendarReset = Date.now(); } } } const { lastStartDate, newStartDate, lastEndDate, newEndDate } = Calendar.datesFromListData(lastLDWH, newLDWH); if (newStartDate > lastStartDate || newEndDate < lastEndDate) { // If there are fewer items in our new data, which happens when the // current calendar query gets reset due to inactivity, let's reset the // scroll position to the center (today) if (!this.props.calendarActive) { sleep(50).then(() => this.scrollToToday()); } this.firstScrollComplete = false; } else if (newStartDate < lastStartDate) { this.updateScrollPositionAfterPrepend(lastLDWH, newLDWH); } else if (newEndDate > lastEndDate) { this.firstScrollComplete = true; } else if (newLDWH.length > lastLDWH.length) { LayoutAnimation.easeInEaseOut(); } if (newStartDate < lastStartDate) { this.topLoadingFromScroll = null; } if (newEndDate > lastEndDate) { this.bottomLoadingFromScroll = null; } const { keyboardShownHeight, lastEntryKeyActive } = this; if (keyboardShownHeight && lastEntryKeyActive) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } } static datesFromListData( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ): { +lastStartDate: Date, +newStartDate: Date, +lastEndDate: Date, +newEndDate: Date, } { const lastSecondItem = lastLDWH[1]; const newSecondItem = newLDWH[1]; invariant( newSecondItem.itemType === 'header' && lastSecondItem.itemType === 'header', 'second item in listData should be a header', ); const lastStartDate = dateFromString(lastSecondItem.dateString); const newStartDate = dateFromString(newSecondItem.dateString); const lastPenultimateItem = lastLDWH[lastLDWH.length - 2]; const newPenultimateItem = newLDWH[newLDWH.length - 2]; invariant( newPenultimateItem.itemType === 'footer' && lastPenultimateItem.itemType === 'footer', 'penultimate item in listData should be a footer', ); const lastEndDate = dateFromString(lastPenultimateItem.dateString); const newEndDate = dateFromString(newPenultimateItem.dateString); return { lastStartDate, newStartDate, lastEndDate, newEndDate }; } /** * When prepending list items, FlatList isn't smart about preserving scroll * position. If we're at the start of the list before prepending, FlatList * will just keep us at the front after prepending. But we want to preserve * the previous on-screen items, so we have to do a calculation to get the new * scroll position. (And deal with the inherent glitchiness of trying to time * that change with the items getting prepended... *sigh*.) */ updateScrollPositionAfterPrepend( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const existingKeys = new Set(_map(Calendar.keyExtractor)(lastLDWH)); const newItems = _filter( (item: CalendarItemWithHeight) => !existingKeys.has(Calendar.keyExtractor(item)), )(newLDWH); const heightOfNewItems = Calendar.heightOfItems(newItems); const flatList = this.flatList; invariant(flatList, 'flatList should be set'); const scrollAction = () => { invariant( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null, 'currentScrollPosition should be set', ); const currentScrollPosition = Math.max(this.currentScrollPosition, 0); const offset = currentScrollPosition + heightOfNewItems; flatList.scrollToOffset({ offset, animated: false, }); }; scrollAction(); if (!this.firstScrollComplete) { setTimeout(scrollAction, 0); this.firstScrollComplete = true; } } scrollToToday(animated: ?boolean = undefined) { if (animated === undefined) { animated = this.props.calendarActive; } const ldwh = this.state.listDataWithHeights; if (!ldwh) { return; } const todayIndex = _findIndex(['dateString', dateString(new Date())])(ldwh); invariant(this.flatList, "scrollToToday called, but flatList isn't set"); this.flatList.scrollToIndex({ index: todayIndex, animated, viewPosition: 0.5, }); } // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return renderItem = (row: { +item: CalendarItemWithHeight, ... }): React.Node => { 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): React.Node => { 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): React.Node => { return ( ); }; onAdd = (dayString: string) => { this.props.navigation.navigate(ThreadPickerModalRouteName, { presentedFrom: this.props.route.key, dateString: dayString, }); }; static keyExtractor = ( item: CalendarItemWithHeight | CalendarItem, // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return ): string => { 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, ): { length: number, offset: number, index: number } => { if (!data) { return { length: 0, offset: 0, index }; } const offset = Calendar.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? Calendar.itemHeight(item) : 0; return { length, offset, index }; }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return static itemHeight = (item: CalendarItemWithHeight): number => { 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, ): number => { return _sum(data.map(Calendar.itemHeight)); }; render(): React.Node { 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(): number { const { safeAreaHeight, tabBarHeight } = this.props.dimensions; return safeAreaHeight - tabBarHeight; } initialScrollIndex(data: $ReadOnlyArray): number { 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: number = 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): ?string => { if (item.itemType !== 'entryInfo') { return null; } return item.entryInfo.text; }; heightMeasurerDummy = (item: CalendarItem): React.MixedElement => { invariant( item.itemType === 'entryInfo', 'NodeHeightMeasurer asked for dummy for non-entryInfo item', ); return dummyNodeForEntryHeightMeasurement(item.entryInfo.text); }; heightMeasurerMergeItem = ( item: CalendarItem, height: ?number, ): CalendarItemWithHeight => { 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: { [string]: boolean } = {}; 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: () => void = _throttle(() => { if ( this.topLoadingFromScroll && this.topLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.topLoadingFromScroll); } }, 1000); loadMoreBelow: () => void = _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 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 connection = useSelector(connectionSelector(ashoatKeyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); const connectionStatus = connection.status; const colors = useColors(); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateCalendarQuery = useUpdateCalendarQuery(); return ( ); }, ); export default ConnectedCalendar; diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index 2003ae086..fca90803a 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,822 +1,822 @@ // @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, LayoutAnimation, Keyboard, } from 'react-native'; import shallowequal from 'shallowequal'; import tinycolor from 'tinycolor2'; import { createEntryActionTypes, useCreateEntry, saveEntryActionTypes, useSaveEntry, deleteEntryActionTypes, useDeleteEntry, concurrentModificationResetActionType, } from 'lib/actions/entry-actions.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import { threadHasPermission } from 'lib/shared/thread-utils.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 { MinimallyEncodedResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { - type ThreadInfo, + type LegacyThreadInfo, type ResolvedThreadInfo, } from 'lib/types/thread-types.js'; import { 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 { useDispatch } from 'lib/utils/redux-utils.js'; import sleep from 'lib/utils/sleep.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.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 Alert from '../utils/alert.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} ); } 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), }, }; 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, + +threadInfo: LegacyThreadInfo, }; type Props = { ...SharedProps, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, // Redux state +calendarQuery: () => CalendarQuery, +online: boolean, +styles: $ReadOnly, // 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: Partial) { 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(); } }; 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 }); }; } 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 connection = useSelector(connectionSelector(ashoatKeyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); const online = connection.status === 'connected'; const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callCreateEntry = useCreateEntry(); const callSaveEntry = useSaveEntry(); const callDeleteEntry = useDeleteEntry(); const { threadInfo: unresolvedThreadInfo, ...restProps } = props; const threadInfo = useResolvedThreadInfo(unresolvedThreadInfo); return ( ); }, ); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/native/chat/chat-context-provider.react.js b/native/chat/chat-context-provider.react.js index e8703914a..331022230 100644 --- a/native/chat/chat-context-provider.react.js +++ b/native/chat/chat-context-provider.react.js @@ -1,174 +1,174 @@ // @flow import * as React from 'react'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } 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 { NativeChatMessageItem } from './message-data.react.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; type Props = { +children: React.Node, }; export type MeasurementTask = { +messages: $ReadOnlyArray, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +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 | ?MinimallyEncodedThreadInfo, + threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, 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 | ?MinimallyEncodedThreadInfo, + threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, 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 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} ); } const MemoizedChatContextProvider: React.ComponentType = React.memo(ChatContextProvider); export default MemoizedChatContextProvider; diff --git a/native/chat/chat-context.js b/native/chat/chat-context.js index 27d56a0c3..68be9c2b3 100644 --- a/native/chat/chat-context.js +++ b/native/chat/chat-context.js @@ -1,54 +1,54 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { SetState } from 'lib/types/hook-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { NativeChatMessageItem } from './message-data.react.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; export type MessagesMeasurer = ( ?$ReadOnlyArray, - ?ThreadInfo | ?MinimallyEncodedThreadInfo, + ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ($ReadOnlyArray) => mixed, ) => void; export type RegisteredMeasurer = { +measure: MessagesMeasurer, +unregister: () => void, }; export type SidebarAnimationType = | 'fade_source_message' | 'move_source_message'; export type ChatContextType = { +registerMeasurer: () => RegisteredMeasurer, +currentTransitionSidebarSourceID: ?string, +setCurrentTransitionSidebarSourceID: SetState, +setChatInputBarHeight: (threadID: string, height: number) => mixed, +deleteChatInputBarHeight: (threadID: string) => mixed, +chatInputBarHeights: $ReadOnlyMap, +sidebarAnimationType: SidebarAnimationType, +setSidebarAnimationType: (animationType: SidebarAnimationType) => mixed, }; const ChatContext: React.Context = React.createContext(null); function useHeightMeasurer(): MessagesMeasurer { const chatContext = React.useContext(ChatContext); invariant(chatContext, 'Chat context should be set'); const measureRegistrationRef = React.useRef(); if (!measureRegistrationRef.current) { measureRegistrationRef.current = chatContext.registerMeasurer(); } const measureRegistration = measureRegistrationRef.current; React.useEffect(() => { return measureRegistration.unregister; }, [measureRegistration]); return measureRegistration.measure; } export { ChatContext, useHeightMeasurer }; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index da5eed834..da0ff93ad 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,1454 +1,1454 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import type { GenericNavigationAction } from '@react-navigation/core'; 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, FadeInDown, FadeOutDown, } from 'react-native-reanimated'; import { moveDraftActionType, updateDraftActionType, } from 'lib/actions/draft-actions.js'; import { joinThreadActionTypes, useJoinThread, newThreadActionTypes, } from 'lib/actions/thread-actions.js'; import { useChatMentionContext, useThreadChatMentionCandidates, } from 'lib/hooks/chat-mention-hooks.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userStoreMentionSearchIndex } from 'lib/selectors/user-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { useEditMessage } from 'lib/shared/edit-messages-utils.js'; import { getMentionTypeaheadUserSuggestions, getMentionTypeaheadChatSuggestions, getTypeaheadRegexMatches, type Selection, getUserMentionsCandidates, } from 'lib/shared/mention-utils.js'; import { useNextLocalID, trimMessage, useMessagePreview, messageKey, type MessagePreviewResult, } from 'lib/shared/message-utils.js'; import SentencePrefixSearchIndex from 'lib/shared/sentence-prefix-search-index.js'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, } from 'lib/shared/thread-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-enum.js'; import type { SendEditMessageResponse, MessageInfo, } from 'lib/types/message-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { - ThreadInfo, + LegacyThreadInfo, ClientThreadJoinRequest, ThreadJoinPayload, ChatMentionCandidates, RelativeMemberInfo, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { ChatContext } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react.js'; import { MessageEditingContext, type MessageEditingContextType, } from './message-editing-context.react.js'; import type { RemoveEditMode } from './message-list-types.js'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import MentionTypeaheadTooltipButton from '../chat/mention-typeahead-tooltip-button.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 SingleLine from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { type InputState, InputStateContext, type EditInputBarMessageParameters, } 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 { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { OverlayContextType } from '../navigation/overlay-context.js'; import { type NavigationRoute, ChatCameraModalRouteName, 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, ImagePasteEvent } from '../types/react-native.js'; import { type AnimatedViewStyle, AnimatedView, type ViewStyle, } from '../types/styles.js'; import Alert from '../utils/alert.js'; import { runTiming } from '../utils/animation-utils.js'; import { exitEditAlert } from '../utils/edit-messages-utils.js'; import { nativeMentionTypeaheadRegex, mentionTypeaheadTooltipActions, } from '../utils/typeahead-utils.js'; /* eslint-disable import/no-named-as-default-member */ 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), }; 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, }, editView: { marginLeft: 20, marginRight: 20, padding: 10, flexDirection: 'row', justifyContent: 'space-between', }, editViewContent: { flex: 1, paddingRight: 6, }, exitEditButton: { marginTop: 6, }, editingLabel: { paddingBottom: 4, }, editingMessagePreview: { color: 'listForegroundLabel', }, 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: 8, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginRight: 4, marginTop: 6, marginBottom: 8, maxHeight: 110, paddingHorizontal: 10, paddingVertical: 5, }, }; type BaseProps = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { ...BaseProps, +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: string, +userInfos: UserInfos, +colors: Colors, +styles: $ReadOnly, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +isActive: boolean, +keyboardState: ?KeyboardState, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +joinThread: (request: ClientThreadJoinRequest) => Promise, +inputState: ?InputState, +userSearchIndex: SentencePrefixSearchIndex, +userMentionsCandidates: $ReadOnlyArray, +chatMentionSearchIndex: SentencePrefixSearchIndex, +chatMentionCandidates: ChatMentionCandidates, - +parentThreadInfo: ?ThreadInfo, + +parentThreadInfo: ?LegacyThreadInfo, +editedMessagePreview: ?MessagePreviewResult, +editedMessageInfo: ?MessageInfo, +editMessage: ( messageID: string, text: string, ) => Promise, +navigation: ?ChatNavigationProp<'MessageList'>, +overlayContext: ?OverlayContextType, +messageEditingContext: ?MessageEditingContextType, }; type State = { +text: string, +textEdited: boolean, +buttonsExpanded: boolean, +selectionState: SyncedSelectionData, +isExitingDuringEditMode: boolean, }; 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; clearBeforeRemoveListener: () => void; clearFocusListener: () => void; clearBlurListener: () => void; constructor(props: Props) { super(props); this.state = { text: props.draft, textEdited: false, buttonsExpanded: true, selectionState: { text: props.draft, selection: { start: 0, end: 0 } }, isExitingDuringEditMode: false, }; 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): boolean { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props): boolean { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing(): boolean { 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() { const { isActive, navigation } = this.props; if (isActive) { this.addEditInputMessageListener(); } if (!navigation) { return; } this.clearBeforeRemoveListener = navigation.addListener( 'beforeRemove', this.onNavigationBeforeRemove, ); this.clearFocusListener = navigation.addListener( 'focus', this.onNavigationFocus, ); this.clearBlurListener = navigation.addListener( 'blur', this.onNavigationBlur, ); } componentWillUnmount() { if (this.props.isActive) { this.removeEditInputMessageListener(); } if (this.clearBeforeRemoveListener) { this.clearBeforeRemoveListener(); } if (this.clearFocusListener) { this.clearFocusListener(); } if (this.clearBlurListener) { this.clearBlurListener(); } } 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.addEditInputMessageListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeEditInputMessageListener(); } 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); 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(); } if ( this.props.messageEditingContext?.editState.editedMessage && !prevProps.messageEditingContext?.editState.editedMessage ) { this.blockNavigation(); } } addEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in addEditInputMessageListener', ); this.props.inputState.addEditInputMessageListener(this.focusAndUpdateText); } removeEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in removeEditInputMessageListener', ); this.props.inputState.removeEditInputMessageListener( 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(): React.Node { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; const threadColor = `#${this.props.threadInfo.color}`; const isEditMode = this.isEditMode(); 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, nativeMentionTypeaheadRegex, ); let typeaheadTooltip = null; if (typeaheadRegexMatches && !isEditMode) { const typeaheadMatchedStrings = { textBeforeAtSymbol: typeaheadRegexMatches[1] ?? '', query: typeaheadRegexMatches[4] ?? '', }; const suggestedUsers = getMentionTypeaheadUserSuggestions( this.props.userSearchIndex, this.props.userMentionsCandidates, this.props.viewerID, typeaheadMatchedStrings.query, ); const suggestedChats = getMentionTypeaheadChatSuggestions( this.props.chatMentionSearchIndex, this.props.chatMentionCandidates, typeaheadMatchedStrings.query, ); const suggestions = [...suggestedUsers, ...suggestedChats]; if (suggestions.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 : ( ); let editedMessage; if (isEditMode && this.props.editedMessagePreview) { const { message } = this.props.editedMessagePreview; editedMessage = ( Editing message {message.text} ); } return ( {typeaheadTooltip} {joinButton} {editedMessage} {content} {keyboardInputHost} ); } renderInput(): React.Node { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; const expandoButtonsViewStyle: Array = [ this.props.styles.innerExpandoButtons, ]; if (this.isEditMode()) { expandoButtonsViewStyle.push({ display: 'none' }); } 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) => { if (this.state.isExitingDuringEditMode) { return; } this.setState({ text, textEdited: true }); this.props.messageEditingContext?.setEditedMessageChanged( this.isMessageEdited(text), ); if (this.isEditMode()) { return; } this.saveDraft(text); }; updateSelectionState: (data: SyncedSelectionData) => void = data => { this.setState({ selectionState: data }); }; saveDraft: (text: string) => void = _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 = (params: EditInputBarMessageParameters) => { const { message: text, mode } = params; const currentText = this.state.text; if (mode === 'replace') { this.updateText(text); } else 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; } const editedMessage = this.getEditedMessage(); if (editedMessage && editedMessage.id) { this.editMessage(editedMessage.id, 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 = 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, this.props.parentThreadInfo, ); }; isEditMode = (): boolean => { const editState = this.props.messageEditingContext?.editState; const isThisThread = editState?.editedMessage?.threadID === this.props.threadInfo.id; return editState?.editedMessage !== null && isThisThread; }; isMessageEdited = (newText?: string): boolean => { let text = newText ?? this.state.text; text = trimMessage(text); const originalText = this.props.editedMessageInfo?.text; return text !== originalText; }; unblockNavigation = () => { const { navigation } = this.props; if (!navigation) { return; } navigation.setParams({ removeEditMode: null }); }; removeEditMode: RemoveEditMode = action => { const { navigation } = this.props; if (!navigation || this.state.isExitingDuringEditMode) { return 'ignore_action'; } if (!this.isMessageEdited()) { this.unblockNavigation(); return 'reduce_action'; } const unblockAndDispatch = () => { this.unblockNavigation(); navigation.dispatch(action); }; const onContinueEditing = () => { this.props.overlayContext?.resetScrollBlockingModalStatus(); }; exitEditAlert({ onDiscard: unblockAndDispatch, onContinueEditing, }); return 'ignore_action'; }; blockNavigation = () => { const { navigation } = this.props; if (!navigation || !navigation.isFocused()) { return; } navigation.setParams({ removeEditMode: this.removeEditMode, }); }; editMessage = async (messageID: string, text: string) => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } text = trimMessage(text); try { await this.props.editMessage(messageID, text); this.exitEditMode(); } catch (error) { Alert.alert( 'Couldn’t edit the message', 'Please try again later', [{ text: 'OK' }], { cancelable: true, }, ); } }; getEditedMessage = (): ?MessageInfo => { const editState = this.props.messageEditingContext?.editState; return editState?.editedMessage; }; onPressExitEditMode = () => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } exitEditAlert({ onDiscard: this.exitEditMode, }); }; scrollToEditedMessage = () => { const editedMessage = this.getEditedMessage(); if (!editedMessage) { return; } const editedMessageKey = messageKey(editedMessage); this.props.inputState?.scrollToMessage(editedMessageKey); }; exitEditMode = () => { this.props.messageEditingContext?.setEditedMessage(null, () => { this.unblockNavigation(); this.updateText(this.props.draft); this.focusAndUpdateButtonsVisibility(); this.updateSendButton(this.props.draft); }); }; onNavigationFocus = () => { this.setState({ isExitingDuringEditMode: false }); }; onNavigationBlur = () => { if (!this.isEditMode()) { return; } this.setState( { text: this.props.draft, isExitingDuringEditMode: true }, this.exitEditMode, ); }; onNavigationBeforeRemove = (e: { +data: { +action: GenericNavigationAction }, +preventDefault: () => void, ... }) => { if (!this.isEditMode()) { return; } const { action } = e.data; e.preventDefault(); const saveExit = () => { this.props.messageEditingContext?.setEditedMessage(null, () => { this.setState({ isExitingDuringEditMode: true }, () => { if (!this.props.navigation) { return; } this.props.navigation.dispatch(action); }); }); }; if (!this.isMessageEdited()) { saveExit(); return; } exitEditAlert({ onDiscard: saveExit, }); }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction(): Promise { 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 || this.isEditMode()) { 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 joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const createThreadLoadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); type ConnectedChatInputBarBaseProps = { ...BaseProps, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +navigation?: ChatNavigationProp<'MessageList'>, }; function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) { const navContext = React.useContext(NavContext); const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const overlayContext = React.useContext(OverlayContext); 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 = useNextLocalID(); 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 = useJoinThread(); const userSearchIndex = useSelector(userStoreMentionSearchIndex); const { getChatMentionSearchIndex } = useChatMentionContext(); const chatMentionSearchIndex = getChatMentionSearchIndex(props.threadInfo); const { parentThreadID } = props.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const userMentionsCandidates = getUserMentionsCandidates( props.threadInfo, parentThreadInfo, ); const chatMentionCandidates = useThreadChatMentionCandidates( props.threadInfo, ); const messageEditingContext = React.useContext(MessageEditingContext); const editedMessageInfo = messageEditingContext?.editState.editedMessage; const editedMessagePreview = useMessagePreview( editedMessageInfo, props.threadInfo, getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); const editMessage = useEditMessage(); 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 { navigation, route, ...restProps } = props; const keyboardState = React.useContext(KeyboardContext); const { threadInfo } = props; const imagePastedCallback = React.useCallback( (imagePastedEvent: ImagePasteEvent) => { 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<'ChatCameraModal'>({ name: ChatCameraModalRouteName, params: { presentedFrom: route.key, thread: threadInfo, }, }); }, [keyboardState, navigation, route.key, threadInfo]); return ( ); }); export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar }; diff --git a/native/chat/chat-router.js b/native/chat/chat-router.js index f81b9701b..bf1a8f562 100644 --- a/native/chat/chat-router.js +++ b/native/chat/chat-router.js @@ -1,192 +1,194 @@ // @flow import type { StackAction, Route, Router, StackRouterOptions, StackNavigationState, RouterConfigOptions, GenericNavigationAction, } from '@react-navigation/core'; import { StackRouter, CommonActions } from '@react-navigation/native'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { createNavigateToThreadAction } from './message-list-types.js'; import { clearScreensActionType, replaceWithThreadActionType, clearThreadsActionType, pushNewThreadActionType, } from '../navigation/action-types.js'; import { getRemoveEditMode } from '../navigation/nav-selectors.js'; import { removeScreensFromStack, getThreadIDFromRoute, } from '../navigation/navigation-utils.js'; import { ChatThreadListRouteName, ComposeSubchannelRouteName, } from '../navigation/route-names.js'; type ClearScreensAction = { +type: 'CLEAR_SCREENS', +payload: { +routeNames: $ReadOnlyArray, }, }; type ReplaceWithThreadAction = { +type: 'REPLACE_WITH_THREAD', +payload: { - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, }, }; type ClearThreadsAction = { +type: 'CLEAR_THREADS', +payload: { +threadIDs: $ReadOnlyArray, }, }; type PushNewThreadAction = { +type: 'PUSH_NEW_THREAD', +payload: { - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, }, }; export type ChatRouterNavigationAction = | StackAction | ClearScreensAction | ReplaceWithThreadAction | ClearThreadsAction | PushNewThreadAction; export type ChatRouterNavigationHelpers = { +clearScreens: (routeNames: $ReadOnlyArray) => void, +replaceWithThread: ( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ) => void, +clearThreads: (threadIDs: $ReadOnlyArray) => void, - +pushNewThread: (threadInfo: ThreadInfo | MinimallyEncodedThreadInfo) => void, + +pushNewThread: ( + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + ) => void, }; function ChatRouter( routerOptions: StackRouterOptions, ): Router { const { getStateForAction: baseGetStateForAction, actionCreators: baseActionCreators, shouldActionChangeFocus: baseShouldActionChangeFocus, ...rest } = StackRouter(routerOptions); return { ...rest, getStateForAction: ( lastState: StackNavigationState, action: ChatRouterNavigationAction, options: RouterConfigOptions, ) => { if (action.type === clearScreensActionType) { const { routeNames } = action.payload; if (!lastState) { return lastState; } return removeScreensFromStack(lastState, (route: Route<>) => routeNames.includes(route.name) ? 'remove' : 'keep', ); } else if (action.type === replaceWithThreadActionType) { const { threadInfo } = action.payload; if (!lastState) { return lastState; } const clearedState = removeScreensFromStack( lastState, (route: Route<>) => route.name === ChatThreadListRouteName ? 'keep' : 'remove', ); const navigateAction = CommonActions.navigate( createNavigateToThreadAction({ threadInfo }), ); return baseGetStateForAction(clearedState, navigateAction, options); } else if (action.type === clearThreadsActionType) { const threadIDs = new Set(action.payload.threadIDs); if (!lastState) { return lastState; } return removeScreensFromStack(lastState, (route: Route<>) => { const threadID = getThreadIDFromRoute(route); if (!threadID) { return 'keep'; } return threadIDs.has(threadID) ? 'remove' : 'keep'; }); } else if (action.type === pushNewThreadActionType) { const { threadInfo } = action.payload; if (!lastState) { return lastState; } const clearedState = removeScreensFromStack( lastState, (route: Route<>) => route.name === ComposeSubchannelRouteName ? 'remove' : 'break', ); const navigateAction = CommonActions.navigate( createNavigateToThreadAction({ threadInfo }), ); return baseGetStateForAction(clearedState, navigateAction, options); } else { const result = baseGetStateForAction(lastState, action, options); const removeEditMode = getRemoveEditMode(lastState); // We prevent navigating if the user is in edit mode. We don't block // navigating back here because it is handled by the `beforeRemove` // listener in the `ChatInputBar` component. if ( result !== null && result?.index && result.index > lastState.index && removeEditMode && removeEditMode(action) === 'ignore_action' ) { return lastState; } return result; } }, actionCreators: { ...baseActionCreators, clearScreens: (routeNames: $ReadOnlyArray) => ({ type: clearScreensActionType, payload: { routeNames, }, }), - replaceWithThread: (threadInfo: ThreadInfo) => + replaceWithThread: (threadInfo: LegacyThreadInfo) => ({ type: replaceWithThreadActionType, payload: { threadInfo }, }: ReplaceWithThreadAction), clearThreads: (threadIDs: $ReadOnlyArray) => ({ type: clearThreadsActionType, payload: { threadIDs }, }), - pushNewThread: (threadInfo: ThreadInfo) => + pushNewThread: (threadInfo: LegacyThreadInfo) => ({ type: pushNewThreadActionType, payload: { threadInfo }, }: PushNewThreadAction), }, shouldActionChangeFocus: (action: GenericNavigationAction) => { if (action.type === replaceWithThreadActionType) { return true; } else if (action.type === pushNewThreadActionType) { return true; } else { return baseShouldActionChangeFocus(action); } }, }; } export default ChatRouter; diff --git a/native/chat/chat-thread-list-item.react.js b/native/chat/chat-thread-list-item.react.js index 0bbe08130..7eeeda098 100644 --- a/native/chat/chat-thread-list-item.react.js +++ b/native/chat/chat-thread-list-item.react.js @@ -1,297 +1,297 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react.js'; import ChatThreadListSidebar from './chat-thread-list-sidebar.react.js'; import MessagePreview from './message-preview.react.js'; import SwipeableThread from './swipeable-thread.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import Button from '../components/button.react.js'; import SingleLine from '../components/single-line.react.js'; import ThreadAncestorsLabel from '../components/thread-ancestors-label.react.js'; import UnreadDot from '../components/unread-dot.react.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { +data: ChatThreadItem, +onPressItem: ( - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, pendingPersonalThreadUserInfo?: UserInfo, ) => void, - +onPressSeeMoreSidebars: (threadInfo: ThreadInfo) => void, - +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, + +onPressSeeMoreSidebars: (threadInfo: LegacyThreadInfo) => void, + +onSwipeableWillOpen: (threadInfo: LegacyThreadInfo) => void, +currentlyOpenedSwipeableId: string, }; function ChatThreadListItem({ data, onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, currentlyOpenedSwipeableId, }: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); const lastMessage = React.useMemo(() => { const mostRecentMessageInfo = data.mostRecentMessageInfo; if (!mostRecentMessageInfo) { return ( No messages ); } return ( ); }, [data.mostRecentMessageInfo, data.threadInfo, styles]); const numOfSidebarsWithExtendedArrow = data.sidebars.filter(sidebarItem => sidebarItem.type === 'sidebar').length - 1; const sidebars = React.useMemo( () => data.sidebars.map((sidebarItem, index) => { if (sidebarItem.type === 'sidebar') { const { type, ...sidebarInfo } = sidebarItem; return ( ); } else if (sidebarItem.type === 'seeMore') { return ( ); } else { return ; } }), [ currentlyOpenedSwipeableId, data.sidebars, data.threadInfo, numOfSidebarsWithExtendedArrow, onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, styles.spacer, ], ); const onPress = React.useCallback(() => { onPressItem(data.threadInfo, data.pendingPersonalThreadUserInfo); }, [onPressItem, data.threadInfo, data.pendingPersonalThreadUserInfo]); const threadNameStyle = React.useMemo(() => { if (!data.threadInfo.currentUser.unread) { return styles.threadName; } return [styles.threadName, styles.unreadThreadName]; }, [ data.threadInfo.currentUser.unread, styles.threadName, styles.unreadThreadName, ]); const lastActivity = shortAbsoluteDate(data.lastUpdatedTime); const lastActivityStyle = React.useMemo(() => { if (!data.threadInfo.currentUser.unread) { return styles.lastActivity; } return [styles.lastActivity, styles.unreadLastActivity]; }, [ data.threadInfo.currentUser.unread, styles.lastActivity, styles.unreadLastActivity, ]); const resolvedThreadInfo = useResolvedThreadInfo(data.threadInfo); const unreadDot = React.useMemo( () => ( ), [data.threadInfo.currentUser.unread, styles.avatarContainer], ); const threadAvatar = React.useMemo( () => ( ), [data.threadInfo, styles.avatarContainer], ); const threadDetails = React.useMemo( () => ( {resolvedThreadInfo.uiName} {lastMessage} {lastActivity} ), [ data.threadInfo, lastActivity, lastActivityStyle, lastMessage, resolvedThreadInfo.uiName, styles.row, styles.threadDetails, threadNameStyle, ], ); const swipeableThreadContent = React.useMemo( () => ( ), [ colors.listIosHighlightUnderlay, onPress, styles.container, styles.content, threadAvatar, threadDetails, unreadDot, ], ); const swipeableThread = React.useMemo( () => ( {swipeableThreadContent} ), [ currentlyOpenedSwipeableId, data.mostRecentNonLocalMessage, data.threadInfo, onSwipeableWillOpen, swipeableThreadContent, ], ); const chatThreadListItem = React.useMemo( () => ( <> {swipeableThread} {sidebars} ), [sidebars, swipeableThread], ); return chatThreadListItem; } const chatThreadListItemHeight = 70; const spacerHeight = 6; const unboundStyles = { container: { height: chatThreadListItemHeight, justifyContent: 'center', backgroundColor: 'listBackground', }, content: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, avatarContainer: { marginLeft: 6, marginBottom: 12, }, threadDetails: { paddingLeft: 12, paddingRight: 18, justifyContent: 'center', flex: 1, marginTop: 5, }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize: 14, marginLeft: 10, }, unreadLastActivity: { color: 'listForegroundLabel', fontWeight: 'bold', }, noMessages: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize: 14, fontStyle: 'italic', }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, threadName: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 21, }, unreadThreadName: { color: 'listForegroundLabel', fontWeight: '500', }, spacer: { height: spacerHeight, }, }; export { ChatThreadListItem, chatThreadListItemHeight, spacerHeight }; 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 111a3df31..4b6d45e29 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 type { LegacyThreadInfo } 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, + +threadInfo: LegacyThreadInfo, +unread: boolean, - +onPress: (threadInfo: ThreadInfo) => void, + +onPress: (threadInfo: LegacyThreadInfo) => void, }; function ChatThreadListSeeMoreSidebars(props: Props): React.Node { const { onPress, threadInfo, unread } = props; 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 04db62007..6e22a8542 100644 --- a/native/chat/chat-thread-list-sidebar.react.js +++ b/native/chat/chat-thread-list-sidebar.react.js @@ -1,158 +1,158 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; -import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo, SidebarInfo } from 'lib/types/thread-types.js'; import { SidebarItem, sidebarHeight } from './sidebar-item.react.js'; import SwipeableThread from './swipeable-thread.react.js'; import Button from '../components/button.react.js'; import UnreadDot from '../components/unread-dot.react.js'; import { useColors, useStyles } from '../themes/colors.js'; import ExtendedArrow from '../vectors/arrow-extended.react.js'; import Arrow from '../vectors/arrow.react.js'; type Props = { +sidebarInfo: SidebarInfo, - +onPressItem: (threadInfo: ThreadInfo) => void, - +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, + +onPressItem: (threadInfo: LegacyThreadInfo) => void, + +onSwipeableWillOpen: (threadInfo: LegacyThreadInfo) => 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; const { threadInfo } = sidebarInfo; const onPress = React.useCallback( () => onPressItem(threadInfo), [threadInfo, onPressItem], ); const arrow = React.useMemo(() => { if (extendArrow) { return ( ); } return ( ); }, [extendArrow, styles.arrow, styles.extendedArrow]); const unreadIndicator = React.useMemo( () => ( ), [ sidebarInfo.threadInfo.currentUser.unread, styles.unreadIndicatorContainer, ], ); const sidebarItem = React.useMemo( () => , [sidebarInfo], ); const swipeableThread = React.useMemo( () => ( {sidebarItem} ), [ currentlyOpenedSwipeableId, onSwipeableWillOpen, sidebarInfo.mostRecentNonLocalMessage, sidebarInfo.threadInfo, sidebarItem, styles.swipeableThreadContainer, ], ); const chatThreadListSidebar = React.useMemo( () => ( ), [ arrow, colors.listIosHighlightUnderlay, onPress, styles.sidebar, swipeableThread, unreadIndicator, ], ); return chatThreadListSidebar; } 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 bb37fcb28..a24d8fa3a 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,497 +1,500 @@ // @flow import IonIcon from '@expo/vector-icons/Ionicons.js'; import type { TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, StackNavigationState, StackOptions, StackNavigationEventMap, } from '@react-navigation/core'; import invariant from 'invariant'; import * as React from 'react'; import { View, FlatList, Platform, TouchableWithoutFeedback, BackHandler, TextInput, } from 'react-native'; import { FloatingAction } from 'react-native-floating-action'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { type ChatThreadItem, useFlattenedChatListData, } from 'lib/selectors/chat-selectors.js'; import { createPendingThread, getThreadListSearchResults, useThreadListSearch, } from 'lib/shared/thread-utils.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; import { ChatThreadListItem } from './chat-thread-list-item.react.js'; import ChatThreadListSearch from './chat-thread-list-search.react.js'; import { getItemLayout, keyExtractor } from './chat-thread-list-utils.js'; import type { ChatTopTabsNavigationProp, ChatNavigationProp, } from './chat.react.js'; import { useNavigateToThread } from './message-list-types.js'; import { SidebarListModalRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type NavigationRoute, type ScreenParamList, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { indicatorStyleSelector, useStyles } from '../themes/colors.js'; import type { ScrollEvent } from '../types/react-native.js'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; export 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 | MinimallyEncodedThreadInfo, + threadItem: LegacyThreadInfo | MinimallyEncodedThreadInfo, ) => boolean, +emptyItem?: React.ComponentType<{}>, }; export type SearchStatus = 'inactive' | 'activating' | 'active'; function ChatThreadList(props: BaseProps): React.Node { const boundChatListData = useFlattenedChatListData(); const loggedInUserInfo = useLoggedInUserInfo(); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); const navigateToThread = useNavigateToThread(); const { navigation, route, filterThreads, emptyItem } = props; const [searchText, setSearchText] = React.useState(''); const [searchStatus, setSearchStatus] = React.useState('inactive'); const { threadSearchResults, usersSearchResults } = useThreadListSearch( searchText, loggedInUserInfo?.id, ); const [openedSwipeableID, setOpenedSwipeableID] = React.useState(''); const [numItemsToDisplay, setNumItemsToDisplay] = React.useState(25); const onChangeSearchText = React.useCallback((updatedSearchText: string) => { setSearchText(updatedSearchText); setNumItemsToDisplay(25); }, []); const scrollPos = React.useRef(0); const flatListRef = React.useRef>(); const onScroll = React.useCallback( (event: ScrollEvent) => { const oldScrollPos = scrollPos.current; scrollPos.current = event.nativeEvent.contentOffset.y; if (scrollPos.current !== 0 || oldScrollPos === 0) { return; } if (searchStatus === 'activating') { setSearchStatus('active'); } }, [searchStatus], ); const onSwipeableWillOpen = React.useCallback( - (threadInfo: ThreadInfo) => setOpenedSwipeableID(threadInfo.id), + (threadInfo: LegacyThreadInfo) => setOpenedSwipeableID(threadInfo.id), [], ); const composeThread = React.useCallback(() => { if (!loggedInUserInfo) { return; } const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PRIVATE, members: [loggedInUserInfo], }); navigateToThread({ threadInfo, searching: true }); }, [loggedInUserInfo, navigateToThread]); const onSearchFocus = React.useCallback(() => { if (searchStatus !== 'inactive') { return; } if (scrollPos.current === 0) { setSearchStatus('active'); } else { setSearchStatus('activating'); } }, [searchStatus]); const clearSearch = React.useCallback(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } setSearchStatus('inactive'); }, []); const onSearchBlur = React.useCallback(() => { if (searchStatus !== 'active') { return; } clearSearch(); }, [clearSearch, searchStatus]); const onSearchCancel = React.useCallback(() => { onChangeSearchText(''); clearSearch(); }, [clearSearch, onChangeSearchText]); const searchInputRef = React.useRef>(); const onPressItem = React.useCallback( - (threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo) => { + ( + threadInfo: LegacyThreadInfo, + pendingPersonalThreadUserInfo?: UserInfo, + ) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigateToThread({ threadInfo, pendingPersonalThreadUserInfo }); }, [navigateToThread, onChangeSearchText], ); const onPressSeeMoreSidebars = React.useCallback( - (threadInfo: ThreadInfo) => { + (threadInfo: LegacyThreadInfo) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigation.navigate<'SidebarListModal'>({ name: SidebarListModalRouteName, params: { threadInfo }, }); }, [navigation, onChangeSearchText], ); const hardwareBack = React.useCallback(() => { if (!navigation.isFocused()) { return false; } const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; if (!isActiveOrActivating) { return false; } onSearchCancel(); return true; }, [navigation, onSearchCancel, searchStatus]); const searchItem = React.useMemo( () => ( ), [ onChangeSearchText, onSearchBlur, onSearchCancel, onSearchFocus, searchStatus, searchText, styles.searchContainer, ], ); const renderItem = React.useCallback( (row: { item: Item, ... }) => { const item = row.item; if (item.type === 'search') { return searchItem; } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }, [ onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, openedSwipeableID, searchItem, ], ); const listData: $ReadOnlyArray = React.useMemo(() => { const chatThreadItems = getThreadListSearchResults( boundChatListData, searchText, filterThreads, threadSearchResults, usersSearchResults, loggedInUserInfo, ); 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; }, [ boundChatListData, emptyItem, filterThreads, loggedInUserInfo, searchStatus, searchText, threadSearchResults, usersSearchResults, ]); const partialListData: $ReadOnlyArray = React.useMemo( () => listData.slice(0, numItemsToDisplay), [listData, numItemsToDisplay], ); const onEndReached = React.useCallback(() => { if (partialListData.length === listData.length) { return; } setNumItemsToDisplay(prevNumItems => prevNumItems + 25); }, [listData.length, partialListData.length]); const floatingAction = React.useMemo(() => { if (Platform.OS !== 'android') { return null; } return ( ); }, [composeThread]); const fixedSearch = React.useMemo(() => { if (searchStatus !== 'active') { return null; } return ( ); }, [ onChangeSearchText, onSearchBlur, onSearchCancel, searchStatus, searchText, styles.searchContainer, ]); const scrollEnabled = searchStatus === 'inactive' || searchStatus === 'active'; // viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem const viewerID = loggedInUserInfo?.id; const extraData = `${viewerID || ''} ${openedSwipeableID}`; const chatThreadList = React.useMemo( () => ( {fixedSearch} {floatingAction} ), [ extraData, fixedSearch, floatingAction, indicatorStyle, onEndReached, onScroll, partialListData, renderItem, scrollEnabled, styles.container, styles.flatList, ], ); const onTabPress = React.useCallback(() => { if (!navigation.isFocused()) { return; } if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } else if (route.name === BackgroundChatThreadListRouteName) { navigation.navigate({ name: HomeChatThreadListRouteName }); } }, [navigation, route.name]); React.useEffect(() => { const clearNavigationBlurListener = navigation.addListener('blur', () => { setNumItemsToDisplay(25); }); return () => { // `.addListener` returns function that can be called to unsubscribe. // https://reactnavigation.org/docs/navigation-events/#navigationaddlistener clearNavigationBlurListener(); }; }, [navigation]); React.useEffect(() => { const chatNavigation = navigation.getParent< ScreenParamList, 'ChatThreadList', StackNavigationState, StackOptions, StackNavigationEventMap, ChatNavigationProp<'ChatThreadList'>, >(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation = chatNavigation.getParent< ScreenParamList, 'Chat', TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, TabNavigationProp<'Chat'>, >(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', onTabPress); return () => { tabNavigation.removeListener('tabPress', onTabPress); }; }, [navigation, onTabPress]); React.useEffect(() => { BackHandler.addEventListener('hardwareBackPress', hardwareBack); return () => { BackHandler.removeEventListener('hardwareBackPress', hardwareBack); }; }, [hardwareBack]); React.useEffect(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } }, [searchText]); const isSearchActivating = searchStatus === 'activating'; React.useEffect(() => { if (isSearchActivating && scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } }, [isSearchActivating]); return chatThreadList; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, searchContainer: { backgroundColor: 'listBackground', display: 'flex', justifyContent: 'center', flexDirection: 'row', }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; export default ChatThreadList; diff --git a/native/chat/compose-subchannel.react.js b/native/chat/compose-subchannel.react.js index 661ae9899..08b283289 100644 --- a/native/chat/compose-subchannel.react.js +++ b/native/chat/compose-subchannel.react.js @@ -1,387 +1,390 @@ // @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 } from 'react-native'; import { newThreadActionTypes, useNewThread, } 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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadType, threadTypes } from 'lib/types/thread-types-enum.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { 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, type BaseTagInput, } 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'; import Alert from '../utils/alert.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 | MinimallyEncodedThreadInfo, + +parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; 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 tagInputRef = React.useRef>(); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setUsernameInputText(''); tagInputRef.current?.focus(); }, []); const waitingOnThreadIDRef = React.useRef(); const { threadType, parentThreadInfo } = props.route.params; const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); const callNewThread = useNewThread(); 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({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs: userInfoInputIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType, }), [ usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs, parentThreadInfo, communityThreadInfo, threadType, ], ); - const existingThreads: $ReadOnlyArray = 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 existingThreads: $ReadOnlyArray = + React.useMemo(() => { + if (userInfoInputIDs.length === 0) { + return []; + } + return _flow( + _filter( + (threadInfo: LegacyThreadInfo) => + threadInFilterList(threadInfo) && + threadInfo.parentThreadID === parentThreadInfo.id && + userInfoInputIDs.every(userID => userIsMember(threadInfo, userID)), + ), + _sortBy( + ([ + 'members.length', + (threadInfo: LegacyThreadInfo) => (threadInfo.name ? 1 : 0), + ]: $ReadOnlyArray< + string | ((threadInfo: LegacyThreadInfo) => 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( ({ id }: AccountUserInfo) => { if (userInfoInputIDs.some(existingUserID => id === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); 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); export default MemoizedComposeSubchannel; diff --git a/native/chat/failed-send.react.js b/native/chat/failed-send.react.js index d7c9ac35a..f444461cc 100644 --- a/native/chat/failed-send.react.js +++ b/native/chat/failed-send.react.js @@ -1,177 +1,177 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { messageID } from 'lib/shared/message-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { assertComposableRawMessage } from 'lib/types/message-types.js'; import type { RawComposableMessageInfo } from 'lib/types/message-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-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; const unboundStyles = { deliveryFailed: { color: 'listSeparatorLabel', paddingHorizontal: 3, }, failedSendInfo: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', marginRight: 20, paddingTop: 5, }, retrySend: { paddingHorizontal: 3, }, }; type BaseProps = { +item: ChatMessageInfoItemWithHeight, }; type Props = { ...BaseProps, +rawMessageInfo: ?RawComposableMessageInfo, +styles: $ReadOnly, +inputState: ?InputState, - +parentThreadInfo: ?ThreadInfo, + +parentThreadInfo: ?LegacyThreadInfo, }; 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(): React.Node { 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, this.props.parentThreadInfo, ); }; } 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); const { parentThreadID } = props.item.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); return ( ); }); export { ConnectedFailedSend as FailedSend, failedSendHeight }; diff --git a/native/chat/fullscreen-thread-media-gallery.react.js b/native/chat/fullscreen-thread-media-gallery.react.js index 8b10baea6..ee4eaeb5f 100644 --- a/native/chat/fullscreen-thread-media-gallery.react.js +++ b/native/chat/fullscreen-thread-media-gallery.react.js @@ -1,180 +1,180 @@ // @flow import * as React from 'react'; import { Text, View, TouchableOpacity } from 'react-native'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { ChatNavigationProp } from './chat.react.js'; import ThreadSettingsMediaGallery from './settings/thread-settings-media-gallery.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import type { VerticalBounds } from '../types/layout-types.js'; export type FullScreenThreadMediaGalleryParams = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; const Tabs = { All: 'ALL', Images: 'IMAGES', Videos: 'VIDEOS', }; type FilterBarProps = { +setActiveTab: (tab: string) => void, +activeTab: string, }; function FilterBar(props: FilterBarProps): React.Node { const styles = useStyles(unboundStyles); const { setActiveTab, activeTab } = props; const allTabsOnPress = React.useCallback( () => setActiveTab(Tabs.All), [setActiveTab], ); const imagesTabOnPress = React.useCallback( () => setActiveTab(Tabs.Images), [setActiveTab], ); const videosTabOnPress = React.useCallback( () => setActiveTab(Tabs.Videos), [setActiveTab], ); const tabStyles = (currentTab: string) => currentTab === activeTab ? styles.tabActiveItem : styles.tabItem; return ( {Tabs.All} {Tabs.Images} {Tabs.Videos} ); } type FullScreenThreadMediaGalleryProps = { +navigation: ChatNavigationProp<'FullScreenThreadMediaGallery'>, +route: NavigationRoute<'FullScreenThreadMediaGallery'>, }; function FullScreenThreadMediaGallery( props: FullScreenThreadMediaGalleryProps, ): React.Node { const { threadInfo } = props.route.params; const { id } = threadInfo; const styles = useStyles(unboundStyles); const [activeTab, setActiveTab] = React.useState(Tabs.All); const flatListContainerRef = React.useRef>(); const [verticalBounds, setVerticalBounds] = React.useState(null); const onFlatListContainerLayout = React.useCallback(() => { if (!flatListContainerRef.current) { return; } flatListContainerRef.current.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setVerticalBounds({ height, y: pageY }); }, ); }, [flatListContainerRef]); return ( ); } const unboundStyles = { container: { marginBottom: 120, }, filterBar: { display: 'flex', flexDirection: 'column', alignItems: 'center', marginTop: 20, marginBottom: 40, }, tabNavigator: { display: 'flex', flexDirection: 'row', alignItems: 'flex-start', position: 'absolute', width: '90%', padding: 0, }, tabActiveItem: { display: 'flex', justifyContent: 'center', alignItems: 'center', backgroundColor: 'floatingButtonBackground', flex: 1, height: 30, borderRadius: 8, }, tabItem: { display: 'flex', justifyContent: 'center', alignItems: 'center', backgroundColor: 'listInputBackground', flex: 1, height: 30, }, tabText: { color: 'floatingButtonLabel', }, }; const MemoizedFullScreenMediaGallery: React.ComponentType = React.memo(FullScreenThreadMediaGallery); export default MemoizedFullScreenMediaGallery; diff --git a/native/chat/image-paste-modal.react.js b/native/chat/image-paste-modal.react.js index 2169afdc9..98a800738 100644 --- a/native/chat/image-paste-modal.react.js +++ b/native/chat/image-paste-modal.react.js @@ -1,122 +1,122 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Button, View, Image } from 'react-native'; import filesystem from 'react-native-fs'; import type { PhotoPaste } from 'lib/types/media-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import sleep from 'lib/utils/sleep.js'; import Modal from '../components/modal.react.js'; import { InputStateContext } from '../input/input-state.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; export type ImagePasteModalParams = { +imagePasteStagingInfo: PhotoPaste, - +thread: ThreadInfo | MinimallyEncodedThreadInfo, + +thread: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; const safeAreaEdges = ['top']; type Props = { +navigation: RootNavigationProp<'ImagePasteModal'>, +route: NavigationRoute<'ImagePasteModal'>, }; function ImagePasteModal(props: Props): React.Node { const styles = useStyles(unboundStyles); const inputState = React.useContext(InputStateContext); const { navigation, route: { params: { imagePasteStagingInfo, thread: threadInfo }, }, } = props; const [sendButtonDisabled, setSendButtonDisabled] = React.useState(false); const sendImage = React.useCallback(async () => { setSendButtonDisabled(true); navigation.goBackOnce(); const selection: $ReadOnlyArray = [imagePasteStagingInfo]; invariant(inputState, 'inputState should be set in ImagePasteModal'); await inputState.sendMultimediaMessage(selection, threadInfo); }, [imagePasteStagingInfo, inputState, navigation, threadInfo]); const cancel = React.useCallback(async () => { navigation.goBackOnce(); await sleep(5000); filesystem.unlink(imagePasteStagingInfo.uri); }, [imagePasteStagingInfo.uri, navigation]); const imageSource = React.useMemo( () => ({ uri: imagePasteStagingInfo.uri }), [imagePasteStagingInfo.uri], ); const imagePasteModal = React.useMemo( () => ( ); } onPress = () => { const { threadInfo } = this.props; this.props.navigate<'ThreadSettings'>({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } 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 a26aed24d..c3a3fc688 100644 --- a/native/chat/message-list-types.js +++ b/native/chat/message-list-types.js @@ -1,137 +1,137 @@ // @flow import type { TabAction } from '@react-navigation/core'; import { useNavigation, useNavigationState } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { type UserInfo } from 'lib/types/user-types.js'; import { ChatContext } from './chat-context.js'; import type { ChatRouterNavigationAction } from './chat-router.js'; import type { MarkdownRules } from '../markdown/rules.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; import { MessageListRouteName, TextMessageTooltipModalRouteName, } from '../navigation/route-names.js'; export type MessageListParams = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, +searching?: boolean, +removeEditMode?: ?RemoveEditMode, }; export type RemoveEditMode = ( action: TabAction | ChatRouterNavigationAction, ) => 'ignore_action' | 'reduce_action'; export type MessageListContextType = { +getTextMessageMarkdownRules: (useDarkStyle: boolean) => MarkdownRules, }; const MessageListContext: React.Context = React.createContext(); function useMessageListContext( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ) { const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const getTextMessageMarkdownRules = useTextMessageRulesFunc( threadInfo, chatMentionCandidates, ); return React.useMemo( () => ({ getTextMessageMarkdownRules, }), [getTextMessageMarkdownRules], ); } type Props = { +children: React.Node, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; function MessageListContextProvider(props: Props): React.Node { const context = useMessageListContext(props.threadInfo); 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); } function useNavigateToThreadWithFadeAnimation( - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, messageKey: ?string, ): () => mixed { const chatContext = React.useContext(ChatContext); invariant(chatContext, 'ChatContext should be set'); const { setCurrentTransitionSidebarSourceID: setSidebarSourceID, setSidebarAnimationType, } = chatContext; const navigateToThread = useNavigateToThread(); const navigationStack = useNavigationState(state => state.routes); return React.useCallback(() => { if ( navigationStack[navigationStack.length - 1].name === TextMessageTooltipModalRouteName ) { setSidebarSourceID(messageKey); setSidebarAnimationType('fade_source_message'); } navigateToThread({ threadInfo }); }, [ messageKey, navigateToThread, navigationStack, setSidebarAnimationType, setSidebarSourceID, threadInfo, ]); } export { MessageListContextProvider, createNavigateToThreadAction, useNavigateToThread, useTextMessageMarkdownRules, useNavigateToThreadWithFadeAnimation, }; diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js index 7b80070d2..f11dc5ee6 100644 --- a/native/chat/message-list.react.js +++ b/native/chat/message-list.react.js @@ -1,374 +1,374 @@ // @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, useFetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, useFetchMostRecentMessages, type FetchMostRecentMessagesInput, type FetchMessagesBeforeCursorInput, } from 'lib/actions/message-actions.js'; import { useOldestMessageServerID } from 'lib/hooks/message-hooks.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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, 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'; const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, header: { height: 12, }, listLoadingIndicator: { flex: 1, }, }; type BaseProps = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +messageListData: $ReadOnlyArray, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, +startReached: boolean, +styles: $ReadOnly, +indicatorStyle: IndicatorStyle, +dispatchActionPromise: DispatchActionPromise, +fetchMessagesBeforeCursor: ( input: FetchMessagesBeforeCursorInput, ) => Promise, +fetchMostRecentMessages: ( input: FetchMostRecentMessagesInput, ) => Promise, +overlayContext: ?OverlayContextType, +keyboardState: ?KeyboardState, +oldestMessageServerID: ?string, }; 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: PropsAndState => FlatListExtraData = 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): OverlayContextType { const { overlayContext } = props; invariant(overlayContext, 'MessageList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props): boolean { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } static modalOpen(props: Props): boolean { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus === 'open'; } componentDidUpdate(prevProps: Props) { 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, ... }): React.Node => { if (row.item.itemType === 'loader') { return ( ); } const messageInfoItem: ChatMessageInfoItemWithHeight = row.item; 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 = (): React.Node => ( ); render(): React.Node { 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.props; const threadID = this.props.threadInfo.id; (async () => { try { if (oldestMessageServerID) { await this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor({ threadID, beforeMessageID: oldestMessageServerID, }), ); } else { await this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages({ threadID }), ); } } finally { this.setState({ loadingFromScroll: false }); } })(); }; } registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); 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 = useFetchMessagesBeforeCursor(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); const oldestMessageServerID = useOldestMessageServerID(threadID); useWatchThread(props.threadInfo); return ( ); }); export default ConnectedMessageList; diff --git a/native/chat/message-preview.react.js b/native/chat/message-preview.react.js index c8506f7e1..679b58093 100644 --- a/native/chat/message-preview.react.js +++ b/native/chat/message-preview.react.js @@ -1,93 +1,93 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text } from 'react-native'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { useMessagePreview } from 'lib/shared/message-utils.js'; import { type MessageInfo } from 'lib/types/message-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import SingleLine from '../components/single-line.react.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { +messageInfo: MessageInfo, - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, }; function MessagePreview(props: Props): React.Node { const { messageInfo, threadInfo } = props; const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const messagePreviewResult = useMessagePreview( messageInfo, threadInfo, getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); invariant( messagePreviewResult, 'useMessagePreview should only return falsey if pass null or undefined', ); const { message, username } = messagePreviewResult; let messageStyle; const styles = useStyles(unboundStyles); if (message.style === 'unread') { messageStyle = styles.unread; } else if (message.style === 'primary') { messageStyle = styles.primary; } else if (message.style === 'secondary') { messageStyle = styles.secondary; } invariant( messageStyle, `MessagePreview doesn't support ${message.style} style for message, ` + 'only unread, primary, and secondary', ); if (!username) { return ( {message.text} ); } let usernameStyle; if (username.style === 'unread') { usernameStyle = styles.unread; } else if (username.style === 'secondary') { usernameStyle = styles.secondary; } invariant( usernameStyle, `MessagePreview doesn't support ${username.style} style for username, ` + 'only unread and secondary', ); return ( {`${username.text}: `} {message.text} ); } const unboundStyles = { lastMessage: { flex: 1, fontSize: 14, }, primary: { color: 'listForegroundTertiaryLabel', }, secondary: { color: 'listForegroundTertiaryLabel', }, unread: { color: 'listForegroundLabel', }, }; export default MessagePreview; diff --git a/native/chat/message-result.react.js b/native/chat/message-result.react.js index 9fd709b03..636a264a8 100644 --- a/native/chat/message-result.react.js +++ b/native/chat/message-result.react.js @@ -1,96 +1,96 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import { longAbsoluteDate } from 'lib/utils/date-utils.js'; import { type ChatNavigationProp } from './chat.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import Message from './message.react.js'; import { modifyItemForResultScreen } from './utils.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; type MessageResultProps = { +item: ChatMessageInfoItemWithHeight, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +navigation: | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'MessageResultsScreen'> | ChatNavigationProp<'MessageSearch'>, +route: | NavigationRoute<'TogglePinModal'> | NavigationRoute<'MessageResultsScreen'> | NavigationRoute<'MessageSearch'>, +messageVerticalBounds: ?VerticalBounds, +scrollable: boolean, }; function MessageResult(props: MessageResultProps): React.Node { const styles = useStyles(unboundStyles); const onToggleFocus = React.useCallback(() => {}, []); const item = React.useMemo( () => modifyItemForResultScreen(props.item), [props.item], ); const containerStyle = React.useMemo( () => props.scrollable ? [styles.container, styles.containerOverflow] : styles.container, [props.scrollable, styles.container, styles.containerOverflow], ); return ( {longAbsoluteDate(props.item.messageInfo.time)} ); } const unboundStyles = { container: { marginTop: 5, backgroundColor: 'panelForeground', }, containerOverflow: { overflow: 'scroll', maxHeight: 400, }, viewContainer: { marginTop: 10, marginBottom: 10, }, messageDate: { color: 'messageLabel', fontSize: 12, marginLeft: 55, }, }; export default MessageResult; diff --git a/native/chat/message-results-screen.react.js b/native/chat/message-results-screen.react.js index 155dab97d..30e6d5401 100644 --- a/native/chat/message-results-screen.react.js +++ b/native/chat/message-results-screen.react.js @@ -1,196 +1,196 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useFetchPinnedMessages } from 'lib/actions/message-actions.js'; import { messageListData, type ChatMessageInfoItem, } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo, isInvalidPinSourceForThread, } from 'lib/shared/message-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useHeightMeasurer } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react'; import type { NativeChatMessageItem } from './message-data.react.js'; import MessageResult from './message-result.react.js'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; export type MessageResultsScreenParams = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; type MessageResultsScreenProps = { +navigation: ChatNavigationProp<'MessageResultsScreen'>, +route: NavigationRoute<'MessageResultsScreen'>, }; function MessageResultsScreen(props: MessageResultsScreenProps): React.Node { const { navigation, route } = props; const { threadInfo } = route.params; const styles = useStyles(unboundStyles); const { id: threadID } = threadInfo; const [rawMessageResults, setRawMessageResults] = React.useState< $ReadOnlyArray, >([]); const measureMessages = useHeightMeasurer(); const [measuredMessages, setMeasuredMessages] = React.useState< $ReadOnlyArray, >([]); const [messageVerticalBounds, setMessageVerticalBounds] = React.useState(); const scrollViewContainerRef = React.useRef>(); const callFetchPinnedMessages = useFetchPinnedMessages(); const userInfos = useSelector(state => state.userStore.userInfos); React.useEffect(() => { (async () => { const result = await callFetchPinnedMessages({ threadID }); setRawMessageResults(result.pinnedMessages); })(); }, [callFetchPinnedMessages, threadID]); const translatedMessageResults = React.useMemo(() => { const threadInfos = { [threadID]: threadInfo }; return rawMessageResults .map(messageInfo => createMessageInfo(messageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [rawMessageResults, userInfos, threadID, threadInfo]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedMessageResults), ); const sortedUniqueChatMessageInfoItems: $ReadOnlyArray = React.useMemo(() => { if (!chatMessageInfos) { return []; } const chatMessageInfoItems = chatMessageInfos.filter( item => item.itemType === 'message' && item.isPinned && !isInvalidPinSourceForThread(item.messageInfo, threadInfo), ); // By the nature of using messageListData and passing in // the desired translatedMessageResults as additional // messages, we will have duplicate ChatMessageInfoItems. const uniqueChatMessageInfoItemsMap = new Map< string, ChatMessageInfoItem, >(); chatMessageInfoItems.forEach( item => item.messageInfo && item.messageInfo.id && uniqueChatMessageInfoItemsMap.set(item.messageInfo.id, item), ); // Push the items in the order they appear in the rawMessageResults // since the messages fetched from the server are already sorted // in the order of pin_time (newest first). const sortedChatMessageInfoItems = []; for (let i = 0; i < rawMessageResults.length; i++) { const { id } = rawMessageResults[i]; invariant(id, 'pinned message returned from server should have ID'); sortedChatMessageInfoItems.push(uniqueChatMessageInfoItemsMap.get(id)); } return sortedChatMessageInfoItems.filter(Boolean); }, [chatMessageInfos, rawMessageResults, threadInfo]); const measureCallback = React.useCallback( (listDataWithHeights: $ReadOnlyArray) => { setMeasuredMessages(listDataWithHeights); }, [], ); React.useEffect(() => { measureMessages( sortedUniqueChatMessageInfoItems, threadInfo, measureCallback, ); }, [ measureCallback, measureMessages, sortedUniqueChatMessageInfoItems, threadInfo, ]); const onLayout = React.useCallback(() => { scrollViewContainerRef.current?.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setMessageVerticalBounds({ height, y: pageY }); }, ); }, []); const messageResultsToDisplay = React.useMemo( () => measuredMessages.map(item => { invariant(item.itemType !== 'loader', 'should not be loader'); return ( ); }), [measuredMessages, threadInfo, navigation, route, messageVerticalBounds], ); return ( {messageResultsToDisplay} ); } const unboundStyles = { scrollViewContainer: { flex: 1, }, }; export default MessageResultsScreen; diff --git a/native/chat/parent-thread-header.react.js b/native/chat/parent-thread-header.react.js index c903fb362..b7a7b00c0 100644 --- a/native/chat/parent-thread-header.react.js +++ b/native/chat/parent-thread-header.react.js @@ -1,73 +1,73 @@ // @flow import * as React from 'react'; import { View, Text } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadType } from 'lib/types/thread-types-enum.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useNavigateToThread } from './message-list-types.js'; import Button from '../components/button.react.js'; import CommunityPill from '../components/community-pill.react.js'; import ThreadVisibility from '../components/thread-visibility.react.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { - +parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +childThreadType: ThreadType, }; function ParentThreadHeader(props: Props): React.Node { const colors = useColors(); const threadVisibilityColor = colors.modalForegroundLabel; const styles = useStyles(unboundStyles); const { parentThreadInfo, childThreadType } = props; const navigateToThread = useNavigateToThread(); const onPressParentThread = React.useCallback(() => { navigateToThread({ threadInfo: parentThreadInfo }); }, [parentThreadInfo, navigateToThread]); return ( within ); } const height = 48; const unboundStyles = { container: { height, backgroundColor: 'panelForeground', borderBottomWidth: 1, borderBottomColor: 'panelForegroundBorder', }, contentContainer: { alignItems: 'center', flexDirection: 'row', paddingHorizontal: 12, }, within: { color: 'modalSubtextLabel', fontSize: 16, paddingHorizontal: 6, }, }; export default ParentThreadHeader; diff --git a/native/chat/relationship-prompt.react.js b/native/chat/relationship-prompt.react.js index 242c04d99..9a32f336b 100644 --- a/native/chat/relationship-prompt.react.js +++ b/native/chat/relationship-prompt.react.js @@ -1,168 +1,168 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome5.js'; import * as React from 'react'; import { Text, View } from 'react-native'; import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; import Button from '../components/button.react.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; type Props = { +pendingPersonalThreadUserInfo: ?UserInfo, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; const RelationshipPrompt: React.ComponentType = React.memo( function RelationshipPrompt({ pendingPersonalThreadUserInfo, threadInfo, }: Props) { const onErrorCallback = React.useCallback(() => { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }]); }, []); const { otherUserInfo, callbacks: { blockUser, unblockUser, friendUser, unfriendUser }, } = useRelationshipPrompt( threadInfo, onErrorCallback, pendingPersonalThreadUserInfo, ); const styles = useStyles(unboundStyles); if ( !otherUserInfo || !otherUserInfo.username || otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND ) { return null; } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.BOTH_BLOCKED || otherUserInfo.relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT ) { return ( ); } return ( ); }, ); const unboundStyles = { container: { paddingVertical: 10, paddingHorizontal: 5, backgroundColor: 'panelBackground', flexDirection: 'row', }, button: { padding: 10, borderRadius: 5, flex: 1, flexDirection: 'row', justifyContent: 'center', marginHorizontal: 5, }, greenButton: { backgroundColor: 'vibrantGreenButton', }, redButton: { backgroundColor: 'vibrantRedButton', }, buttonText: { fontSize: 11, color: 'white', fontWeight: 'bold', textAlign: 'center', marginLeft: 5, }, }; export default RelationshipPrompt; diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index 169e4d8ee..8431f6c43 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,300 +1,300 @@ // @flow import * as React from 'react'; import { View, Text, ActivityIndicator } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } 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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import Button from '../../components/button.react.js'; import Modal from '../../components/modal.react.js'; import { createTagInput, type BaseTagInput, } 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'; import Alert from '../../utils/alert.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 | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; 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([]); tagInputRef.current?.focus(); }, []); const { navigation } = props; const { goBackOnce } = navigation; const close = React.useCallback(() => { goBackOnce(); }, [goBackOnce]); const callChangeThreadSettings = useChangeThreadSettings(); 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({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType: 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( ({ id }: AccountUserInfo) => { if (isLoading) { return; } if (userInfoInputIDs.some(existingUserID => id === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); 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); export default MemoizedAddUsersModal; diff --git a/native/chat/settings/color-selector-modal.react.js b/native/chat/settings/color-selector-modal.react.js index dd45a234a..df9f907f9 100644 --- a/native/chat/settings/color-selector-modal.react.js +++ b/native/chat/settings/color-selector-modal.react.js @@ -1,191 +1,191 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import * as React from 'react'; import { TouchableHighlight } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { - type ThreadInfo, + type LegacyThreadInfo, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types.js'; import type { DispatchActionPromise } from 'lib/utils/action-utils.js'; import { 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'; import Alert from '../../utils/alert.js'; export type ColorSelectorModalParams = { +presentedFrom: string, +color: string, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +setColor: (color: string) => void, }; 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, }, }; type BaseProps = { +navigation: RootNavigationProp<'ColorSelectorModal'>, +route: NavigationRoute<'ColorSelectorModal'>, }; type Props = { ...BaseProps, // Redux state +colors: Colors, +styles: $ReadOnly, +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(); const action = changeThreadSettingsActionTypes.started; const threadID = props.route.params.threadInfo.id; dispatchActionPromise( changeThreadSettingsActionTypes, editColor(colorEditValue), { customKeyName: `${action}:${threadID}:color`, }, ); }, [ setColor, close, dispatchActionPromise, editColor, props.route.params.threadInfo.id, ], ); 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 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 = useChangeThreadSettings(); 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 640d5e579..2c30e2c1c 100644 --- a/native/chat/settings/compose-subchannel-modal.react.js +++ b/native/chat/settings/compose-subchannel-modal.react.js @@ -1,153 +1,153 @@ // @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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } 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 | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; 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, }, }; type BaseProps = { +navigation: RootNavigationProp<'ComposeSubchannelModal'>, +route: NavigationRoute<'ComposeSubchannelModal'>, }; type Props = { ...BaseProps, +colors: Colors, +styles: $ReadOnly, }; class ComposeSubchannelModal extends React.PureComponent { render(): React.Node { 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 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 6b67ed04e..9c77101ea 100644 --- a/native/chat/settings/delete-thread.react.js +++ b/native/chat/settings/delete-thread.react.js @@ -1,297 +1,297 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput as BaseTextInput, ActivityIndicator, } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { deleteThreadActionTypes, useDeleteThread, } from 'lib/actions/thread-actions.js'; import type { DeleteThreadInput } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector, containedThreadInfos, } from 'lib/selectors/thread-selectors.js'; import { identifyInvalidatedThreads, getThreadsToDeleteText, } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MinimallyEncodedResolvedThreadInfo, MinimallyEncodedThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { - ThreadInfo, + LegacyThreadInfo, ResolvedThreadInfo, LeaveThreadPayload, } from 'lib/types/thread-types.js'; import { 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 Alert from '../../utils/alert.js'; import type { ChatNavigationProp } from '../chat.react.js'; export type DeleteThreadParams = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; 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', }, }; type BaseProps = { +navigation: ChatNavigationProp<'DeleteThread'>, +route: NavigationRoute<'DeleteThread'>, }; type Props = { ...BaseProps, // Redux state +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +shouldUseDeleteConfirmationAlert: boolean, +loadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +deleteThread: (input: DeleteThreadInput) => Promise, // withNavContext +navDispatch: (action: NavAction) => void, }; class DeleteThread extends React.PureComponent { mounted = false; passwordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render(): React.Node { 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(); }; dispatchDeleteThreadAction = () => { this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThread(), ); }; submitDeletion = () => { if (!this.props.shouldUseDeleteConfirmationAlert) { this.dispatchDeleteThreadAction(); return; } Alert.alert( 'Warning', `${getThreadsToDeleteText( this.props.threadInfo, )} will also be permanently deleted.`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Continue', onPress: this.dispatchDeleteThreadAction }, ], { cancelable: false }, ); }; async deleteThread(): Promise { const { threadInfo, navDispatch } = this.props; navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadInfo.id] }, }); try { const result = await this.props.deleteThread({ threadID: threadInfo.id }); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Permission not granted', 'You do not have permission to delete this thread', [{ text: 'OK' }], { cancelable: false }, ); } else { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); } throw e; } } } const loadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); const ConnectedDeleteThread: React.ComponentType = React.memo(function ConnectedDeleteThread(props: BaseProps) { const threadID = props.route.params.threadInfo.id; const reduxThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); const reduxContainedThreadInfos = useSelector( state => containedThreadInfos(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 colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteThread = useDeleteThread(); const navContext = React.useContext(NavContext); invariant(navContext, 'NavContext should be set in DeleteThread'); const navDispatch = navContext.dispatch; const shouldUseDeleteConfirmationAlert = reduxContainedThreadInfos && reduxContainedThreadInfos.length > 0; return ( ); }); export default ConnectedDeleteThread; diff --git a/native/chat/settings/emoji-thread-avatar-creation.react.js b/native/chat/settings/emoji-thread-avatar-creation.react.js index f813f94e2..b15a0ba94 100644 --- a/native/chat/settings/emoji-thread-avatar-creation.react.js +++ b/native/chat/settings/emoji-thread-avatar-creation.react.js @@ -1,61 +1,64 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { savedEmojiAvatarSelectorForThread } from 'lib/selectors/thread-selectors.js'; import type { UpdateUserAvatarRequest } from 'lib/types/avatar-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; +import type { + RawThreadInfo, + LegacyThreadInfo, +} from 'lib/types/thread-types.js'; import { useNativeSetThreadAvatar } from '../../avatars/avatar-hooks.js'; import EmojiAvatarCreation from '../../avatars/emoji-avatar-creation.react.js'; import type { ChatNavigationProp } from '../../chat/chat.react.js'; import { displayActionResultModal } from '../../navigation/action-result-modal.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; export type EmojiThreadAvatarCreationParams = { - +threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { +navigation: ChatNavigationProp<'EmojiThreadAvatarCreation'>, +route: NavigationRoute<'EmojiThreadAvatarCreation'>, }; function EmojiThreadAvatarCreation(props: Props): React.Node { const { id: threadID, containingThreadID } = props.route.params.threadInfo; const selector = savedEmojiAvatarSelectorForThread( threadID, containingThreadID, ); const savedEmojiAvatarFunc = useSelector(selector); const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { threadAvatarSaveInProgress } = editThreadAvatarContext; const nativeSetThreadAvatar = useNativeSetThreadAvatar(); const setAvatar = React.useCallback( async (avatarRequest: UpdateUserAvatarRequest) => { const result = await nativeSetThreadAvatar(threadID, avatarRequest); displayActionResultModal('Avatar updated!'); return result; }, [nativeSetThreadAvatar, threadID], ); return ( ); } export default EmojiThreadAvatarCreation; diff --git a/native/chat/settings/thread-settings-child-thread.react.js b/native/chat/settings/thread-settings-child-thread.react.js index 73029bd2d..a6288373c 100644 --- a/native/chat/settings/thread-settings-child-thread.react.js +++ b/native/chat/settings/thread-settings-child-thread.react.js @@ -1,84 +1,84 @@ // @flow import * as React from 'react'; import { View, Platform } from 'react-native'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import ThreadAvatar from '../../avatars/thread-avatar.react.js'; import Button from '../../components/button.react.js'; import ThreadIcon from '../../components/thread-icon.react.js'; import ThreadPill from '../../components/thread-pill.react.js'; import { useColors, useStyles } from '../../themes/colors.js'; import { useNavigateToThread } from '../message-list-types.js'; type Props = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +firstListItem: boolean, +lastListItem: boolean, }; function ThreadSettingsChildThread(props: Props): React.Node { const { threadInfo } = props; const navigateToThread = useNavigateToThread(); const onPress = React.useCallback(() => { navigateToThread({ threadInfo }); }, [threadInfo, navigateToThread]); const styles = useStyles(unboundStyles); const colors = useColors(); const firstItem = props.firstListItem ? null : styles.topBorder; const lastItem = props.lastListItem ? styles.lastButton : null; return ( ); } const unboundStyles = { avatarContainer: { marginRight: 8, }, button: { flex: 1, flexDirection: 'row', paddingVertical: 8, paddingLeft: 12, paddingRight: 10, alignItems: 'center', }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 12, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, paddingTop: 8, }, leftSide: { flex: 1, flexDirection: 'row', alignItems: 'center', }, }; export default ThreadSettingsChildThread; diff --git a/native/chat/settings/thread-settings-color.react.js b/native/chat/settings/thread-settings-color.react.js index 1673c737d..f09124b46 100644 --- a/native/chat/settings/thread-settings-color.react.js +++ b/native/chat/settings/thread-settings-color.react.js @@ -1,126 +1,126 @@ // @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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } 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'; const unboundStyles = { colorLine: { lineHeight: (Platform.select({ android: 22, default: 25 }): number), }, colorRow: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingBottom: 8, paddingHorizontal: 24, paddingTop: 4, }, currentValue: { flex: 1, paddingLeft: 4, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, }; type BaseProps = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +colorEditValue: string, +setColorEditValue: (color: string) => void, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, }; class ThreadSettingsColor extends React.PureComponent { render(): React.Node { 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 ConnectedThreadSettingsColor: React.ComponentType = React.memo(function ConnectedThreadSettingsColor( props: BaseProps, ) { const threadID = props.threadInfo.id; const loadingStatus = useSelector( createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:color`, ), ); 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 5a3fe8432..d9ec0b691 100644 --- a/native/chat/settings/thread-settings-description.react.js +++ b/native/chat/settings/thread-settings-description.react.js @@ -1,325 +1,325 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, ActivityIndicator, TextInput as BaseTextInput, View, } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } 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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { - type ThreadInfo, + type LegacyThreadInfo, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, 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'; import Alert from '../../utils/alert.js'; 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', }, }; type BaseProps = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +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: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, }; class ThreadSettingsDescription extends React.PureComponent { textInput: ?React.ElementRef; render(): React.Node { if ( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined ) { const textInputStyle: { height?: number } = {}; if ( this.props.descriptionTextHeight !== undefined && this.props.descriptionTextHeight !== null ) { textInputStyle.height = this.props.descriptionTextHeight; } return ( {this.renderButton()} ); } if (this.props.threadInfo.description) { return ( {this.props.threadInfo.description} {this.renderButton()} ); } const canEditThreadDescription = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const { panelIosHighlightUnderlay } = this.props.colors; if (canEditThreadDescription) { return ( ); } return null; } renderButton(): React.Node { if (this.props.loadingStatus === 'loading') { return ( ); } else if ( this.props.descriptionEditValue === null || this.props.descriptionEditValue === undefined ) { return ( ); } return ; } 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); const action = changeThreadSettingsActionTypes.started; const threadID = this.props.threadInfo.id; this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editDescriptionPromise, { customKeyName: `${action}:${threadID}:description`, }, ); editDescriptionPromise.then(() => { this.props.setDescriptionEditValue(null); }); }; async editDescription( newDescription: string, ): Promise { 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 ConnectedThreadSettingsDescription: React.ComponentType = React.memo(function ConnectedThreadSettingsDescription( props: BaseProps, ) { const threadID = props.threadInfo.id; const loadingStatus = useSelector( createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:description`, ), ); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); 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 82e192a73..7ff2526f7 100644 --- a/native/chat/settings/thread-settings-edit-relationship.react.js +++ b/native/chat/settings/thread-settings-edit-relationship.react.js @@ -1,130 +1,130 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { 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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type RelationshipAction, type RelationshipButton, } from 'lib/types/relationship-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } 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'; import Alert from '../../utils/alert.js'; type Props = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, }; 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 7ef4c33e2..1b2814632 100644 --- a/native/chat/settings/thread-settings-home-notifs.react.js +++ b/native/chat/settings/thread-settings-home-notifs.react.js @@ -1,117 +1,117 @@ // @flow import * as React from 'react'; import { View, Switch } from 'react-native'; import { updateSubscriptionActionTypes, useUpdateSubscription, } from 'lib/actions/user-actions.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { DispatchActionPromise } from 'lib/utils/action-utils.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import SingleLine from '../../components/single-line.react.js'; import { useStyles } from '../../themes/colors.js'; 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, }, }; type BaseProps = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { ...BaseProps, // Redux state +styles: $ReadOnly, // 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(): React.Node { 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 ConnectedThreadSettingsHomeNotifs: React.ComponentType = React.memo(function ConnectedThreadSettingsHomeNotifs( props: BaseProps, ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useUpdateSubscription(); 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 dba089ad6..306955f05 100644 --- a/native/chat/settings/thread-settings-leave-thread.react.js +++ b/native/chat/settings/thread-settings-leave-thread.react.js @@ -1,185 +1,188 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, ActivityIndicator, View } from 'react-native'; import { leaveThreadActionTypes, useLeaveThread, } from 'lib/actions/thread-actions.js'; import type { LeaveThreadInput } 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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo, LeaveThreadPayload } from 'lib/types/thread-types.js'; +import type { + LegacyThreadInfo, + LeaveThreadPayload, +} from 'lib/types/thread-types.js'; import { type DispatchActionPromise, 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'; import Alert from '../../utils/alert.js'; const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'redText', flex: 1, fontSize: 16, }, }; type BaseProps = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +buttonStyle: ViewStyle, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +otherUsersButNoOtherAdmins: boolean, +colors: Colors, +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +leaveThread: (input: LeaveThreadInput) => Promise, // withNavContext +navContext: ?NavContextType, }; class ThreadSettingsLeaveThread extends React.PureComponent { render(): React.Node { 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 = () => { const threadID = this.props.threadInfo.id; this.props.dispatchActionPromise( leaveThreadActionTypes, this.leaveThread(), { customKeyName: `${leaveThreadActionTypes.started}:${threadID}`, }, ); }; async leaveThread(): Promise { 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 ConnectedThreadSettingsLeaveThread: React.ComponentType = React.memo(function ConnectedThreadSettingsLeaveThread( props: BaseProps, ) { const threadID = props.threadInfo.id; const loadingStatus = useSelector( createLoadingStatusSelector( leaveThreadActionTypes, `${leaveThreadActionTypes.started}:${threadID}`, ), ); const otherUsersButNoOtherAdminsValue = useSelector( otherUsersButNoOtherAdmins(props.threadInfo.id), ); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callLeaveThread = useLeaveThread(); const navContext = React.useContext(NavContext); return ( ); }); export default ConnectedThreadSettingsLeaveThread; diff --git a/native/chat/settings/thread-settings-member-tooltip-modal.react.js b/native/chat/settings/thread-settings-member-tooltip-modal.react.js index a87bdf43e..b87681baa 100644 --- a/native/chat/settings/thread-settings-member-tooltip-modal.react.js +++ b/native/chat/settings/thread-settings-member-tooltip-modal.react.js @@ -1,112 +1,115 @@ // @flow import * as React from 'react'; import { useRemoveUsersFromThread } from 'lib/actions/thread-actions.js'; import { removeMemberFromThread } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo, RelativeMemberInfo } from 'lib/types/thread-types.js'; +import type { + LegacyThreadInfo, + RelativeMemberInfo, +} from 'lib/types/thread-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import ThreadSettingsMemberTooltipButton from './thread-settings-member-tooltip-button.react.js'; import type { AppNavigationProp } from '../../navigation/app-navigator.react'; import { ChangeRolesScreenRouteName } from '../../navigation/route-names.js'; import { createTooltip, type TooltipParams, type TooltipRoute, type BaseTooltipProps, type TooltipMenuProps, } from '../../tooltip/tooltip.react.js'; import Alert from '../../utils/alert.js'; export type ThreadSettingsMemberTooltipModalParams = TooltipParams<{ +memberInfo: RelativeMemberInfo, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }>; function useOnRemoveUser( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, ) { const { memberInfo, threadInfo } = route.params; const boundRemoveUsersFromThread = useRemoveUsersFromThread(); const dispatchActionPromise = useDispatchActionPromise(); const onConfirmRemoveUser = React.useCallback( () => removeMemberFromThread( threadInfo, memberInfo, dispatchActionPromise, boundRemoveUsersFromThread, ), [threadInfo, memberInfo, dispatchActionPromise, boundRemoveUsersFromThread], ); const userText = stringForUser(memberInfo); return React.useCallback(() => { Alert.alert( 'Confirm removal', `Are you sure you want to remove ${userText} from this chat?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmRemoveUser }, ], { cancelable: true }, ); }, [onConfirmRemoveUser, userText]); } function useOnChangeRole( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, navigation: AppNavigationProp<'ThreadSettingsMemberTooltipModal'>, ) { const { threadInfo, memberInfo } = route.params; return React.useCallback(() => { navigation.navigate<'ChangeRolesScreen'>({ name: ChangeRolesScreenRouteName, params: { threadInfo, memberInfo, role: memberInfo.role, }, key: route.key, }); }, [navigation, route.key, threadInfo, memberInfo]); } function TooltipMenu( props: TooltipMenuProps<'ThreadSettingsMemberTooltipModal'>, ): React.Node { const { route, navigation, tooltipItem: TooltipItem } = props; const onChangeRole = useOnChangeRole(route, navigation); const onRemoveUser = useOnRemoveUser(route); return ( <> ); } const ThreadSettingsMemberTooltipModal: React.ComponentType< BaseTooltipProps<'ThreadSettingsMemberTooltipModal'>, > = createTooltip<'ThreadSettingsMemberTooltipModal'>( ThreadSettingsMemberTooltipButton, TooltipMenu, ); export default ThreadSettingsMemberTooltipModal; diff --git a/native/chat/settings/thread-settings-member.react.js b/native/chat/settings/thread-settings-member.react.js index cab3f8b40..61b299420 100644 --- a/native/chat/settings/thread-settings-member.react.js +++ b/native/chat/settings/thread-settings-member.react.js @@ -1,303 +1,303 @@ // @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 { 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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { - type ThreadInfo, + type LegacyThreadInfo, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import { useRolesFromCommunityThreadInfo } from 'lib/utils/role-utils.js'; import type { ThreadSettingsNavigate } from './thread-settings.react.js'; import UserAvatar from '../../avatars/user-avatar.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'; import { useNavigateToUserProfileBottomSheet } from '../../user-profile/user-profile-utils.js'; const unboundStyles = { container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 24, paddingVertical: 8, }, editButton: { paddingLeft: 10, }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastContainer: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, }, role: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 14, paddingTop: 4, }, row: { flex: 1, flexDirection: 'row', }, userInfoContainer: { flex: 1, flexDirection: 'row', alignItems: 'center', }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, marginLeft: 8, }, anonymous: { color: 'panelForegroundTertiaryLabel', fontStyle: 'italic', }, }; type BaseProps = { +memberInfo: RelativeMemberInfo, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, }; type Props = { ...BaseProps, // Redux state +roleName: ?string, +removeUserLoadingStatus: LoadingStatus, +changeRoleLoadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, +navigateToUserProfileBottomSheet: (userID: string) => mixed, }; class ThreadSettingsMember extends React.PureComponent { editButton: ?React.ElementRef; render(): React.Node { const userText = stringForUser(this.props.memberInfo); let usernameInfo = null; if (this.props.memberInfo.username) { usernameInfo = ( {userText} ); } else { usernameInfo = ( {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 = ( ); } const roleInfo = ( {this.props.roleName} ); const firstItem = this.props.firstListItem ? null : this.props.styles.topBorder; const lastItem = this.props.lastListItem ? this.props.styles.lastContainer : null; return ( {usernameInfo} {editButton} {roleInfo} ); } onPressUser = () => { this.props.navigateToUserProfileBottomSheet(this.props.memberInfo.id); }; 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 = (): boolean => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } 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); const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); const roles = useRolesFromCommunityThreadInfo(props.threadInfo, [ props.memberInfo, ]); const roleName = roles.get(props.memberInfo.id)?.name; return ( ); }); export default ConnectedThreadSettingsMember; diff --git a/native/chat/settings/thread-settings-parent.react.js b/native/chat/settings/thread-settings-parent.react.js index 4559ed1ca..952794416 100644 --- a/native/chat/settings/thread-settings-parent.react.js +++ b/native/chat/settings/thread-settings-parent.react.js @@ -1,116 +1,116 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import ThreadAvatar from '../../avatars/thread-avatar.react.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 ParentButtonProps = { - +parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; function ParentButton(props: ParentButtonProps): React.Node { const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const onPressParentThread = React.useCallback(() => { navigateToThread({ threadInfo: props.parentThreadInfo }); }, [props.parentThreadInfo, navigateToThread]); return ( ); } type ThreadSettingsParentProps = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - +parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + +parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, }; function ThreadSettingsParent(props: ThreadSettingsParentProps): React.Node { const { threadInfo, parentThreadInfo } = props; const styles = useStyles(unboundStyles); let parent; if (parentThreadInfo) { parent = ; } else if (threadInfo.parentThreadID) { parent = ( Secret parent ); } else { parent = ( No parent ); } return ( Parent {parent} ); } const unboundStyles = { avatarContainer: { marginRight: 8, }, 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, }, parentContainer: { flexDirection: 'row', }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 4, alignItems: 'center', }, }; 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 0ad411021..4c7f11906 100644 --- a/native/chat/settings/thread-settings-promote-sidebar.react.js +++ b/native/chat/settings/thread-settings-promote-sidebar.react.js @@ -1,115 +1,115 @@ // @flow import * as React from 'react'; import { Text, ActivityIndicator, View } from 'react-native'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } 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'; import Alert from '../../utils/alert.js'; const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, }, }; type BaseProps = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +buttonStyle: ViewStyle, }; type Props = { ...BaseProps, +loadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, +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(): React.Node { const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel } = this.props.colors; const loadingIndicator = this.props.loadingStatus === 'loading' ? ( ) : null; return ( ); } } const onError = () => { Alert.alert('Unknown error', 'Uhh... try again?', undefined, { cancelable: true, }); }; 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 ac98e2f5f..b62f85d7c 100644 --- a/native/chat/settings/thread-settings-push-notifs.react.js +++ b/native/chat/settings/thread-settings-push-notifs.react.js @@ -1,197 +1,197 @@ // @flow import * as React from 'react'; import { View, Switch, TouchableOpacity, Platform } from 'react-native'; import Linking from 'react-native/Libraries/Linking/Linking.js'; import { updateSubscriptionActionTypes, useUpdateSubscription, } from 'lib/actions/user-actions.js'; import { deviceTokenSelector } from 'lib/selectors/keyserver-selectors.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { DispatchActionPromise } from 'lib/utils/action-utils.js'; import { useDispatchActionPromise, extractKeyserverIDFromID, } from 'lib/utils/action-utils.js'; import SingleLine from '../../components/single-line.react.js'; import SWMansionIcon from '../../components/swmansion-icon.react.js'; import { CommAndroidNotifications } from '../../push/android.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; 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, }, }; type BaseProps = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { ...BaseProps, // Redux state +styles: $ReadOnly, // 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(): React.Node { const componentLabel = 'Push notifs'; let notificationsSettingsLinkingIcon: React.Node = 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 = async () => { let platformRequestsPermission; if (Platform.OS !== 'android') { platformRequestsPermission = true; } else { platformRequestsPermission = await CommAndroidNotifications.canRequestNotificationsPermissionFromUser(); } const alertTitle = platformRequestsPermission ? 'Need notif permissions' : 'Unable to initialize notifs'; const notificationsSettingsPath = Platform.OS === 'ios' ? 'Settings App → Notifications → Comm' : 'Settings → Apps → Comm → Notifications'; let alertMessage; if (platformRequestsPermission && 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 ' + notificationsSettingsPath; } else if (platformRequestsPermission) { alertMessage = 'In order to enable push notifs for this chat, ' + 'you need to first grant notif permissions to Comm. ' + 'Please enable them in ' + notificationsSettingsPath; } 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 ConnectedThreadSettingsPushNotifs: React.ComponentType = React.memo(function ConnectedThreadSettingsPushNotifs( props: BaseProps, ) { const keyserverID = extractKeyserverIDFromID(props.threadInfo.id); const deviceToken = useSelector(deviceTokenSelector(keyserverID)); const hasPushPermissions = deviceToken !== null && deviceToken !== undefined; const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useUpdateSubscription(); return ( ); }); export default ConnectedThreadSettingsPushNotifs; diff --git a/native/chat/settings/thread-settings-visibility.react.js b/native/chat/settings/thread-settings-visibility.react.js index 29c8fcdfc..0cb66268a 100644 --- a/native/chat/settings/thread-settings-visibility.react.js +++ b/native/chat/settings/thread-settings-visibility.react.js @@ -1,45 +1,45 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import ThreadVisibility from '../../components/thread-visibility.react.js'; import { useStyles, useColors } from '../../themes/colors.js'; type Props = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; function ThreadSettingsVisibility(props: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); return ( Visibility ); } const unboundStyles = { label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 4, alignItems: 'center', }, }; export default ThreadSettingsVisibility; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index 33294cf94..c9ec4ec80 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1298 +1,1298 @@ // @flow import type { TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, } from '@react-navigation/core'; 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 { MinimallyEncodedResolvedThreadInfo, MinimallyEncodedThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RelationshipButton } from 'lib/types/relationship-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { - type ThreadInfo, + type LegacyThreadInfo, type ResolvedThreadInfo, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { useResolvedThreadInfo, useResolvedOptionalThreadInfo, useResolvedOptionalThreadInfos, } from 'lib/utils/entity-helpers.js'; import ThreadSettingsAvatar from './thread-settings-avatar.react.js'; import type { CategoryType } from './thread-settings-category.react.js'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryActionHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react.js'; import ThreadSettingsChildThread from './thread-settings-child-thread.react.js'; import ThreadSettingsColor from './thread-settings-color.react.js'; import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react.js'; import ThreadSettingsDescription from './thread-settings-description.react.js'; import ThreadSettingsEditRelationship from './thread-settings-edit-relationship.react.js'; import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react.js'; import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react.js'; import { ThreadSettingsSeeMore, ThreadSettingsAddMember, ThreadSettingsAddSubchannel, } from './thread-settings-list-action.react.js'; import ThreadSettingsMediaGallery from './thread-settings-media-gallery.react.js'; import ThreadSettingsMember from './thread-settings-member.react.js'; import ThreadSettingsName from './thread-settings-name.react.js'; import ThreadSettingsParent from './thread-settings-parent.react.js'; import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react.js'; import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react.js'; import ThreadSettingsVisibility from './thread-settings-visibility.react.js'; import ThreadAncestors from '../../components/thread-ancestors.react.js'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state.js'; import { defaultStackScreenOptions } from '../../navigation/options.js'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context.js'; import { AddUsersModalRouteName, ComposeSubchannelModalRouteName, FullScreenThreadMediaGalleryRouteName, type ScreenParamList, 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 | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; export type ThreadSettingsNavigate = $PropertyType< ChatNavigationProp<'ThreadSettings'>, 'navigate', >; type ChatSettingsItem = | { +itemType: 'header', +key: string, +title: string, +categoryType: CategoryType, } | { +itemType: 'actionHeader', +key: string, +title: string, +actionText: string, +onPress: () => void, } | { +itemType: 'footer', +key: string, +categoryType: CategoryType, } | { +itemType: 'avatar', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +canChangeSettings: boolean, } | { +itemType: 'name', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +nameEditValue: ?string, +canChangeSettings: boolean, } | { +itemType: 'color', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, } | { +itemType: 'description', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, } | { +itemType: 'parent', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +parentThreadInfo: | ?ResolvedThreadInfo | ?MinimallyEncodedResolvedThreadInfo, } | { +itemType: 'visibility', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, } | { +itemType: 'pushNotifs', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, } | { +itemType: 'homeNotifs', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, } | { +itemType: 'seeMore', +key: string, +onPress: () => void, } | { +itemType: 'childThread', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +firstListItem: boolean, +lastListItem: boolean, } | { +itemType: 'addSubchannel', +key: string, } | { +itemType: 'member', +key: string, +memberInfo: RelativeMemberInfo, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, } | { +itemType: 'addMember', +key: string, } | { +itemType: 'mediaGallery', +key: string, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +limit: number, +verticalBounds: ?VerticalBounds, } | { +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, } | { +itemType: 'editRelationship', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, }; const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; type BaseProps = { +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, }; type Props = { ...BaseProps, // Redux state +userInfos: UserInfos, +viewerID: ?string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +parentThreadInfo: ?ResolvedThreadInfo | ?MinimallyEncodedResolvedThreadInfo, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: $ReadOnly, +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): boolean { 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: PropsAndState => $ReadOnlyArray = 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 | MinimallyEncodedResolvedThreadInfo, parentThreadInfo: | ?ResolvedThreadInfo | ?MinimallyEncodedResolvedThreadInfo, nameEditValue: ?string, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, ) => { const canEditThreadAvatar = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_AVATAR, ); const canEditThreadName = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const canEditThreadDescription = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const canEditThreadColor = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_COLOR, ); const canChangeAvatar = canEditThreadAvatar && canStartEditing; const canChangeName = canEditThreadName && canStartEditing; const canChangeDescription = canEditThreadDescription && canStartEditing; const canChangeColor = canEditThreadColor && canStartEditing; const listData: ChatSettingsItem[] = []; listData.push({ itemType: 'header', key: 'avatarHeader', title: 'Channel Avatar', categoryType: 'unpadded', }); listData.push({ itemType: 'avatar', key: 'avatar', threadInfo, canChangeSettings: canChangeAvatar, }); listData.push({ itemType: 'footer', key: 'avatarFooter', categoryType: 'outline', }); listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, canChangeSettings: canChangeName, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings: canChangeColor, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThreadDescription ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings: canChangeDescription, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); if (threadInfo.type !== threadTypes.SIDEBAR) { listData.push({ itemType: 'homeNotifs', key: 'homeNotifs', threadInfo, }); } listData.push({ itemType: 'footer', key: 'subscriptionFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, parentThreadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subchannelsListDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubchannelsShowing, ( threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray< ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, >, 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: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSidebarsShowing, ( navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray< ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, >, 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: PropsAndState => $ReadOnlyArray = 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 | MinimallyEncodedResolvedThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, numMembersShowing: number, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); if (threadInfo.members.length === 0 && !canAddMembers) { return listData; } listData.push({ itemType: 'header', key: 'memberHeader', title: 'Members', categoryType: 'unpadded', }); if (canAddMembers) { listData.push({ itemType: 'addMember', key: 'addMember', }); } const numItems = Math.min(numMembersShowing, threadInfo.members.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadInfo.members[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadInfo.members.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadInfo.members.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); mediaGalleryListDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.verticalBounds, ( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const limit = 6; listData.push({ itemType: 'actionHeader', key: 'mediaGalleryHeader', title: 'Media Gallery', actionText: 'See more', onPress: this.onPressSeeMoreMediaGallery, }); listData.push({ itemType: 'mediaGallery', key: 'mediaGallery', threadInfo, limit, verticalBounds, }); listData.push({ itemType: 'footer', key: 'mediaGalleryFooter', categoryType: 'outline', }); return listData; }, ); actionsListDataSelector: PropsAndState => $ReadOnlyArray = 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 | MinimallyEncodedResolvedThreadInfo, parentThreadInfo: | ?ResolvedThreadInfo | ?MinimallyEncodedResolvedThreadInfo, navigate: ThreadSettingsNavigate, styles: $ReadOnly, userInfos: UserInfos, viewerID: ?string, ) => { const buttons = []; if (this.props.canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } const canLeaveThread = threadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } const canDeleteThread = threadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } const threadIsPersonal = threadInfo.type === threadTypes.PERSONAL; if (threadIsPersonal && viewerID) { const otherMemberID = getSingleOtherUser(threadInfo, viewerID); if (otherMemberID) { const otherUserInfo = userInfos[otherMemberID]; const availableRelationshipActions = getAvailableRelationshipButtons(otherUserInfo); for (const action of availableRelationshipActions) { buttons.push({ itemType: 'editRelationship', key: action, threadInfo, navigate, relationshipButton: action, }); } } } const listData: ChatSettingsItem[] = []; if (buttons.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'actionsHeader', title: 'Actions', categoryType: 'unpadded', }); for (let i = 0; i < buttons.length; i++) { // Necessary for Flow... if (buttons[i].itemType === 'editRelationship') { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } else { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } } listData.push({ itemType: 'footer', key: 'actionsFooter', categoryType: 'unpadded', }); return listData; }, ); listDataSelector: PropsAndState => $ReadOnlyArray = createSelector( this.threadBasicsListDataSelector, this.subchannelsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.mediaGalleryListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: $ReadOnlyArray, subchannelsListData: $ReadOnlyArray, sidebarsListData: $ReadOnlyArray, threadMembersListData: $ReadOnlyArray, mediaGalleryListData: $ReadOnlyArray, actionsListData: $ReadOnlyArray, ) => [ ...threadBasicsListData, ...subchannelsListData, ...sidebarsListData, ...threadMembersListData, ...mediaGalleryListData, ...actionsListData, ], ); get listData(): $ReadOnlyArray { return this.listDataSelector({ ...this.props, ...this.state }); } render(): React.Node { return ( ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }); }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return renderItem = (row: { +item: ChatSettingsItem, ... }): React.Node => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'actionHeader') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'avatar') { return ( ); } else if (item.itemType === 'name') { return ( ); } else if (item.itemType === 'color') { return ( ); } else if (item.itemType === 'description') { return ( ); } else if (item.itemType === 'parent') { return ( ); } else if (item.itemType === 'visibility') { return ; } else if (item.itemType === 'pushNotifs') { return ; } else if (item.itemType === 'homeNotifs') { return ; } else if (item.itemType === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubchannel') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'mediaGallery') { return ( ); } else if (item.itemType === 'leaveThread') { return ( ); } else if (item.itemType === 'deleteThread') { return ( ); } else if (item.itemType === 'promoteSidebar') { return ( ); } else if (item.itemType === 'editRelationship') { return ( ); } else { invariant(false, `unexpected ThreadSettings item type ${item.itemType}`); } }; setNameEditValue = (value: ?string, callback?: () => void) => { this.setState({ nameEditValue: value }, callback); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubchannel = () => { this.props.navigation.navigate(ComposeSubchannelModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressAddMember = () => { this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressSeeMoreMembers = () => { this.setState(prevState => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubchannels = () => { this.setState(prevState => ({ numSubchannelsShowing: prevState.numSubchannelsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState(prevState => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; onPressSeeMoreMediaGallery = () => { this.props.navigation.navigate(FullScreenThreadMediaGalleryRouteName, { threadInfo: this.props.threadInfo, }); }; } const threadMembersChangeIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { for (const threadMember of threadMembers) { const removeUserLoadingStatus = createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${threadMember.id}`, )(state); if (removeUserLoadingStatus === 'loading') { return true; } const changeRoleLoadingStatus = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${threadMember.id}`, )(state); if (changeRoleLoadingStatus === 'loading') { return true; } } return false; }; const ConnectedThreadSettings: React.ComponentType = React.memo(function ConnectedThreadSettings(props: BaseProps) { const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const threadID = props.route.params.threadInfo.id; - const reduxThreadInfo: ?ThreadInfo = useSelector( + const reduxThreadInfo: ?LegacyThreadInfo = 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 | MinimallyEncodedThreadInfo = + const threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo = 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 => + const parentThreadInfo: ?LegacyThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const resolvedParentThreadInfo = useResolvedOptionalThreadInfo(parentThreadInfo); const threadMembers = threadInfo.members; const boundChildThreadInfos = useSelector( state => childThreadInfos(state)[threadID], ); const resolvedChildThreadInfos = useResolvedOptionalThreadInfos( boundChildThreadInfos, ); const somethingIsSaving = useSelector(state => { const editNameLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:name`, )(state); const editColorLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:color`, )(state); const editDescriptionLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:description`, )(state); const leaveThreadLoadingStatus = createLoadingStatusSelector( leaveThreadActionTypes, `${leaveThreadActionTypes.started}:${threadID}`, )(state); const boundThreadMembersChangeIsSaving = threadMembersChangeIsSaving( state, threadMembers, ); return ( boundThreadMembersChangeIsSaving || editNameLoadingStatus === 'loading' || editColorLoadingStatus === 'loading' || editDescriptionLoadingStatus === 'loading' || leaveThreadLoadingStatus === 'loading' ); }); const { navigation } = props; React.useEffect(() => { const tabNavigation = navigation.getParent< ScreenParamList, 'Chat', TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, TabNavigationProp<'Chat'>, >(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); const onTabPress = () => { if (navigation.isFocused() && !somethingIsSaving) { navigation.popToTop(); } }; tabNavigation.addListener('tabPress', onTabPress); return () => tabNavigation.removeListener('tabPress', onTabPress); }, [navigation, somethingIsSaving]); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const { canPromoteSidebar } = usePromoteSidebar(threadInfo); return ( ); }); export default ConnectedThreadSettings; diff --git a/native/chat/sidebar-list-modal.react.js b/native/chat/sidebar-list-modal.react.js index 5eede3a56..4b8c3bb6b 100644 --- a/native/chat/sidebar-list-modal.react.js +++ b/native/chat/sidebar-list-modal.react.js @@ -1,141 +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 type { LegacyThreadInfo, SidebarInfo } from 'lib/types/thread-types.js'; import { SidebarItem } from './sidebar-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'; import ExtendedArrow from '../vectors/arrow-extended.react.js'; import Arrow from '../vectors/arrow.react.js'; export type SidebarListModalParams = { - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, }; 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 numOfSidebarsWithExtendedArrow = listData.length - 1; const createRenderItem = React.useCallback( - (onPressItem: (threadInfo: ThreadInfo) => void) => + (onPressItem: (threadInfo: LegacyThreadInfo) => void) => // eslint-disable-next-line react/display-name (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, + onPressItem: (threadInfo: LegacyThreadInfo) => void, extendArrow: boolean, }): React.Node { const { item, onPressItem, extendArrow } = props; const { threadInfo } = item; 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/sidebar-navigation.js b/native/chat/sidebar-navigation.js index a05e40bc9..0f804ae9f 100644 --- a/native/chat/sidebar-navigation.js +++ b/native/chat/sidebar-navigation.js @@ -1,130 +1,130 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ENSCacheContext } from 'lib/components/ens-cache-provider.react.js'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { createPendingSidebar, createUnresolvedPendingSidebar, } from 'lib/shared/thread-utils.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { - ThreadInfo, + LegacyThreadInfo, ChatMentionCandidates, } from 'lib/types/thread-types.js'; import type { LoggedInUserInfo } from 'lib/types/user-types.js'; import type { GetENSNames } from 'lib/utils/ens-helpers.js'; import { ChatContext } from './chat-context.js'; import { useNavigateToThread } from './message-list-types.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; type GetUnresolvedSidebarThreadInfoInput = { +sourceMessage: ChatMessageInfoItemWithHeight, +loggedInUserInfo: ?LoggedInUserInfo, +chatMentionCandidates: ChatMentionCandidates, }; function getUnresolvedSidebarThreadInfo( input: GetUnresolvedSidebarThreadInfoInput, -): ?ThreadInfo | ?MinimallyEncodedThreadInfo { +): ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo { const { sourceMessage, loggedInUserInfo, chatMentionCandidates } = input; const threadCreatedFromMessage = sourceMessage.threadCreatedFromMessage; if (threadCreatedFromMessage) { return threadCreatedFromMessage; } if (!loggedInUserInfo) { return null; } const { messageInfo, threadInfo } = sourceMessage; return createUnresolvedPendingSidebar({ sourceMessageInfo: messageInfo, parentThreadInfo: threadInfo, loggedInUserInfo, markdownRules: getDefaultTextMessageRules(chatMentionCandidates) .simpleMarkdownRules, }); } type GetSidebarThreadInfoInput = { ...GetUnresolvedSidebarThreadInfoInput, +getENSNames: ?GetENSNames, }; async function getSidebarThreadInfo( input: GetSidebarThreadInfoInput, -): Promise { +): Promise { const { sourceMessage, loggedInUserInfo, getENSNames, chatMentionCandidates, } = input; const threadCreatedFromMessage = sourceMessage.threadCreatedFromMessage; if (threadCreatedFromMessage) { return threadCreatedFromMessage; } if (!loggedInUserInfo) { return null; } const { messageInfo, threadInfo } = sourceMessage; return await createPendingSidebar({ sourceMessageInfo: messageInfo, parentThreadInfo: threadInfo, loggedInUserInfo, markdownRules: getDefaultTextMessageRules(chatMentionCandidates) .simpleMarkdownRules, getENSNames, }); } function useNavigateToSidebar( item: ChatMessageInfoItemWithHeight, ): () => mixed { const loggedInUserInfo = useLoggedInUserInfo(); const navigateToThread = useNavigateToThread(); const cacheContext = React.useContext(ENSCacheContext); const chatMentionCandidates = useThreadChatMentionCandidates(item.threadInfo); const { getENSNames } = cacheContext; return React.useCallback(async () => { const threadInfo = await getSidebarThreadInfo({ sourceMessage: item, loggedInUserInfo, getENSNames, chatMentionCandidates, }); invariant(threadInfo, 'threadInfo should be set'); navigateToThread({ threadInfo }); }, [ item, loggedInUserInfo, getENSNames, chatMentionCandidates, navigateToThread, ]); } function useAnimatedNavigateToSidebar( item: ChatMessageInfoItemWithHeight, ): () => void { const chatContext = React.useContext(ChatContext); const setSidebarSourceID = chatContext?.setCurrentTransitionSidebarSourceID; const navigateToSidebar = useNavigateToSidebar(item); const messageID = item.messageInfo.id; return React.useCallback(() => { setSidebarSourceID && setSidebarSourceID(messageID); navigateToSidebar(); }, [setSidebarSourceID, messageID, navigateToSidebar]); } export { getUnresolvedSidebarThreadInfo, useNavigateToSidebar, useAnimatedNavigateToSidebar, }; diff --git a/native/chat/subchannels-list-modal.react.js b/native/chat/subchannels-list-modal.react.js index 4626fb8b9..eeb68bb5f 100644 --- a/native/chat/subchannels-list-modal.react.js +++ b/native/chat/subchannels-list-modal.react.js @@ -1,96 +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 { type LegacyThreadInfo } 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, + +threadInfo: LegacyThreadInfo, }; type Props = { +navigation: RootNavigationProp<'SubchannelsListModal'>, +route: NavigationRoute<'SubchannelsListModal'>, }; function SubchannelListModal(props: Props): React.Node { const { listData, searchState, setSearchState, onChangeSearchInputText } = useSearchSubchannels(props.route.params.threadInfo); return ( ); } const createRenderItem = - (onPressItem: (threadInfo: ThreadInfo) => void) => + (onPressItem: (threadInfo: LegacyThreadInfo) => void) => // eslint-disable-next-line react/display-name (row: { +item: ChatThreadItem, +index: number, ... }) => { return ; }; function Item(props: { - onPressItem: (threadInfo: ThreadInfo) => void, + onPressItem: (threadInfo: LegacyThreadInfo) => void, subchannelInfo: ChatThreadItem, }): React.Node { const { onPressItem, subchannelInfo } = props; const { threadInfo } = subchannelInfo; 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-thread.react.js b/native/chat/swipeable-thread.react.js index 914a29f29..d3d162891 100644 --- a/native/chat/swipeable-thread.react.js +++ b/native/chat/swipeable-thread.react.js @@ -1,100 +1,100 @@ // @flow import MaterialIcon from '@expo/vector-icons/MaterialCommunityIcons.js'; import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; // eslint-disable-next-line import/extensions import SwipeableComponent from 'react-native-gesture-handler/Swipeable'; import useToggleUnreadStatus from 'lib/hooks/toggle-unread-status.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import Swipeable from '../components/swipeable.js'; import { useColors } from '../themes/colors.js'; type Props = { - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, +mostRecentNonLocalMessage: ?string, - +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, + +onSwipeableWillOpen: (threadInfo: LegacyThreadInfo) => void, +currentlyOpenedSwipeableId?: string, +iconSize: number, +children: React.Node, }; function SwipeableThread(props: Props): React.Node { const swipeable = React.useRef(); const navigation = useNavigation(); React.useEffect(() => { return navigation.addListener('blur', () => { if (swipeable.current) { swipeable.current.close(); } }); }, [navigation, swipeable]); const { threadInfo, currentlyOpenedSwipeableId } = props; React.useEffect(() => { if (swipeable.current && threadInfo.id !== currentlyOpenedSwipeableId) { swipeable.current.close(); } }, [currentlyOpenedSwipeableId, swipeable, threadInfo.id]); const { onSwipeableWillOpen } = props; const onSwipeableRightWillOpen = React.useCallback(() => { onSwipeableWillOpen(threadInfo); }, [onSwipeableWillOpen, threadInfo]); const colors = useColors(); const { mostRecentNonLocalMessage, iconSize } = props; const swipeableClose = React.useCallback(() => { if (swipeable.current) { swipeable.current.close(); } }, []); const toggleUnreadStatus = useToggleUnreadStatus( threadInfo, mostRecentNonLocalMessage, swipeableClose, ); const swipeableActions = React.useMemo(() => { const isUnread = threadInfo.currentUser.unread; return [ { key: 'action1', onPress: toggleUnreadStatus, color: isUnread ? colors.vibrantRedButton : colors.vibrantGreenButton, content: ( ), }, ]; }, [ threadInfo.currentUser.unread, toggleUnreadStatus, colors.vibrantRedButton, colors.vibrantGreenButton, iconSize, ]); const swipeableThread = React.useMemo( () => ( {props.children} ), [onSwipeableRightWillOpen, props.children, swipeableActions], ); return swipeableThread; } export default SwipeableThread; diff --git a/native/chat/thread-list-modal.react.js b/native/chat/thread-list-modal.react.js index e8083eb08..5f2ce690f 100644 --- a/native/chat/thread-list-modal.react.js +++ b/native/chat/thread-list-modal.react.js @@ -1,198 +1,200 @@ // @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 type { LegacyThreadInfo, 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: { + +threadInfo: LegacyThreadInfo, + +createRenderItem: ( + onPressItem: (threadInfo: LegacyThreadInfo) => 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) => { + (threadInfo: LegacyThreadInfo) => { setSearchState({ text: '', results: new Set(), }); if (searchTextInputRef.current) { searchTextInputRef.current.blur(); } navigateToThread({ threadInfo }); }, [navigateToThread, setSearchState], ); 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 330e06260..36523780a 100644 --- a/native/chat/thread-settings-button.react.js +++ b/native/chat/thread-settings-button.react.js @@ -1,62 +1,62 @@ // @flow import * as React from 'react'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } 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 { ThreadSettingsRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; const unboundStyles = { button: { color: 'panelForegroundLabel', paddingHorizontal: 10, }, }; type BaseProps = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +navigate: $PropertyType, 'navigate'>, }; type Props = { ...BaseProps, +styles: $ReadOnly, }; class ThreadSettingsButton extends React.PureComponent { render(): React.Node { return ( ); } onPress = () => { const threadInfo = this.props.threadInfo; this.props.navigate<'ThreadSettings'>({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } 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 5c6daa642..b2a344ad4 100644 --- a/native/chat/thread-settings-header-title.react.js +++ b/native/chat/thread-settings-header-title.react.js @@ -1,25 +1,25 @@ // @flow import type { HeaderTitleInputProps } from '@react-navigation/core'; import { HeaderTitle } from '@react-navigation/elements'; import * as React from 'react'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } 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 | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ...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); export default MemoizedThreadSettingsHeaderTitle; diff --git a/native/chat/toggle-pin-modal.react.js b/native/chat/toggle-pin-modal.react.js index 84fdbfb3e..c6b27e781 100644 --- a/native/chat/toggle-pin-modal.react.js +++ b/native/chat/toggle-pin-modal.react.js @@ -1,164 +1,164 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { useToggleMessagePin, toggleMessagePinActionTypes, } from 'lib/actions/message-actions.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import MessageResult from './message-result.react.js'; import Button from '../components/button.react.js'; import Modal from '../components/modal.react.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types'; export type TogglePinModalParams = { +item: ChatMessageInfoItemWithHeight, - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, }; type TogglePinModalProps = { +navigation: AppNavigationProp<'TogglePinModal'>, +route: NavigationRoute<'TogglePinModal'>, }; function TogglePinModal(props: TogglePinModalProps): React.Node { const { navigation, route } = props; const { item, threadInfo } = route.params; const { messageInfo, isPinned } = item; const styles = useStyles(unboundStyles); const callToggleMessagePin = useToggleMessagePin(); const dispatchActionPromise = useDispatchActionPromise(); const modalInfo = React.useMemo(() => { if (isPinned) { return { name: 'Remove Pinned Message', action: 'unpin', confirmationText: 'Are you sure you want to remove this pinned message?', buttonText: 'Remove Pinned Message', buttonStyle: styles.removePinButton, }; } return { name: 'Pin Message', action: 'pin', confirmationText: 'You may pin this message to the channel you are ' + 'currently viewing. To unpin a message, select the pinned messages ' + 'icon in the channel.', buttonText: 'Pin Message', buttonStyle: styles.pinButton, }; }, [isPinned, styles.pinButton, styles.removePinButton]); const createToggleMessagePinPromise = React.useCallback(async () => { invariant(messageInfo.id, 'messageInfo.id should be defined'); const result = await callToggleMessagePin({ messageID: messageInfo.id, action: modalInfo.action, }); return ({ newMessageInfos: result.newMessageInfos, threadID: result.threadID, }: { +newMessageInfos: $ReadOnlyArray, +threadID: string }); }, [callToggleMessagePin, messageInfo.id, modalInfo.action]); const onPress = React.useCallback(() => { dispatchActionPromise( toggleMessagePinActionTypes, createToggleMessagePinPromise(), ); navigation.goBack(); }, [createToggleMessagePinPromise, dispatchActionPromise, navigation]); const onCancel = React.useCallback(() => { navigation.goBack(); }, [navigation]); return ( {modalInfo.name} {modalInfo.confirmationText} ); } const unboundStyles = { modal: { backgroundColor: 'modalForeground', borderColor: 'modalForegroundBorder', }, modalHeader: { fontSize: 18, color: 'modalForegroundLabel', }, modalConfirmationText: { fontSize: 12, color: 'modalBackgroundLabel', marginTop: 4, }, buttonsContainer: { flexDirection: 'column', flex: 1, justifyContent: 'flex-end', marginBottom: 0, height: 72, paddingHorizontal: 16, }, removePinButton: { borderRadius: 5, height: 48, justifyContent: 'center', alignItems: 'center', backgroundColor: 'vibrantRedButton', }, pinButton: { borderRadius: 5, height: 48, justifyContent: 'center', alignItems: 'center', backgroundColor: 'purpleButton', }, cancelButton: { borderRadius: 5, height: 48, justifyContent: 'center', alignItems: 'center', }, textColor: { color: 'modalButtonLabel', }, }; export default TogglePinModal; diff --git a/native/chat/utils.js b/native/chat/utils.js index 2aafa8f73..b554dd69d 100644 --- a/native/chat/utils.js +++ b/native/chat/utils.js @@ -1,446 +1,446 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import Animated from 'react-native-reanimated'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { viewerIsMember } from 'lib/shared/thread-utils.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { clusterEndHeight } from './chat-constants.js'; import { ChatContext, useHeightMeasurer } from './chat-context.js'; import { failedSendHeight } from './failed-send.react.js'; import { useNativeMessageListData, type NativeChatMessageItem, } from './message-data.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 type { ChatMessageInfoItemWithHeight, ChatMessageItemWithHeight, 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; } return height; } 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 += item.contentHeight; } 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 | ?MinimallyEncodedThreadInfo, + sidebarThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ): { +position: number, +color: string, } { const messageListData = useNativeMessageListData({ searching: false, userInfoInputArray: [], threadInfo: sidebarThreadInfo, }); 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 loggedInUserInfo = useLoggedInUserInfo(); const chatMentionCandidates = useThreadChatMentionCandidates( sourceMessage.threadInfo, ); const sidebarThreadInfo = React.useMemo( () => getUnresolvedSidebarThreadInfo({ sourceMessage, loggedInUserInfo, chatMentionCandidates, }), [sourceMessage, loggedInUserInfo, chatMentionCandidates], ); 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, ); 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 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 | NativeChatMessageItem, ): string { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } function modifyItemForResultScreen( item: ChatMessageInfoItemWithHeight, ): ChatMessageInfoItemWithHeight { if (item.messageShapeType === 'robotext') { return item; } if (item.messageShapeType === 'multimedia') { return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } export { chatMessageItemKey, chatMessageItemHeight, useAnimatedMessageTooltipButton, messageItemHeight, getMessageTooltipKey, isMessageTooltipKey, useContentAndHeaderOpacity, useDeliveryIconOpacity, modifyItemForResultScreen, }; diff --git a/native/components/community-actions-button.react.js b/native/components/community-actions-button.react.js index bf7248570..84da737c3 100644 --- a/native/components/community-actions-button.react.js +++ b/native/components/community-actions-button.react.js @@ -1,157 +1,157 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { primaryInviteLinksSelector } from 'lib/selectors/invite-links-selectors.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import SWMansionIcon from './swmansion-icon.react.js'; import { InviteLinkNavigatorRouteName, ManagePublicLinkRouteName, ViewInviteLinksRouteName, RolesNavigatorRouteName, CommunityRolesScreenRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; type Props = { - +community: ThreadInfo | MinimallyEncodedThreadInfo, + +community: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; function CommunityActionsButton(props: Props): React.Node { const { community } = props; const inviteLink = useSelector(primaryInviteLinksSelector)[community.id]; const { navigate } = useNavigation(); const navigateToInviteLinksView = React.useCallback(() => { if (!inviteLink || !community) { return; } navigate<'InviteLinkNavigator'>(InviteLinkNavigatorRouteName, { screen: ViewInviteLinksRouteName, params: { community, }, }); }, [community, inviteLink, navigate]); const navigateToManagePublicLinkView = React.useCallback(() => { navigate<'InviteLinkNavigator'>(InviteLinkNavigatorRouteName, { screen: ManagePublicLinkRouteName, params: { community, }, }); }, [community, navigate]); const navigateToCommunityRolesScreen = React.useCallback(() => { navigate<'RolesNavigator'>(RolesNavigatorRouteName, { screen: CommunityRolesScreenRouteName, params: { threadInfo: community, }, }); }, [community, navigate]); const insets = useSafeAreaInsets(); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const styles = useStyles(unboundStyles); const { showActionSheetWithOptions } = useActionSheet(); const actions = React.useMemo(() => { if (!community) { return null; } const result = []; const canManageLinks = threadHasPermission( community, threadPermissions.MANAGE_INVITE_LINKS, ); if (canManageLinks) { result.push({ label: 'Manage invite links', action: navigateToManagePublicLinkView, }); } if (inviteLink) { result.push({ label: 'Invite link', action: navigateToInviteLinksView, }); } const canChangeRoles = threadHasPermission( community, threadPermissions.CHANGE_ROLE, ); if (canChangeRoles) { result.push({ label: 'Manage roles', action: navigateToCommunityRolesScreen, }); } if (result.length > 0) { return result; } return null; }, [ community, inviteLink, navigateToInviteLinksView, navigateToManagePublicLinkView, navigateToCommunityRolesScreen, ]); const openActionSheet = React.useCallback(() => { if (!actions) { return; } const options = [...actions.map(a => a.label), 'Cancel']; showActionSheetWithOptions( { options, cancelButtonIndex: options.length - 1, containerStyle: { paddingBottom: insets.bottom, }, userInterfaceStyle: activeTheme ?? 'dark', }, selectedIndex => { if (selectedIndex !== undefined && selectedIndex < actions.length) { actions[selectedIndex].action(); } }, ); }, [actions, activeTheme, insets.bottom, showActionSheetWithOptions]); let button = null; if (actions) { button = ( ); } return {button}; } const unboundStyles = { button: { color: 'drawerItemLabelLevel0', }, container: { width: 22, }, }; export default CommunityActionsButton; diff --git a/native/components/community-pill.react.js b/native/components/community-pill.react.js index 5d02019bf..b31f5220f 100644 --- a/native/components/community-pill.react.js +++ b/native/components/community-pill.react.js @@ -1,69 +1,69 @@ // @flow import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import { useKeyserverAdmin } from 'lib/shared/user-utils.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import CommIcon from './comm-icon.react.js'; import Pill from './pill.react.js'; import ThreadPill from './thread-pill.react.js'; import { useColors } from '../themes/colors.js'; const threadPillRoundCorners = { left: false, right: true }; type Props = { - +community: ThreadInfo | MinimallyEncodedThreadInfo, + +community: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; function CommunityPill(props: Props): React.Node { const { community } = props; const keyserverAdmin = useKeyserverAdmin(community); const keyserverOperatorUsername = keyserverAdmin?.username; const colors = useColors(); const keyserverOperatorLabel: ?React.Node = React.useMemo(() => { if (!keyserverOperatorUsername) { return undefined; } const icon = ( ); return ( ); }, [ colors.codeBackground, colors.panelForegroundLabel, keyserverOperatorUsername, ]); return ( {keyserverOperatorLabel} ); } const styles = StyleSheet.create({ container: { flexDirection: 'row', }, }); export default CommunityPill; diff --git a/native/components/thread-ancestors-label.react.js b/native/components/thread-ancestors-label.react.js index fdad66d2b..8c7e79fd7 100644 --- a/native/components/thread-ancestors-label.react.js +++ b/native/components/thread-ancestors-label.react.js @@ -1,80 +1,80 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome5.js'; import * as React from 'react'; import { Text, View } from 'react-native'; import { useAncestorThreads } from 'lib/shared/ancestor-threads.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfos } from 'lib/utils/entity-helpers.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; function ThreadAncestorsLabel(props: Props): React.Node { const { threadInfo } = props; const { unread } = threadInfo.currentUser; const styles = useStyles(unboundStyles); const colors = useColors(); const ancestorThreads = useAncestorThreads(threadInfo); const resolvedAncestorThreads = useResolvedThreadInfos(ancestorThreads); const chevronIcon = React.useMemo( () => ( ), [colors.listForegroundTertiaryLabel], ); const ancestorPath = React.useMemo(() => { const path: Array = []; for (const thread of resolvedAncestorThreads) { path.push({thread.uiName}); path.push( ${thread.id}`} style={styles.chevron}> {chevronIcon} , ); } path.pop(); return path; }, [resolvedAncestorThreads, chevronIcon, styles.chevron]); const ancestorPathStyle = React.useMemo(() => { return unread ? [styles.pathText, styles.unread] : styles.pathText; }, [styles.pathText, styles.unread, unread]); const threadAncestorsLabel = React.useMemo( () => ( {ancestorPath} ), [ancestorPath, ancestorPathStyle], ); return threadAncestorsLabel; } const unboundStyles = { pathText: { opacity: 0.8, fontSize: 12, color: 'listForegroundTertiaryLabel', }, unread: { color: 'listForegroundLabel', }, chevron: { paddingHorizontal: 3, }, }; export default ThreadAncestorsLabel; diff --git a/native/components/thread-ancestors.react.js b/native/components/thread-ancestors.react.js index 1378b3647..e9aab5769 100644 --- a/native/components/thread-ancestors.react.js +++ b/native/components/thread-ancestors.react.js @@ -1,113 +1,113 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome5.js'; import * as React from 'react'; import { View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { ancestorThreadInfos } from 'lib/selectors/thread-selectors.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import Button from './button.react.js'; import CommunityPill from './community-pill.react.js'; import ThreadPill from './thread-pill.react.js'; import { useNavigateToThread } from '../chat/message-list-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; function ThreadAncestors(props: Props): React.Node { const { threadInfo } = props; const styles = useStyles(unboundStyles); const colors = useColors(); - const ancestorThreads: $ReadOnlyArray = useSelector( + const ancestorThreads: $ReadOnlyArray = useSelector( ancestorThreadInfos(threadInfo.id), ); const rightArrow = React.useMemo( () => ( ), [colors.panelForegroundLabel], ); const navigateToThread = useNavigateToThread(); const pathElements = React.useMemo(() => { const elements = []; for (const [idx, ancestorThreadInfo] of ancestorThreads.entries()) { const isLastThread = idx === ancestorThreads.length - 1; const pill = idx === 0 ? ( ) : ( ); elements.push( {!isLastThread ? rightArrow : null} , ); } return {elements}; }, [ ancestorThreads, navigateToThread, rightArrow, styles.pathItem, styles.row, ]); return ( {pathElements} ); } const height = 48; const unboundStyles = { arrowIcon: { paddingHorizontal: 8, }, container: { height, backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', }, contentContainer: { paddingHorizontal: 12, }, pathItem: { alignItems: 'center', flexDirection: 'row', height, }, row: { flexDirection: 'row', }, }; export default ThreadAncestors; diff --git a/native/components/thread-list-thread.react.js b/native/components/thread-list-thread.react.js index c654c798c..bab9af2f0 100644 --- a/native/components/thread-list-thread.react.js +++ b/native/components/thread-list-thread.react.js @@ -1,87 +1,90 @@ // @flow import * as React from 'react'; import type { MinimallyEncodedResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo, ResolvedThreadInfo } from 'lib/types/thread-types.js'; +import type { + LegacyThreadInfo, + ResolvedThreadInfo, +} from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import Button from './button.react.js'; import SingleLine from './single-line.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import { type Colors, useStyles, useColors } from '../themes/colors.js'; import type { ViewStyle, TextStyle } from '../types/styles.js'; const unboundStyles = { button: { alignItems: 'center', flexDirection: 'row', paddingLeft: 13, }, text: { color: 'modalForegroundLabel', fontSize: 16, paddingLeft: 9, paddingRight: 12, paddingVertical: 6, }, }; type SharedProps = { +onSelect: (threadID: string) => void, +style?: ViewStyle, +textStyle?: TextStyle, }; type BaseProps = { ...SharedProps, - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, }; type Props = { ...SharedProps, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +colors: Colors, +styles: $ReadOnly, }; class ThreadListThread extends React.PureComponent { render(): React.Node { const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSelect = () => { this.props.onSelect(this.props.threadInfo.id); }; } 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 a26e801e0..3fd687b53 100644 --- a/native/components/thread-list.react.js +++ b/native/components/thread-list.react.js @@ -1,157 +1,157 @@ // @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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } 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'; const unboundStyles = { search: { marginBottom: 8, }, }; type BaseProps = { - +threadInfos: $ReadOnlyArray, + +threadInfos: $ReadOnlyArray, +onSelect: (threadID: string) => void, +itemStyle?: ViewStyle, +itemTextStyle?: TextStyle, +searchIndex?: SearchIndex, }; type Props = { ...BaseProps, // Redux state +styles: $ReadOnly, +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: PropsAndState => $ReadOnlyArray = + listDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfos, (propsAndState: PropsAndState) => propsAndState.searchText, (propsAndState: PropsAndState) => propsAndState.searchResults, (propsAndState: PropsAndState) => propsAndState.itemStyle, (propsAndState: PropsAndState) => propsAndState.itemTextStyle, ( - threadInfos: $ReadOnlyArray, + threadInfos: $ReadOnlyArray, text: string, searchResults: Set, - ): $ReadOnlyArray => + ): $ReadOnlyArray => 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(): $ReadOnlyArray { + get listData(): $ReadOnlyArray { return this.listDataSelector({ ...this.props, ...this.state }); } render(): React.Node { let searchBar = null; if (this.props.searchIndex) { searchBar = ( ); } return ( {searchBar} ); } static keyExtractor = ( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): string => { return threadInfo.id; }; - renderItem = (row: { +item: ThreadInfo, ... }): React.Node => { + renderItem = (row: { +item: LegacyThreadInfo, ... }): React.Node => { return ( ); }; static getItemLayout = ( - data: ?$ReadOnlyArray, + data: ?$ReadOnlyArray, index: number, ): { length: number, offset: number, 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 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/thread-pill.react.js b/native/components/thread-pill.react.js index 20041a24a..6ce4fef1f 100644 --- a/native/components/thread-pill.react.js +++ b/native/components/thread-pill.react.js @@ -1,29 +1,29 @@ // @flow import * as React from 'react'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import Pill from './pill.react.js'; type Props = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +roundCorners?: { +left: boolean, +right: boolean }, +fontSize?: number, }; function ThreadPill(props: Props): React.Node { const { threadInfo, roundCorners, fontSize } = props; const { uiName } = useResolvedThreadInfo(threadInfo); return ( ); } export default ThreadPill; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index 83c6814ce..487b3f33e 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1761 +1,1761 @@ // @flow import * as FileSystem from 'expo-file-system'; import invariant from 'invariant'; import * as React from 'react'; import { Platform } from 'react-native'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, useSendMultimediaMessage, sendTextMessageActionTypes, useSendTextMessage, } from 'lib/actions/message-actions.js'; import type { SendMultimediaMessageInput, SendTextMessageInput, } from 'lib/actions/message-actions.js'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { useNewThread } from 'lib/actions/thread-actions.js'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, useBlobServiceUpload, type MultimediaUploadCallbacks, type MultimediaUploadExtras, type BlobServiceUploadAction, type BlobServiceUploadResult, } from 'lib/actions/upload-actions.js'; import commStaffCommunity from 'lib/facts/comm-staff-community.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, useNextLocalID, useMessageCreationSideEffectsFunc, } from 'lib/shared/message-utils.js'; import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js'; import { createRealThreadFromPendingThread, threadIsPending, threadIsPendingSidebar, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, MediaMissionStep, } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { 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 { 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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ClientMediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ClientNewThreadRequest, type NewThreadResult, - type ThreadInfo, + type LegacyThreadInfo, } 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 { useDispatch } from 'lib/utils/redux-utils.js'; import { generateReportID, useIsReportEnabled, } from 'lib/utils/report-utils.js'; import { type EditInputBarMessageParameters, InputStateContext, type PendingMultimediaUploads, type MultimediaProcessingStep, type MessagePendingUploads, type InputState, } from './input-state.js'; import { encryptMedia } from '../media/encryption-utils.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 blobServiceUploadHandler from '../utils/blob-service-upload.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 WritableCompletedUploads = { [localMessageID: string]: ?$ReadOnlySet, }; type CompletedUploads = $ReadOnly; type ActiveURI = { +count: number, +onClear: $ReadOnlyArray<() => mixed> }; type BaseProps = { +children: React.Node, }; type Props = { ...BaseProps, +viewerID: ?string, +nextLocalID: string, +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, +blobServiceUpload: BlobServiceUploadAction, +sendMultimediaMessage: ( input: SendMultimediaMessageInput, ) => Promise, +sendTextMessage: (input: SendTextMessageInput) => Promise, +newThread: (request: ClientNewThreadRequest) => Promise, +textMessageCreationSideEffectsFunc: CreationSideEffectsFunc, }; type State = { +pendingUploads: PendingMultimediaUploads, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs: Map = new Map(); editInputBarCallbacks: Array< (params: EditInputBarMessageParameters) => void, > = []; scrollToMessageCallbacks: Array<(messageID: string) => void> = []; pendingThreadCreations: Map> = new Map(); pendingThreadUpdateHandlers: Map< string, - (ThreadInfo | MinimallyEncodedThreadInfo) => mixed, + (LegacyThreadInfo | MinimallyEncodedThreadInfo) => mixed, > = new Map(); // TODO: flip the switch // Note that this enables Blob service for encrypted media only useBlobServiceUploads = false; // When the user sends a multimedia message that triggers the creation of a // sidebar, the sidebar gets created right away, but the message needs to wait // for the uploads to complete before sending. We use this Set to track the // message localIDs that need sidebarCreation: true. pendingSidebarCreationMessageLocalIDs: Set = new Set(); static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads: WritableCompletedUploads = {}; 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: PendingMultimediaUploads = {}; 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: MessagePendingUploads = {}; 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, ): Promise { 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 sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); const mediaMessageContents = getMediaMessageServerDBContentsFromMedia( messageInfo.media, ); try { const result = await this.props.sendMultimediaMessage({ threadID, localID, mediaMessageContents, sidebarCreation, }); this.pendingSidebarCreationMessageLocalIDs.delete(localID); 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: State => InputState = createSelector( (state: State) => state.pendingUploads, (pendingUploads: PendingMultimediaUploads) => ({ pendingUploads, sendTextMessage: this.sendTextMessage, sendMultimediaMessage: this.sendMultimediaMessage, editInputMessage: this.editInputMessage, addEditInputMessageListener: this.addEditInputMessageListener, removeEditInputMessageListener: this.removeEditInputMessageListener, messageHasUploadFailure: this.messageHasUploadFailure, retryMessage: this.retryMessage, registerSendCallback: this.registerSendCallback, unregisterSendCallback: this.unregisterSendCallback, uploadInProgress: this.uploadInProgress, reportURIDisplayed: this.reportURIDisplayed, setPendingThreadUpdateHandler: this.setPendingThreadUpdateHandler, scrollToMessage: this.scrollToMessage, addScrollToMessageListener: this.addScrollToMessageListener, removeScrollToMessageListener: this.removeScrollToMessageListener, }), ); scrollToMessage = (messageID: string) => { this.scrollToMessageCallbacks.forEach(callback => callback(messageID)); }; addScrollToMessageListener = (callback: (messageID: string) => void) => { this.scrollToMessageCallbacks.push(callback); }; removeScrollToMessageListener = ( callbackScrollToMessage: (messageID: string) => void, ) => { this.scrollToMessageCallbacks = this.scrollToMessageCallbacks.filter( candidate => candidate !== callbackScrollToMessage, ); }; uploadInProgress = (): boolean => { 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, - inputThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + inputThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); if (threadIsPendingSidebar(inputThreadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localID); } if (!threadIsPending(inputThreadInfo.id)) { this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( messageInfo, inputThreadInfo, parentThreadInfo, ), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendTextMessageActionTypes.started, payload: messageInfo, }); let threadInfo = inputThreadInfo; const { viewerID } = this.props; if (viewerID && inputThreadInfo.type === threadTypes.SIDEBAR) { invariant(parentThreadInfo, 'sidebar should have parent'); threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent( inputThreadInfo, parentThreadInfo, messageInfo.text, viewerID, ); if (threadInfo !== inputThreadInfo) { const pendingThreadUpdateHandler = this.pendingThreadUpdateHandlers.get( threadInfo.id, ); pendingThreadUpdateHandler?.(threadInfo); } } 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(), }; // Branching to appease `flow`. const newThreadInfo = threadInfo.minimallyEncoded ? { ...threadInfo, id: newThreadID, } : { ...threadInfo, id: newThreadID, }; this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( newMessageInfo, newThreadInfo, parentThreadInfo, ), undefined, newMessageInfo, ); }; startThreadCreation( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): Promise { if (!threadIsPending(threadInfo.id)) { return Promise.resolve(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, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ): Promise { try { await this.props.textMessageCreationSideEffectsFunc( messageInfo, threadInfo, parentThreadInfo, ); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); const result = await this.props.sendTextMessage({ threadID: messageInfo.threadID, localID, text: messageInfo.text, sidebarCreation, }); this.pendingSidebarCreationMessageLocalIDs.delete(localID); 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; } } shouldEncryptMedia( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): boolean { return threadInfoInsideCommunity(threadInfo, commStaffCommunity.id); } sendMultimediaMessage = async ( selections: $ReadOnlyArray, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const localMessageID = this.props.nextLocalID; this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const uploadFileInputs = [], media: Array = []; 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, thumbHash: null, }); 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, thumbnailThumbHash: null, }); ids = { type: 'video', localMediaID, localThumbnailID }; } invariant(ids, `unexpected MediaSelection ${selection.step}`); uploadFileInputs.push({ selection, ids }); } const pendingUploads: MessagePendingUploads = {}; 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, }, { forceMultimediaMessageType: this.shouldEncryptMedia(threadInfo) }, ); this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); }, ); await this.uploadFiles(localMessageID, uploadFileInputs, threadInfo); }; async uploadFiles( localMessageID: string, uploadFileInputs: $ReadOnlyArray, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ) { const results = await Promise.all( uploadFileInputs.map(uploadFileInput => this.uploadFile(localMessageID, uploadFileInput, threadInfo), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, uploadFileInput: UploadFileInput, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): Promise { const { ids, selection } = uploadFileInput; const { localMediaID } = ids; const start = selection.sendTime; const steps: Array = [selection]; let encryptionSteps: $ReadOnlyArray = []; let serverID; let userTime; let errorMessage; let reportPromise: ?Promise<$ReadOnlyArray>; const filesToDispose = []; const onUploadFinished = async (result: MediaMissionResult) => { if (!this.props.mediaReportsEnabled) { return errorMessage; } if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); steps.push(...encryptionSteps); } 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; }; const onTranscodingProgress = (percent: number) => { this.setProgress(localMessageID, localMediaID, 'transcoding', percent); }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia(selection, { hasWiFi: this.props.hasWiFi, finalFileHeaderCheck: this.props.staffCanSee, onTranscodingProgress, }); 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); } if (processResult.shouldDisposePath) { filesToDispose.push(processResult.shouldDisposePath); } processedMedia = processResult; } catch (e) { onUploadFailed(localMediaID, 'processing failed'); return await onUploadFinished({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } if (this.shouldEncryptMedia(threadInfo)) { const encryptionStart = Date.now(); try { const { result: encryptionResult, ...encryptionReturn } = await encryptMedia(processedMedia); encryptionSteps = encryptionReturn.steps; if (!encryptionResult.success) { onUploadFailed(localMediaID, encryptionResult.reason); return await onUploadFinished(encryptionResult); } if (encryptionResult.shouldDisposePath) { filesToDispose.push(encryptionResult.shouldDisposePath); } processedMedia = encryptionResult; } catch (e) { onUploadFailed(localMediaID, 'encryption failed'); return await onUploadFinished({ success: false, reason: 'encryption_exception', time: Date.now() - encryptionStart, exceptionMessage: getMessageForException(e), }); } } const { uploadURI, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, uploadThumbnailResult, mediaMissionResult; try { if ( this.useBlobServiceUploads && (processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video') ) { const uploadPromise = this.props.blobServiceUpload({ uploadInput: { blobInput: { type: 'uri', uri: uploadURI, filename: filename, mimeType: mime, }, blobHash: processedMedia.blobHash, encryptionKey: processedMedia.encryptionKey, dimensions: processedMedia.dimensions, thumbHash: processedMedia.mediaType === 'encrypted_photo' ? processedMedia.thumbHash : null, }, keyserverOrThreadID: threadInfo.id, callbacks: { blobServiceUploadHandler, onProgress: (percent: number) => { this.setProgress( localMessageID, localMediaID, 'uploading', percent, ); }, }, }); const uploadThumbnailPromise: Promise = (async () => { if (processedMedia.mediaType !== 'encrypted_video') { return undefined; } return await this.props.blobServiceUpload({ uploadInput: { blobInput: { type: 'uri', uri: processedMedia.uploadThumbnailURI, filename: replaceExtension(`thumb${filename}`, 'jpg'), mimeType: 'image/jpeg', }, blobHash: processedMedia.thumbnailBlobHash, encryptionKey: processedMedia.thumbnailEncryptionKey, loop: false, dimensions: processedMedia.dimensions, thumbHash: processedMedia.thumbHash, }, keyserverOrThreadID: threadInfo.id, callbacks: { blobServiceUploadHandler, }, }); })(); [uploadResult, uploadThumbnailResult] = await Promise.all([ uploadPromise, uploadThumbnailPromise, ]); } else { const uploadPromise = this.props.uploadMultimedia( { uri: uploadURI, name: filename, type: mime }, { ...processedMedia.dimensions, loop: processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ? processedMedia.loop : undefined, encryptionKey: processedMedia.encryptionKey, thumbHash: processedMedia.mediaType === 'photo' || processedMedia.mediaType === 'encrypted_photo' ? processedMedia.thumbHash : null, }, { onProgress: (percent: number) => this.setProgress( localMessageID, localMediaID, 'uploading', percent, ), uploadBlob: this.uploadBlob, }, ); const uploadThumbnailPromise: Promise = (async () => { if ( processedMedia.mediaType !== 'video' && processedMedia.mediaType !== 'encrypted_video' ) { return undefined; } return await this.props.uploadMultimedia( { uri: processedMedia.uploadThumbnailURI, name: replaceExtension(`thumb${filename}`, 'jpg'), type: 'image/jpeg', }, { ...processedMedia.dimensions, loop: false, encryptionKey: processedMedia.thumbnailEncryptionKey, thumbHash: processedMedia.thumbHash, }, { uploadBlob: this.uploadBlob, }, ); })(); [uploadResult, uploadThumbnailResult] = await Promise.all([ uploadPromise, uploadThumbnailPromise, ]); } 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' || processedMedia.mediaType === 'encrypted_photo') && uploadResult) || ((processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video') && uploadResult && uploadThumbnailResult) ) { const { encryptionKey } = processedMedia; const { id, uri, dimensions, loop } = uploadResult; serverID = id; const mediaSourcePayload = processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video' ? { type: processedMedia.mediaType, blobURI: uri, encryptionKey, } : { type: uploadResult.mediaType, uri, }; let updateMediaPayload = { messageID: localMessageID, currentMediaID: localMediaID, mediaUpdate: { id, ...mediaSourcePayload, dimensions, localMediaSelection: undefined, loop: uploadResult.mediaType === 'video' ? loop : undefined, }, }; if ( processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ) { invariant(uploadThumbnailResult, 'uploadThumbnailResult exists'); const { uri: thumbnailURI, id: thumbnailID } = uploadThumbnailResult; const { thumbnailEncryptionKey, thumbHash: thumbnailThumbHash } = processedMedia; if (processedMedia.mediaType === 'encrypted_video') { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailBlobURI: thumbnailURI, thumbnailEncryptionKey, thumbnailThumbHash, }, }; } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailURI, thumbnailThumbHash, }, }; } } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbHash: processedMedia.thumbHash, }, }; } // 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(...encryptionSteps); 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 (filesToDispose.length > 0) { // 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 filesToDispose.forEach(shouldDisposePath => { 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); 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); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(cleanupPromises); return await onUploadFinished(mediaMissionResult); } 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: { [key: string]: mixed } = {}; 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 uploadTask = FileSystem.createUploadTask( url, path, { uploadType: FileSystem.FileSystemUploadType.MULTIPART, fieldName: 'multimedia', headers: { Accept: 'application/json', }, parameters, }, uploadProgress => { if (options && options.onProgress) { const { totalByteSent, totalBytesExpectedToSend } = uploadProgress; options.onProgress(totalByteSent / totalBytesExpectedToSend); } }, ); if (options && options.abortHandler) { options.abortHandler(() => uploadTask.cancelAsync()); } try { const response = await uploadTask.uploadAsync(); return JSON.parse(response.body); } catch (e) { throw new Error( `Failed to upload blob: ${ getMessageForException(e) ?? 'unknown error' }`, ); } }; 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: ClientMediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: ids.serverID, uploadLocalID: ids.localID, messageLocalID: ids.localMessageID, id: generateReportID(), }; this.props.dispatch({ type: queueReportsActionType, payload: { reports: [report], }, }); } messageHasUploadFailure = (localMessageID: string): boolean => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } return values(pendingUploads).some(upload => upload.failed); }; editInputMessage = (params: EditInputBarMessageParameters) => { this.editInputBarCallbacks.forEach(addEditInputBarCallback => addEditInputBarCallback(params), ); }; addEditInputMessageListener = ( callbackEditInputBar: (params: EditInputBarMessageParameters) => void, ) => { this.editInputBarCallbacks.push(callbackEditInputBar); }; removeEditInputMessageListener = ( callbackEditInputBar: (params: EditInputBarMessageParameters) => void, ) => { this.editInputBarCallbacks = this.editInputBarCallbacks.filter( candidate => candidate !== callbackEditInputBar, ); }; retryTextMessage = async ( rawMessageInfo: RawTextMessageInfo, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ) => { await this.sendTextMessage( { ...rawMessageInfo, time: Date.now(), }, threadInfo, parentThreadInfo, ); }; retryMultimediaMessage = async ( rawMessageInfo: RawMultimediaMessageInfo, localMessageID: string, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ): Promise => { const pendingUploads = this.state.pendingUploads[localMessageID] ?? {}; const now = Date.now(); this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const updateMedia = (media: $ReadOnlyArray): T[] => media.map(singleMedia => { invariant( singleMedia.type === 'photo' || singleMedia.type === 'video', 'Retry selection must be unencrypted', ); 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, threadInfo); }; retryMessage = async ( localMessageID: string, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); if (rawMessageInfo.type === messageTypes.TEXT) { await this.retryTextMessage(rawMessageInfo, threadInfo, parentThreadInfo); } 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): Promise<{ +steps: $ReadOnlyArray, +result: ?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); }); } setPendingThreadUpdateHandler = ( threadID: string, pendingThreadUpdateHandler: ?( - ThreadInfo | MinimallyEncodedThreadInfo, + LegacyThreadInfo | MinimallyEncodedThreadInfo, ) => mixed, ) => { if (!pendingThreadUpdateHandler) { this.pendingThreadUpdateHandlers.delete(threadID); } else { this.pendingThreadUpdateHandlers.set( threadID, pendingThreadUpdateHandler, ); } }; render(): React.Node { 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 viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nextLocalID = useNextLocalID(); 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 callBlobServiceUpload = useBlobServiceUpload(); const callSendMultimediaMessage = useSendMultimediaMessage(); const callSendTextMessage = useSendTextMessage(); const callNewThread = useNewThread(); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); const mediaReportsEnabled = useIsReportEnabled('mediaReports'); const staffCanSee = useStaffCanSee(); const textMessageCreationSideEffectsFunc = useMessageCreationSideEffectsFunc(messageTypes.TEXT); return ( ); }); export default ConnectedInputStateContainer; diff --git a/native/input/input-state.js b/native/input/input-state.js index 50f8abacc..98a42b8be 100644 --- a/native/input/input-state.js +++ b/native/input/input-state.js @@ -1,73 +1,73 @@ // @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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } 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 EditInputBarMessageParameters = { +message: string, +mode: 'prepend' | 'replace', }; export type InputState = { +pendingUploads: PendingMultimediaUploads, +sendTextMessage: ( messageInfo: RawTextMessageInfo, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ) => Promise, +sendMultimediaMessage: ( selections: $ReadOnlyArray, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, ) => Promise, +editInputMessage: (params: EditInputBarMessageParameters) => void, +addEditInputMessageListener: ( (params: EditInputBarMessageParameters) => void, ) => void, +removeEditInputMessageListener: ( (params: EditInputBarMessageParameters) => void, ) => void, +messageHasUploadFailure: (localMessageID: string) => boolean, +retryMessage: ( localMessageID: string, - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, ) => Promise, +registerSendCallback: (() => void) => void, +unregisterSendCallback: (() => void) => void, +uploadInProgress: () => boolean, +reportURIDisplayed: (uri: string, loaded: boolean) => void, +setPendingThreadUpdateHandler: ( threadID: string, pendingThreadUpdateHandler: ?( - ThreadInfo | MinimallyEncodedThreadInfo, + LegacyThreadInfo | MinimallyEncodedThreadInfo, ) => mixed, ) => void, +scrollToMessage: (messageKey: string) => void, +addScrollToMessageListener: ((messageKey: string) => void) => void, +removeScrollToMessageListener: ((messageKey: string) => void) => void, }; const InputStateContext: React.Context = React.createContext(null); export { InputStateContext }; diff --git a/native/invite-links/manage-public-link-screen.react.js b/native/invite-links/manage-public-link-screen.react.js index f2f84bdde..046ce25e3 100644 --- a/native/invite-links/manage-public-link-screen.react.js +++ b/native/invite-links/manage-public-link-screen.react.js @@ -1,224 +1,224 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import { inviteLinkURL } from 'lib/facts/links.js'; import { useInviteLinksActions } from 'lib/hooks/invite-links.js'; import { primaryInviteLinksSelector } from 'lib/selectors/invite-links-selectors.js'; import { defaultErrorMessage, inviteLinkErrorMessages, } from 'lib/shared/invite-links.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import Button from '../components/button.react.js'; import TextInput from '../components/text-input.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'; import Alert from '../utils/alert.js'; export type ManagePublicLinkScreenParams = { - +community: ThreadInfo | MinimallyEncodedThreadInfo, + +community: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { +navigation: RootNavigationProp<'ManagePublicLink'>, +route: NavigationRoute<'ManagePublicLink'>, }; function ManagePublicLinkScreen(props: Props): React.Node { const { community } = props.route.params; const inviteLink = useSelector(primaryInviteLinksSelector)[community.id]; const { error, isLoading, name, setName, createOrUpdateInviteLink, disableInviteLink, } = useInviteLinksActions(community.id, inviteLink); const styles = useStyles(unboundStyles); let errorComponent = null; if (error) { errorComponent = ( {inviteLinkErrorMessages[error] ?? defaultErrorMessage} ); } const onDisableButtonClick = React.useCallback(() => { Alert.alert( 'Disable public link', 'Are you sure you want to disable your public link?\n' + '\n' + 'Other communities will be able to claim the same URL.', [ { text: 'Confirm disable', style: 'destructive', onPress: disableInviteLink, }, { text: 'Cancel', }, ], { cancelable: true, }, ); }, [disableInviteLink]); let disablePublicLinkSection = null; if (inviteLink) { disablePublicLinkSection = ( You may also disable the community public link. ); } return ( Invite links make it easy for your friends to join your community. Anybody who knows your community’s invite link will be able to join it. Note that if you change your public link’s URL, other communities will be able to claim the old URL. INVITE URL {inviteLinkURL('')} {errorComponent} {disablePublicLinkSection} ); } const unboundStyles = { sectionTitle: { fontSize: 14, fontWeight: '400', lineHeight: 20, color: 'modalBackgroundLabel', paddingHorizontal: 16, paddingBottom: 4, }, section: { borderBottomColor: 'modalSeparator', borderBottomWidth: 1, borderTopColor: 'modalSeparator', borderTopWidth: 1, backgroundColor: 'modalForeground', padding: 16, marginBottom: 24, }, disableLinkSection: { marginTop: 16, }, sectionText: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'modalBackgroundLabel', }, withMargin: { marginBottom: 12, }, inviteLink: { flexDirection: 'row', alignItems: 'center', marginBottom: 8, }, inviteLinkPrefix: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'disabledButtonText', marginRight: 2, }, input: { color: 'panelForegroundLabel', borderColor: 'panelSecondaryForegroundBorder', borderWidth: 1, borderRadius: 8, paddingVertical: 13, paddingHorizontal: 16, flex: 1, }, button: { borderRadius: 8, paddingVertical: 12, paddingHorizontal: 24, marginTop: 8, }, buttonPrimary: { backgroundColor: 'purpleButton', }, destructiveButton: { borderWidth: 1, borderRadius: 8, borderColor: 'vibrantRedButton', }, destructiveButtonText: { fontSize: 16, fontWeight: '500', lineHeight: 24, color: 'vibrantRedButton', textAlign: 'center', }, buttonText: { color: 'whiteText', textAlign: 'center', fontWeight: '500', fontSize: 16, lineHeight: 24, }, error: { fontSize: 12, fontWeight: '400', lineHeight: 18, textAlign: 'center', color: 'redText', }, }; export default ManagePublicLinkScreen; diff --git a/native/invite-links/view-invite-links-screen.react.js b/native/invite-links/view-invite-links-screen.react.js index 6c96af6dc..e52f2d29b 100644 --- a/native/invite-links/view-invite-links-screen.react.js +++ b/native/invite-links/view-invite-links-screen.react.js @@ -1,170 +1,170 @@ // @flow import Clipboard from '@react-native-clipboard/clipboard'; import * as React from 'react'; import { Text, View } from 'react-native'; import { TouchableOpacity } from 'react-native-gesture-handler'; import { inviteLinkURL } from 'lib/facts/links.js'; import { primaryInviteLinksSelector } from 'lib/selectors/invite-links-selectors.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { InviteLink } from 'lib/types/link-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import SingleLine from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { ManagePublicLinkRouteName, type NavigationRoute, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles, useColors } from '../themes/colors.js'; export type ViewInviteLinksScreenParams = { - +community: ThreadInfo | MinimallyEncodedThreadInfo, + +community: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { +navigation: RootNavigationProp<'ViewInviteLinks'>, +route: NavigationRoute<'ViewInviteLinks'>, }; const confirmCopy = () => displayActionResultModal('copied!'); function ViewInviteLinksScreen(props: Props): React.Node { const { community } = props.route.params; const inviteLink: ?InviteLink = useSelector(primaryInviteLinksSelector)[ community.id ]; const styles = useStyles(unboundStyles); const { modalForegroundLabel } = useColors(); const linkUrl = inviteLinkURL(inviteLink?.name ?? ''); const onPressCopy = React.useCallback(() => { Clipboard.setString(linkUrl); setTimeout(confirmCopy); }, [linkUrl]); const { navigate } = props.navigation; const onEditButtonClick = React.useCallback(() => { navigate<'ManagePublicLink'>({ name: ManagePublicLinkRouteName, params: { community, }, }); }, [community, navigate]); const canManageLinks = threadHasPermission( community, threadPermissions.MANAGE_INVITE_LINKS, ); let publicLinkSection = null; if (inviteLink || canManageLinks) { let description; if (canManageLinks) { description = ( <> Public links allow unlimited uses and never expire. Edit public link ); } else { description = ( Share this invite link to help your friends join your community! ); } publicLinkSection = ( <> PUBLIC LINK {linkUrl} Copy {description} ); } return {publicLinkSection}; } const unboundStyles = { container: { flex: 1, paddingTop: 24, }, sectionTitle: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalBackgroundLabel', paddingHorizontal: 16, paddingBottom: 4, }, section: { borderBottomColor: 'modalSeparator', borderBottomWidth: 1, borderTopColor: 'modalSeparator', borderTopWidth: 1, backgroundColor: 'modalForeground', padding: 16, }, link: { paddingHorizontal: 16, paddingVertical: 9, marginBottom: 16, backgroundColor: 'inviteLinkButtonBackground', borderRadius: 20, flexDirection: 'row', justifyContent: 'space-between', }, linkText: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'inviteLinkLinkColor', flex: 1, }, button: { flexDirection: 'row', alignItems: 'center', }, copy: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalForegroundLabel', paddingLeft: 8, }, details: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalForegroundLabel', }, editLinkButton: { color: 'purpleLink', }, }; export default ViewInviteLinksScreen; diff --git a/native/keyboard/keyboard-input-host.react.js b/native/keyboard/keyboard-input-host.react.js index 311d743c1..1aeb52a0f 100644 --- a/native/keyboard/keyboard-input-host.react.js +++ b/native/keyboard/keyboard-input-host.react.js @@ -1,123 +1,123 @@ // @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 { LegacyThreadInfo } 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'; const unboundStyles = { // This is a special style needed by 'react-native-keyboard-input': // https://github.com/wix/react-native-keyboard-input/blob/acb3a58e96988026f449b48e8b49f49164684d9f/src/KeyboardAccessoryView.js#L115 kbInitialProps: { backgroundColor: 'listBackground', }, }; type BaseProps = { +textInputRef?: ?React.ElementRef, }; type Props = { ...BaseProps, // Redux state +styles: $ReadOnly, +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): boolean { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } render(): React.Node { 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, + +threadInfo: ?LegacyThreadInfo, }, ) => { 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 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 fca12f44d..7f10e02c3 100644 --- a/native/keyboard/keyboard-state-container.react.js +++ b/native/keyboard/keyboard-state-container.react.js @@ -1,149 +1,150 @@ // @flow import * as React from 'react'; import { Platform } from 'react-native'; import { KeyboardUtils } from 'react-native-keyboard-input'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } 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 | ?MinimallyEncodedThreadInfo, + +mediaGalleryThread: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, +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 | MinimallyEncodedThreadInfo) => void = - (thread: ThreadInfo | MinimallyEncodedThreadInfo) => { - this.setState({ - mediaGalleryOpen: true, - mediaGalleryThread: thread, - }); - }; + showMediaGallery: ( + thread: LegacyThreadInfo | MinimallyEncodedThreadInfo, + ) => void = (thread: LegacyThreadInfo | MinimallyEncodedThreadInfo) => { + this.setState({ + mediaGalleryOpen: true, + mediaGalleryThread: thread, + }); + }; hideMediaGallery: () => void = () => { this.setState({ mediaGalleryOpen: false, mediaGalleryThread: null, renderKeyboardInputHost: false, }); }; - getMediaGalleryThread: () => ?ThreadInfo | ?MinimallyEncodedThreadInfo = () => - this.state.mediaGalleryThread; + getMediaGalleryThread: () => ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo = + () => this.state.mediaGalleryThread; render(): React.Node { 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 396710158..2898dabe0 100644 --- a/native/keyboard/keyboard-state.js +++ b/native/keyboard/keyboard-state.js @@ -1,22 +1,24 @@ // @flow import * as React from 'react'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; export type KeyboardState = { +keyboardShowing: boolean, +dismissKeyboard: () => void, +dismissKeyboardIfShowing: () => boolean, +systemKeyboardShowing: boolean, +mediaGalleryOpen: boolean, - +showMediaGallery: (thread: ThreadInfo | MinimallyEncodedThreadInfo) => void, + +showMediaGallery: ( + thread: LegacyThreadInfo | MinimallyEncodedThreadInfo, + ) => void, +hideMediaGallery: () => void, - +getMediaGalleryThread: () => ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +getMediaGalleryThread: () => ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, }; const KeyboardContext: React.Context = React.createContext(null); export { KeyboardContext }; diff --git a/native/markdown/rules.react.js b/native/markdown/rules.react.js index 12dd1688a..47cc088e9 100644 --- a/native/markdown/rules.react.js +++ b/native/markdown/rules.react.js @@ -1,449 +1,449 @@ // @flow import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import { Text, View, Platform } from 'react-native'; import * as SimpleMarkdown from 'simple-markdown'; import * as SharedMarkdown from 'lib/shared/markdown.js'; import { chatMentionRegex } from 'lib/shared/mention-utils.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { - ThreadInfo, + LegacyThreadInfo, ChatMentionCandidates, RelativeMemberInfo, } from 'lib/types/thread-types.js'; import MarkdownChatMention from './markdown-chat-mention.react.js'; import MarkdownLink from './markdown-link.react.js'; import MarkdownParagraph from './markdown-paragraph.react.js'; import MarkdownSpoiler from './markdown-spoiler.react.js'; import MarkdownUserMention from './markdown-user-mention.react.js'; import { getMarkdownStyles } from './styles.js'; export type MarkdownRules = { +simpleMarkdownRules: SharedMarkdown.ParserRules, +emojiOnlyFactor: ?number, // We need to use a Text container for Entry because it needs to match up // exactly with TextInput. However, if we use a Text container, we can't // support styles for things like blockQuote, which rely on rendering as a // View, and Views can't be nested inside Texts without explicit height and // width +container: 'View' | 'Text', }; // Entry requires a seamless transition between Markdown and TextInput // components, so we can't do anything that would change the position of text const inlineMarkdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const simpleMarkdownRules = { // Matches 'https://google.com' during parse phase and returns a 'link' node url: { ...SimpleMarkdown.defaultRules.url, // simple-markdown is case-sensitive, but we don't want to be match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...SimpleMarkdown.defaultRules.link, match: () => null, react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { return ( {output(node.content, state)} ); }, }, // Each line gets parsed into a 'paragraph' node. The AST returned by the // parser will be an array of one or more 'paragraph' nodes paragraph: { ...SimpleMarkdown.defaultRules.paragraph, // simple-markdown's default RegEx collapses multiple newlines into one. // We want to keep the newlines, but when rendering within a View, we // strip just one trailing newline off, since the View adds vertical // spacing between its children match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } else if (state.container === 'View') { return SharedMarkdown.paragraphStripTrailingNewlineRegex.exec(source); } else { return SharedMarkdown.paragraphRegex.exec(source); } }, parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { let content = capture[1]; if (state.container === 'View') { // React Native renders empty lines with less height. We want to // preserve the newline characters, so we replace empty lines with a // single space content = content.replace(/^$/m, ' '); } return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, // This is the leaf node in the AST returned by the parse phase text: SimpleMarkdown.defaultRules.text, }; return { simpleMarkdownRules, emojiOnlyFactor: null, container: 'Text', }; }); // We allow the most markdown features for TextMessage, which doesn't have the // same requirements as Entry const fullMarkdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const inlineRules = inlineMarkdownRules(useDarkStyle); const simpleMarkdownRules = { ...inlineRules.simpleMarkdownRules, // Matches '' during parse phase and returns a 'link' // node autolink: SimpleMarkdown.defaultRules.autolink, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...inlineRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, mailto: SimpleMarkdown.defaultRules.mailto, em: { ...SimpleMarkdown.defaultRules.em, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, strong: { ...SimpleMarkdown.defaultRules.strong, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, u: { ...SimpleMarkdown.defaultRules.u, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, del: { ...SimpleMarkdown.defaultRules.del, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, spoiler: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: SimpleMarkdown.inlineRegex(SharedMarkdown.spoilerRegex), parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { const content = capture[1]; return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( ), }, inlineCode: { ...SimpleMarkdown.defaultRules.inlineCode, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex( SharedMarkdown.headingStripFollowingNewlineRegex, ), // eslint-disable-next-line react/display-name react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { const headingStyle = styles['h' + node.level]; return ( {output(node.content, state)} ); }, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SharedMarkdown.matchBlockQuote( SharedMarkdown.blockQuoteStripFollowingNewlineRegex, ), parse: SharedMarkdown.parseBlockQuote, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => { const { isNestedQuote } = state; const backgroundColor = isNestedQuote ? '#00000000' : '#00000066'; const borderLeftColor = (Platform.select({ ios: '#00000066', default: isNestedQuote ? '#00000066' : '#000000A3', }): string); return ( {output(node.content, { ...state, isNestedQuote: true })} ); }, }, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex( SharedMarkdown.codeBlockStripTrailingNewlineRegex, ), parse(capture: SharedMarkdown.Capture) { return { content: capture[1].replace(/^ {4}/gm, ''), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex( SharedMarkdown.fenceStripTrailingNewlineRegex, ), parse: (capture: SharedMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SharedMarkdown.Capture) => { const jsonCapture: SharedMarkdown.JSONCapture = (capture: any); return { type: 'codeBlock', content: SharedMarkdown.jsonPrint(jsonCapture), }; }, }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { const children = node.items.map((item, i) => { const content = output(item, state); const bulletValue = node.ordered ? node.start + i + '. ' : '\u2022 '; return ( {bulletValue} {content} ); }); return {children}; }, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...inlineRules, simpleMarkdownRules, emojiOnlyFactor: 2, container: 'View', }; }); function useTextMessageRulesFunc( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, chatMentionCandidates: ChatMentionCandidates, ): (useDarkStyle: boolean) => MarkdownRules { const { members } = threadInfo; return React.useMemo( () => _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => textMessageRules(members, chatMentionCandidates, useDarkStyle), ), [members, chatMentionCandidates], ); } function textMessageRules( members: $ReadOnlyArray, chatMentionCandidates: ChatMentionCandidates, useDarkStyle: boolean, ): MarkdownRules { const baseRules = fullMarkdownRules(useDarkStyle); const membersMap = SharedMarkdown.createMemberMapForUserMentions(members); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, userMention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchUserMentions(membersMap), parse: (capture: SharedMarkdown.Capture) => SharedMarkdown.parseUserMentions(membersMap, capture), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, chatMention: { ...SimpleMarkdown.defaultRules.strong, match: SimpleMarkdown.inlineRegex(chatMentionRegex), parse: (capture: SharedMarkdown.Capture) => SharedMarkdown.parseChatMention(chatMentionCandidates, capture), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => node.hasAccessToChat ? ( {node.content} ) : ( {node.content} ), }, }, }; } let defaultTextMessageRules = null; function getDefaultTextMessageRules( overrideDefaultChatMentionCandidates: ChatMentionCandidates = {}, ): MarkdownRules { if (Object.keys(overrideDefaultChatMentionCandidates).length > 0) { return textMessageRules([], overrideDefaultChatMentionCandidates, false); } if (!defaultTextMessageRules) { defaultTextMessageRules = textMessageRules([], {}, false); } return defaultTextMessageRules; } export { inlineMarkdownRules, useTextMessageRulesFunc, getDefaultTextMessageRules, }; diff --git a/native/media/chat-camera-modal.react.js b/native/media/chat-camera-modal.react.js index 25d73c942..9dc873d3c 100644 --- a/native/media/chat-camera-modal.react.js +++ b/native/media/chat-camera-modal.react.js @@ -1,42 +1,42 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { PhotoCapture } from 'lib/types/media-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import CameraModal from './camera-modal.react.js'; import { InputStateContext } from '../input/input-state.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; export type ChatCameraModalParams = { +presentedFrom: string, - +thread: ThreadInfo | MinimallyEncodedThreadInfo, + +thread: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { +navigation: AppNavigationProp<'ChatCameraModal'>, +route: NavigationRoute<'ChatCameraModal'>, }; function ChatCameraModal(props: Props): React.Node { const { navigation, route } = props; const { thread } = route.params; const inputState = React.useContext(InputStateContext); const sendPhoto = React.useCallback( (capture: PhotoCapture) => { invariant(inputState, 'inputState should be set'); inputState.sendMultimediaMessage([capture], thread); }, [inputState, thread], ); return ; } export default ChatCameraModal; diff --git a/native/media/media-gallery-keyboard.react.js b/native/media/media-gallery-keyboard.react.js index 4f14f2f44..c4b3d80ea 100644 --- a/native/media/media-gallery-keyboard.react.js +++ b/native/media/media-gallery-keyboard.react.js @@ -1,685 +1,685 @@ // @flow import * as ImagePicker from 'expo-image-picker'; import * as MediaLibrary from 'expo-media-library'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text, FlatList, ActivityIndicator, Animated, Easing, Platform, } from 'react-native'; import { KeyboardRegistry } from 'react-native-keyboard-input'; import { Provider } from 'react-redux'; import { extensionFromFilename, filenameFromPathOrURI, } from 'lib/media/file-utils.js'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils.js'; import type { MediaLibrarySelection } from 'lib/types/media-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { getCompatibleMediaURI } from './identifier-utils.js'; import MediaGalleryMedia from './media-gallery-media.react.js'; import SendMediaButton from './send-media-button.react.js'; import Button from '../components/button.react.js'; import type { DimensionsInfo } from '../redux/dimensions-updater.react.js'; import { store } from '../redux/redux-setup.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { LayoutEvent, ViewableItemsChange, } from '../types/react-native.js'; import type { ViewStyle } from '../types/styles.js'; const animationSpec = { duration: 400, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; const unboundStyles = { container: { backgroundColor: 'listBackground', position: 'absolute', left: 0, right: 0, top: 0, }, galleryHeader: { height: 56, borderTopWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, }, galleryHeaderTitle: { color: 'modalForegroundLabel', fontSize: 14, fontWeight: '500', }, nativePickerButton: { backgroundColor: 'rgba(255, 255, 255, 0.08)', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, }, nativePickerButtonLabel: { color: 'modalButtonLabel', fontSize: 12, fontWeight: '500', }, galleryContainer: { flex: 1, alignItems: 'center', flexDirection: 'row', }, error: { color: 'listBackgroundLabel', flex: 1, fontSize: 28, textAlign: 'center', }, loadingIndicator: { flex: 1, }, sendButtonContainer: { bottom: 20, position: 'absolute', right: 30, }, separator: { width: 2, }, }; type BaseProps = { - +threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, }; type Props = { ...BaseProps, // Redux state +dimensions: DimensionsInfo, +foreground: boolean, +colors: Colors, +styles: $ReadOnly, }; type State = { +selections: ?$ReadOnlyArray, +error: ?string, +containerHeight: ?number, // null means end reached; undefined means no fetch yet +cursor: ?string, +queuedMediaURIs: ?Set, +focusedMediaURI: ?string, +dimensions: DimensionsInfo, }; class MediaGalleryKeyboard extends React.PureComponent { mounted = false; fetchingPhotos = false; flatList: ?FlatList; viewableIndices: number[] = []; queueModeProgress: Animated.Value = new Animated.Value(0); sendButtonStyle: ViewStyle; mediaSelected = false; constructor(props: Props) { super(props); const sendButtonScale = this.queueModeProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.3, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.queueModeProgress, transform: [{ scale: sendButtonScale }], }; this.state = { selections: null, error: null, containerHeight: null, cursor: undefined, queuedMediaURIs: null, focusedMediaURI: null, dimensions: props.dimensions, }; } static getDerivedStateFromProps(props: Props): Partial { // We keep this in state since we pass this.state as // FlatList's extraData prop return { dimensions: props.dimensions }; } componentDidMount() { this.mounted = true; this.fetchPhotos(); } componentWillUnmount() { this.mounted = false; } componentDidUpdate(prevProps: Props, prevState: State) { const { queuedMediaURIs } = this.state; const prevQueuedMediaURIs = prevState.queuedMediaURIs; if (queuedMediaURIs && !prevQueuedMediaURIs) { Animated.timing(this.queueModeProgress, { ...animationSpec, toValue: 1, }).start(); } else if (!queuedMediaURIs && prevQueuedMediaURIs) { Animated.timing(this.queueModeProgress, { ...animationSpec, toValue: 0, }).start(); } const { flatList, viewableIndices } = this; const { selections, focusedMediaURI } = this.state; let scrollingSomewhere = false; if (flatList && selections) { let newURI; if (focusedMediaURI && focusedMediaURI !== prevState.focusedMediaURI) { newURI = focusedMediaURI; } else if ( queuedMediaURIs && (!prevQueuedMediaURIs || queuedMediaURIs.size > prevQueuedMediaURIs.size) ) { const flowMadeMeDoThis = queuedMediaURIs; for (const queuedMediaURI of flowMadeMeDoThis) { if (prevQueuedMediaURIs && prevQueuedMediaURIs.has(queuedMediaURI)) { continue; } newURI = queuedMediaURI; break; } } let index; if (newURI !== null && newURI !== undefined) { index = selections.findIndex(({ uri }) => uri === newURI); } if (index !== null && index !== undefined) { if (index === viewableIndices[0]) { scrollingSomewhere = true; flatList.scrollToIndex({ index }); } else if (index === viewableIndices[viewableIndices.length - 1]) { scrollingSomewhere = true; flatList.scrollToIndex({ index, viewPosition: 1 }); } } } if (this.props.foreground && !prevProps.foreground) { this.fetchPhotos(); } if ( !scrollingSomewhere && this.flatList && this.state.selections && prevState.selections && this.state.selections.length > 0 && prevState.selections.length > 0 && this.state.selections[0].uri !== prevState.selections[0].uri ) { this.flatList.scrollToIndex({ index: 0 }); } } guardedSetState(change: Partial) { if (this.mounted) { this.setState(change); } } async fetchPhotos(after?: ?string) { if (this.fetchingPhotos) { return; } this.fetchingPhotos = true; try { const hasPermission = await this.getPermissions(); if (!hasPermission) { return; } const { assets, endCursor, hasNextPage } = await MediaLibrary.getAssetsAsync({ first: 20, after, mediaType: [ MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video, ], sortBy: [MediaLibrary.SortBy.modificationTime], }); let firstRemoved = false, lastRemoved = false; const mediaURIs = this.state.selections ? this.state.selections.map(({ uri }) => uri) : []; const existingURIs = new Set(mediaURIs); let first = true; const selections = assets .map(asset => { const { id, height, width, filename, mediaType, duration } = asset; const isVideo = mediaType === MediaLibrary.MediaType.video; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(filename), ); if (existingURIs.has(uri)) { if (first) { firstRemoved = true; } lastRemoved = true; first = false; return null; } first = false; lastRemoved = false; existingURIs.add(uri); if (isVideo) { return { step: 'video_library', dimensions: { height, width }, uri, filename, mediaNativeID: id, duration, selectTime: 0, sendTime: 0, retries: 0, }; } else { return { step: 'photo_library', dimensions: { height, width }, uri, filename, mediaNativeID: id, selectTime: 0, sendTime: 0, retries: 0, }; } }) .filter(Boolean); let appendOrPrepend = after ? 'append' : 'prepend'; if (firstRemoved && !lastRemoved) { appendOrPrepend = 'append'; } else if (!firstRemoved && lastRemoved) { appendOrPrepend = 'prepend'; } let newSelections = selections; if (this.state.selections) { if (appendOrPrepend === 'prepend') { newSelections = [...newSelections, ...this.state.selections]; } else { newSelections = [...this.state.selections, ...newSelections]; } } this.guardedSetState({ selections: newSelections, error: null, cursor: hasNextPage ? endCursor : null, }); } catch (e) { this.guardedSetState({ selections: null, error: 'something went wrong :(', }); } this.fetchingPhotos = false; } openNativePicker = async () => { try { const { assets, canceled } = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.All, allowsEditing: false, allowsMultipleSelection: true, // maximum quality is 1 - it disables compression quality: 1, // we don't want to compress videos at this point videoExportPreset: ImagePicker.VideoExportPreset.Passthrough, }); if (canceled || assets.length === 0) { return; } const selections = assets.map(asset => { const { width, height, fileName, type, duration, assetId: mediaNativeID, } = asset; const isVideo = type === 'video'; const filename = fileName || filenameFromPathOrURI(asset.uri) || ''; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(filename), ); if (isVideo) { return { step: 'video_library', dimensions: { height, width }, uri, filename, mediaNativeID, duration, selectTime: 0, sendTime: 0, retries: 0, }; } else { return { step: 'photo_library', dimensions: { height, width }, uri, filename, mediaNativeID, selectTime: 0, sendTime: 0, retries: 0, }; } }); this.sendMedia(selections); } catch (e) { if (__DEV__) { console.warn(e); } this.guardedSetState({ selections: null, error: 'something went wrong :(', }); } }; async getPermissions(): Promise { const { granted } = await MediaLibrary.requestPermissionsAsync(); if (!granted) { this.guardedSetState({ error: "don't have permission :(" }); } return granted; } get queueModeActive(): boolean { return !!this.state.queuedMediaURIs; } renderItem = (row: { +item: MediaLibrarySelection, ... }): React.Node => { const { containerHeight, queuedMediaURIs } = this.state; invariant(containerHeight, 'should be set'); const { uri } = row.item; const isQueued = !!(queuedMediaURIs && queuedMediaURIs.has(uri)); const { queueModeActive } = this; return ( ); }; ItemSeparator = (): React.Node => { return ; }; static keyExtractor = (item: MediaLibrarySelection): string => { return item.uri; }; GalleryHeader = (): React.Node => ( Photos ); render(): React.Node { let content; const { selections, error, containerHeight } = this.state; const bottomOffsetStyle: ViewStyle = { marginBottom: this.props.dimensions.bottomInset, }; if (selections && selections.length > 0 && containerHeight) { content = ( ); } else if (selections && containerHeight) { content = ( no media was found! ); } else if (error) { content = ( {error} ); } else { content = ( ); } const { queuedMediaURIs } = this.state; const queueCount = queuedMediaURIs ? queuedMediaURIs.size : 0; const bottomInset: number = Platform.select({ ios: -1 * this.props.dimensions.bottomInset, default: 0, }); const containerStyle = { bottom: bottomInset }; return ( {content} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onContainerLayout = (event: LayoutEvent) => { this.guardedSetState({ containerHeight: event.nativeEvent.layout.height }); }; onEndReached = () => { const { cursor } = this.state; if (cursor !== null) { this.fetchPhotos(cursor); } }; onViewableItemsChanged = (info: ViewableItemsChange) => { const viewableIndices = []; for (const { index } of info.viewableItems) { if (index !== null && index !== undefined) { viewableIndices.push(index); } } this.viewableIndices = viewableIndices; }; setMediaQueued = (selection: MediaLibrarySelection, isQueued: boolean) => { this.setState((prevState: State) => { const prevQueuedMediaURIs = prevState.queuedMediaURIs ? [...prevState.queuedMediaURIs] : []; if (isQueued) { return { queuedMediaURIs: new Set([...prevQueuedMediaURIs, selection.uri]), focusedMediaURI: null, }; } const queuedMediaURIs = prevQueuedMediaURIs.filter( uri => uri !== selection.uri, ); if (queuedMediaURIs.length < prevQueuedMediaURIs.length) { return { queuedMediaURIs: new Set(queuedMediaURIs), focusedMediaURI: null, }; } return null; }); }; setFocus = (selection: MediaLibrarySelection, isFocused: boolean) => { const { uri } = selection; if (isFocused) { this.setState({ focusedMediaURI: uri }); } else if (this.state.focusedMediaURI === uri) { this.setState({ focusedMediaURI: null }); } }; sendSingleMedia = (selection: MediaLibrarySelection) => { this.sendMedia([selection]); }; sendQueuedMedia = () => { const { selections, queuedMediaURIs } = this.state; if (!selections || !queuedMediaURIs) { return; } const queuedSelections = []; for (const uri of queuedMediaURIs) { for (const selection of selections) { if (selection.uri === uri) { queuedSelections.push(selection); break; } } } this.sendMedia(queuedSelections); }; sendMedia(selections: $ReadOnlyArray) { if (this.mediaSelected) { return; } this.mediaSelected = true; const now = Date.now(); const timeProps = { selectTime: now, sendTime: now, }; const selectionsWithTime = selections.map(selection => ({ ...selection, ...timeProps, })); KeyboardRegistry.onItemSelected(mediaGalleryKeyboardName, { selections: selectionsWithTime, threadInfo: this.props.threadInfo, }); } } const mediaGalleryKeyboardName = 'MediaGalleryKeyboard'; function ConnectedMediaGalleryKeyboard(props: BaseProps) { const dimensions = useSelector(state => state.dimensions); const foreground = useIsAppForegrounded(); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); } function ReduxMediaGalleryKeyboard(props: BaseProps) { return ( ); } KeyboardRegistry.registerKeyboard( mediaGalleryKeyboardName, () => ReduxMediaGalleryKeyboard, ); export { mediaGalleryKeyboardName }; diff --git a/native/navigation/nav-selectors.js b/native/navigation/nav-selectors.js index eedabf149..8ebfbbdb6 100644 --- a/native/navigation/nav-selectors.js +++ b/native/navigation/nav-selectors.js @@ -1,446 +1,446 @@ // @flow import type { PossiblyStaleNavigationState } from '@react-navigation/core'; import { useRoute } from '@react-navigation/native'; import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import { createSelector } from 'reselect'; import { nonThreadCalendarFiltersSelector } from 'lib/selectors/calendar-filter-selectors.js'; import { currentCalendarQuery } from 'lib/selectors/nav-selectors.js'; import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { CalendarFilter } from 'lib/types/filter-types.js'; import type { ComposableMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { GlobalTheme } from 'lib/types/theme-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { NavContextType } from './navigation-context.js'; import { NavContext } from './navigation-context.js'; import { getStateFromNavigatorRoute, getThreadIDFromRoute, currentLeafRoute, } from './navigation-utils.js'; import { AppRouteName, TabNavigatorRouteName, MessageListRouteName, ChatRouteName, CalendarRouteName, ThreadPickerModalRouteName, ActionResultModalRouteName, accountModals, scrollBlockingModals, chatRootModals, threadRoutes, CommunityDrawerNavigatorRouteName, MessageResultsScreenRouteName, MessageSearchRouteName, } from './route-names.js'; import type { RemoveEditMode } from '../chat/message-list-types.js'; import { useSelector } from '../redux/redux-utils.js'; import type { NavPlusRedux } from '../types/selector-types.js'; const baseCreateIsForegroundSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = (routeName: string) => createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } return navigationState.routes[navigationState.index].name === routeName; }, ); const createIsForegroundSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = _memoize( baseCreateIsForegroundSelector, ); function useIsAppLoggedIn(): boolean { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return false; } const { state } = navContext; return !accountModals.includes(state.routes[state.index].name); }, [navContext]); } const baseCreateActiveTabSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = (routeName: string) => createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { return false; } const appState = getStateFromNavigatorRoute(currentRootSubroute); const [firstAppSubroute] = appState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return false; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return false; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); return tabState.routes[tabState.index].name === routeName; }, ); const createActiveTabSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = _memoize( baseCreateActiveTabSelector, ); const scrollBlockingModalsClosedSelector: ( context: ?NavContextType, ) => boolean = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { return true; } const appState = getStateFromNavigatorRoute(currentRootSubroute); for (let i = appState.index; i >= 0; i--) { const route = appState.routes[i]; if (scrollBlockingModals.includes(route.name)) { return false; } } return true; }, ); function selectBackgroundIsDark( navigationState: ?PossiblyStaleNavigationState, theme: ?GlobalTheme, ): boolean { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { // Very bright... we'll call it non-dark. Doesn't matter right now since // we only use this selector for determining ActionResultModal appearance return false; } const appState = getStateFromNavigatorRoute(currentRootSubroute); let appIndex = appState.index; let currentAppSubroute = appState.routes[appIndex]; while (currentAppSubroute.name === ActionResultModalRouteName) { currentAppSubroute = appState.routes[--appIndex]; } if (scrollBlockingModals.includes(currentAppSubroute.name)) { // All the scroll-blocking chat modals have a dark background return true; } return theme === 'dark'; } function activeThread( navigationState: ?PossiblyStaleNavigationState, validRouteNames: $ReadOnlyArray, ): ?string { if (!navigationState) { return null; } let rootIndex = navigationState.index; let currentRootSubroute = navigationState.routes[rootIndex]; while (currentRootSubroute.name !== AppRouteName) { if (!chatRootModals.includes(currentRootSubroute.name)) { return null; } if (rootIndex === 0) { return null; } currentRootSubroute = navigationState.routes[--rootIndex]; } const appState = getStateFromNavigatorRoute(currentRootSubroute); const [firstAppSubroute] = appState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return null; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return null; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); const currentTabSubroute = tabState.routes[tabState.index]; if (currentTabSubroute.name !== ChatRouteName) { return null; } const chatState = getStateFromNavigatorRoute(currentTabSubroute); const currentChatSubroute = chatState.routes[chatState.index]; return getThreadIDFromRoute(currentChatSubroute, validRouteNames); } const activeThreadSelector: (context: ?NavContextType) => ?string = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState): ?string => activeThread(navigationState, threadRoutes), ); const messageListRouteNames = [MessageListRouteName]; const activeMessageListSelector: (context: ?NavContextType) => ?string = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState): ?string => activeThread(navigationState, messageListRouteNames), ); function useActiveThread(): ?string { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; return activeThread(state, threadRoutes); }, [navContext]); } function useActiveMessageList(): ?string { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; return activeThread(state, messageListRouteNames); }, [navContext]); } const calendarTabActiveSelector = createActiveTabSelector(CalendarRouteName); const threadPickerActiveSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const calendarActiveSelector: (context: ?NavContextType) => boolean = createSelector( calendarTabActiveSelector, threadPickerActiveSelector, (calendarTabActive: boolean, threadPickerActive: boolean) => calendarTabActive || threadPickerActive, ); const nativeCalendarQuery: (input: NavPlusRedux) => () => CalendarQuery = createSelector( (input: NavPlusRedux) => currentCalendarQuery(input.redux), (input: NavPlusRedux) => calendarActiveSelector(input.navContext), ( calendarQuery: (calendarActive: boolean) => CalendarQuery, calendarActive: boolean, ) => () => calendarQuery(calendarActive), ); const nonThreadCalendarQuery: (input: NavPlusRedux) => () => CalendarQuery = createSelector( nativeCalendarQuery, (input: NavPlusRedux) => nonThreadCalendarFiltersSelector(input.redux), ( calendarQuery: () => CalendarQuery, filters: $ReadOnlyArray, ) => { return (): CalendarQuery => { const query = calendarQuery(); return { startDate: query.startDate, endDate: query.endDate, filters, }; }; }, ); function useCalendarQuery(): () => CalendarQuery { const navContext = React.useContext(NavContext); return useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); } const drawerSwipeEnabledSelector: (context: ?NavContextType) => boolean = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return true; } // First, we recurse into the navigation state until we find the tab route // The tab route should always be accessible by recursing through the // first routes of each subsequent nested navigation state const [firstRootSubroute] = navigationState.routes; if (firstRootSubroute.name !== AppRouteName) { return true; } const appState = getStateFromNavigatorRoute(firstRootSubroute); const [firstAppSubroute] = appState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return true; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return true; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); // Once we have the tab state, we want to figure out if we currently have // an active StackNavigator const currentTabSubroute = tabState.routes[tabState.index]; if (!currentTabSubroute.state) { return true; } const currentTabSubrouteState = getStateFromNavigatorRoute(currentTabSubroute); if (currentTabSubrouteState.type !== 'stack') { return true; } // Finally, we want to disable the swipe gesture if there is a stack with // more than one subroute, since then the stack will have its own swipe // gesture that will conflict with the drawer's return currentTabSubrouteState.routes.length < 2; }, ); function getTabNavState( navigationState: ?PossiblyStaleNavigationState, ): ?PossiblyStaleNavigationState { if (!navigationState) { return null; } const [firstAppSubroute] = navigationState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return null; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return null; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); return tabState; } function getChatNavStateFromTabNavState( tabState: ?PossiblyStaleNavigationState, ): ?PossiblyStaleNavigationState { if (!tabState) { return null; } let chatRoute; for (const route of tabState.routes) { if (route.name === ChatRouteName) { chatRoute = route; break; } } if (!chatRoute || !chatRoute.state) { return null; } const chatRouteState = getStateFromNavigatorRoute(chatRoute); if (chatRouteState.type !== 'stack') { return null; } return chatRouteState; } function getRemoveEditMode( chatRouteState: ?PossiblyStaleNavigationState, ): ?RemoveEditMode { if (!chatRouteState) { return null; } const messageListRoute = chatRouteState.routes[chatRouteState.routes.length - 1]; if (messageListRoute.name !== MessageListRouteName) { return null; } if (!messageListRoute || !messageListRoute.params) { return null; } const removeEditMode: Function = messageListRoute.params.removeEditMode; return removeEditMode; } function useCurrentLeafRouteName(): ?string { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return undefined; } return currentLeafRoute(navContext.state).name; }, [navContext]); } function useCanEditMessageNative( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const route = useRoute(); const screenKey = route.key; const threadCreationTime = threadInfo.creationTime; const messageCreationTime = targetMessageInfo.time; const canEditInThisScreen = !screenKey.startsWith(MessageSearchRouteName) && !screenKey.startsWith(MessageResultsScreenRouteName) && messageCreationTime >= threadCreationTime; return ( useCanEditMessage(threadInfo, targetMessageInfo) && canEditInThisScreen ); } export { createIsForegroundSelector, useIsAppLoggedIn, createActiveTabSelector, scrollBlockingModalsClosedSelector, selectBackgroundIsDark, activeThreadSelector, activeMessageListSelector, useActiveThread, useActiveMessageList, calendarActiveSelector, nativeCalendarQuery, nonThreadCalendarQuery, useCalendarQuery, drawerSwipeEnabledSelector, useCurrentLeafRouteName, getRemoveEditMode, getTabNavState, getChatNavStateFromTabNavState, useCanEditMessageNative, }; diff --git a/native/navigation/subchannels-button.react.js b/native/navigation/subchannels-button.react.js index 0b8ac2f65..82151fa10 100644 --- a/native/navigation/subchannels-button.react.js +++ b/native/navigation/subchannels-button.react.js @@ -1,64 +1,64 @@ // @flow import Icon from '@expo/vector-icons/Feather.js'; import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { TouchableOpacity, Text, View } from 'react-native'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { SubchannelsListModalRouteName } from './route-names.js'; import { useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; function SubchnnelsButton(props: Props): React.Node { const styles = useStyles(unboundStyles); const { threadInfo } = props; const { navigate } = useNavigation(); const onPress = React.useCallback( () => navigate<'SubchannelsListModal'>({ name: SubchannelsListModalRouteName, params: { threadInfo }, }), [navigate, threadInfo], ); return ( Subchannels ); } const unboundStyles = { view: { flexDirection: 'row', }, label: { color: 'drawerExpandButton', fontWeight: '500', fontSize: 12, lineHeight: 18, }, iconWrapper: { height: 16, width: 16, alignItems: 'center', }, icon: { color: 'drawerExpandButton', marginRight: 2, }, }; export default SubchnnelsButton; diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index ed9b06a6e..4a5c09a64 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,741 +1,741 @@ // @flow import * as Haptics from 'expo-haptics'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, LogBox } from 'react-native'; import { Notification as InAppNotification } from 'react-native-in-app-message'; import type { DeviceTokens, SetDeviceTokenActionPayload, } from 'lib/actions/device-actions.js'; import { setDeviceTokenActionTypes, useSetDeviceToken, useSetDeviceTokenFanout, } from 'lib/actions/device-actions.js'; import { saveMessagesActionType } from 'lib/actions/message-actions.js'; import { updatesCurrentAsOfSelector, connectionSelector, deviceTokensSelector, } from 'lib/selectors/keyserver-selectors.js'; import { unreadCount, threadInfoSelector, } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ConnectionInfo } from 'lib/types/socket-types.js'; import type { GlobalTheme } from 'lib/types/theme-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import { convertNotificationMessageInfoToNewIDSchema, convertNonPendingIDToNewSchema, } from 'lib/utils/migration-utils.js'; import { type NotifPermissionAlertInfo, recordNotifPermissionAlertActionType, shouldSkipPushPermissionAlert, } from 'lib/utils/push-alerts.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import sleep from 'lib/utils/sleep.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { parseAndroidMessage, androidNotificationChannelID, handleAndroidMessage, getCommAndroidNotificationsEventEmitter, type AndroidMessage, CommAndroidNotifications, } from './android.js'; import { CommIOSNotification, type CoreIOSNotificationData, type CoreIOSNotificationDataWithRequestIdentifier, } from './comm-ios-notification.js'; import InAppNotif from './in-app-notif.react.js'; import { requestIOSPushPermissions, iosPushPermissionResponseReceived, CommIOSNotifications, getCommIOSNotificationsEventEmitter, type CoreIOSNotificationBackgroundData, } from './ios.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle.js'; import { replaceWithThreadActionType } from '../navigation/action-types.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { RootContext, type RootContextType } from '../root-context.js'; import type { EventSubscription } from '../types/react-native.js'; import Alert from '../utils/alert.js'; LogBox.ignoreLogs([ // react-native-in-app-message 'ForceTouchGestureHandler is not available', ]); type BaseProps = { +navigation: RootNavigationProp<'App'>, }; type Props = { ...BaseProps, // Navigation state +activeThread: ?string, // Redux state +unreadCount: number, +deviceTokens: { +[keyserverID: string]: ?string, }, - +threadInfos: { +[id: string]: ThreadInfo }, + +threadInfos: { +[id: string]: LegacyThreadInfo }, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +connection: ConnectionInfo, +updatesCurrentAsOf: number, +activeTheme: ?GlobalTheme, +loggedIn: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +setDeviceToken: ( input: DeviceTokens, ) => Promise, +setDeviceTokenFanout: ( deviceToken: ?string, ) => Promise, // withRootContext +rootContext: ?RootContextType, }; type State = { +inAppNotifProps: ?{ +customComponent: React.Node, +blurType: ?('xlight' | 'dark'), +onPress: () => void, }, }; class PushHandler extends React.PureComponent { state: State = { inAppNotifProps: null, }; currentState: ?string = getCurrentLifecycleState(); appStarted = 0; androidNotificationsEventSubscriptions: Array = []; androidNotificationsPermissionPromise: ?Promise = undefined; initialAndroidNotifHandled = false; openThreadOnceReceived: Set = new Set(); lifecycleSubscription: ?EventSubscription; iosNotificationEventSubscriptions: Array = []; componentDidMount() { this.appStarted = Date.now(); this.lifecycleSubscription = addLifecycleListener( this.handleAppStateChange, ); this.onForeground(); if (Platform.OS === 'ios') { const commIOSNotificationsEventEmitter = getCommIOSNotificationsEventEmitter(); this.iosNotificationEventSubscriptions.push( commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTERED_EVENT, registration => this.registerPushPermissions(registration?.deviceToken), ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTRATION_FAILED_EVENT, this.failedToRegisterPushPermissionsIOS, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_FOREGROUND_EVENT, this.iosForegroundNotificationReceived, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants().NOTIFICATION_OPENED_EVENT, this.iosNotificationOpened, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_BACKGROUND_EVENT, this.iosBackgroundNotificationReceived, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.createChannel( androidNotificationChannelID, 'Default', CommAndroidNotifications.getConstants().NOTIFICATIONS_IMPORTANCE_HIGH, 'Comm notifications channel', ); const commAndroidNotificationsEventEmitter = getCommAndroidNotificationsEventEmitter(); this.androidNotificationsEventSubscriptions.push( commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_TOKEN, this.handleAndroidDeviceToken, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_MESSAGE, this.androidMessageReceived, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED, this.androidNotificationOpened, ), ); } if (this.props.connection.status === 'connected') { this.updateBadgeCount(); } } componentWillUnmount() { if (this.lifecycleSubscription) { this.lifecycleSubscription.remove(); } if (Platform.OS === 'ios') { for (const iosNotificationEventSubscription of this .iosNotificationEventSubscriptions) { iosNotificationEventSubscription.remove(); } } else if (Platform.OS === 'android') { for (const androidNotificationsEventSubscription of this .androidNotificationsEventSubscriptions) { androidNotificationsEventSubscription.remove(); } this.androidNotificationsEventSubscriptions = []; } } handleAppStateChange = (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = this.currentState; this.currentState = nextState; if (lastState === 'background' && nextState === 'active') { this.onForeground(); this.clearNotifsOfThread(); } }; onForeground() { if (this.props.loggedIn) { this.ensurePushNotifsEnabled(); } else { // We do this in case there was a crash, so we can clear deviceToken from // any other cookies it might be set for const deviceTokensMap: { [string]: string } = {}; for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken) { deviceTokensMap[keyserverID] = deviceToken; } } this.setDeviceToken(deviceTokensMap); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.activeThread !== prevProps.activeThread) { this.clearNotifsOfThread(); } if ( this.props.connection.status === 'connected' && (prevProps.connection.status !== 'connected' || this.props.unreadCount !== prevProps.unreadCount) ) { this.updateBadgeCount(); } for (const threadID of this.openThreadOnceReceived) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, false); this.openThreadOnceReceived.clear(); break; } } if (this.props.loggedIn && !prevProps.loggedIn) { this.ensurePushNotifsEnabled(); } else { for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; const prevDeviceToken = prevProps.deviceTokens[keyserverID]; if (!deviceToken && prevDeviceToken) { this.ensurePushNotifsEnabled(); break; } } } if (!this.props.loggedIn && prevProps.loggedIn) { this.clearAllNotifs(); this.resetBadgeCount(); } if ( this.state.inAppNotifProps && this.state.inAppNotifProps !== prevState.inAppNotifProps ) { Haptics.notificationAsync(); InAppNotification.show(); } } updateBadgeCount() { const curUnreadCount = this.props.unreadCount; if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(curUnreadCount); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(curUnreadCount); } } resetBadgeCount() { if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(0); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(0); } } clearAllNotifs() { if (Platform.OS === 'ios') { CommIOSNotifications.removeAllDeliveredNotifications(); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllDeliveredNotifications(); } } clearNotifsOfThread() { const { activeThread } = this.props; if (!activeThread) { return; } if (Platform.OS === 'ios') { CommIOSNotifications.getDeliveredNotifications(notifications => PushHandler.clearDeliveredIOSNotificationsForThread( activeThread, notifications, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllActiveNotificationsForThread( activeThread, ); } } static clearDeliveredIOSNotificationsForThread( threadID: string, notifications: $ReadOnlyArray, ) { const identifiersToClear = []; for (const notification of notifications) { if (notification.threadID === threadID) { identifiersToClear.push(notification.identifier); } } if (identifiersToClear) { CommIOSNotifications.removeDeliveredNotifications(identifiersToClear); } } async ensurePushNotifsEnabled() { if (!this.props.loggedIn) { return; } if (Platform.OS === 'ios') { let missingDeviceToken = false; for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken === null || deviceToken === undefined) { missingDeviceToken = true; break; } } await requestIOSPushPermissions(missingDeviceToken); } else if (Platform.OS === 'android') { await this.ensureAndroidPushNotifsEnabled(); } } async ensureAndroidPushNotifsEnabled() { const permissionPromisesResult = await Promise.all([ CommAndroidNotifications.hasPermission(), CommAndroidNotifications.canRequestNotificationsPermissionFromUser(), ]); let [hasPermission] = permissionPromisesResult; const [, canRequestPermission] = permissionPromisesResult; if (!hasPermission && canRequestPermission) { const permissionResponse = await (async () => { // We issue a call to sleep to match iOS behavior where prompt // doesn't appear immediately but after logged-out modal disappears await sleep(10); return await this.requestAndroidNotificationsPermission(); })(); hasPermission = permissionResponse; } if (!hasPermission) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); return; } try { const fcmToken = await CommAndroidNotifications.getToken(); await this.handleAndroidDeviceToken(fcmToken); } catch (e) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); } } requestAndroidNotificationsPermission = (): Promise => { if (!this.androidNotificationsPermissionPromise) { this.androidNotificationsPermissionPromise = (async () => { const notifPermission = await CommAndroidNotifications.requestNotificationsPermission(); this.androidNotificationsPermissionPromise = undefined; return notifPermission; })(); } return this.androidNotificationsPermissionPromise; }; handleAndroidDeviceToken = async (deviceToken: string) => { this.registerPushPermissions(deviceToken); await this.handleInitialAndroidNotification(); }; async handleInitialAndroidNotification() { if (this.initialAndroidNotifHandled) { return; } this.initialAndroidNotifHandled = true; const initialNotifThreadID = await CommAndroidNotifications.getInitialNotificationThreadID(); if (initialNotifThreadID) { await this.androidNotificationOpened(initialNotifThreadID); } } registerPushPermissions = (deviceToken: ?string) => { const deviceType = Platform.OS; if (deviceType !== 'android' && deviceType !== 'ios') { return; } if (deviceType === 'ios') { iosPushPermissionResponseReceived(); } const deviceTokensMap: { [string]: ?string } = {}; for (const keyserverID in this.props.deviceTokens) { const keyserverDeviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken !== keyserverDeviceToken) { deviceTokensMap[keyserverID] = deviceToken; } } this.setDeviceToken(deviceTokensMap); }; setDeviceToken(deviceTokens: DeviceTokens) { this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceToken(deviceTokens), ); } setAllDeviceTokensNull = () => { this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceTokenFanout(null), ); }; failedToRegisterPushPermissionsIOS = () => { this.setAllDeviceTokensNull(); if (!this.props.loggedIn) { return; } iosPushPermissionResponseReceived(); }; failedToRegisterPushPermissionsAndroid = ( shouldShowAlertOnAndroid: boolean, ) => { this.setAllDeviceTokensNull(); if (!this.props.loggedIn) { return; } if (shouldShowAlertOnAndroid) { this.showNotifAlertOnAndroid(); } }; showNotifAlertOnAndroid() { const alertInfo = this.props.notifPermissionAlertInfo; if (shouldSkipPushPermissionAlert(alertInfo)) { return; } this.props.dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); Alert.alert( 'Unable to initialize notifs!', '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.', undefined, { cancelable: true }, ); } - navigateToThread(threadInfo: ThreadInfo, clearChatRoutes: boolean) { + navigateToThread(threadInfo: LegacyThreadInfo, clearChatRoutes: boolean) { if (clearChatRoutes) { this.props.navigation.dispatch({ type: replaceWithThreadActionType, payload: { threadInfo }, }); } else { this.props.navigateToThread({ threadInfo }); } } onPressNotificationForThread(threadID: string, clearChatRoutes: boolean) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, clearChatRoutes); } else { this.openThreadOnceReceived.add(threadID); } } saveMessageInfos(rawMessageInfos: ?$ReadOnlyArray) { if (!rawMessageInfos) { return; } const { updatesCurrentAsOf } = this.props; this.props.dispatch({ type: saveMessagesActionType, payload: { rawMessageInfos, updatesCurrentAsOf }, }); } iosForegroundNotificationReceived = ( rawNotification: CoreIOSNotificationData, ) => { const notification = new CommIOSNotification(rawNotification); if (Date.now() < this.appStarted + 1500) { // On iOS, when the app is opened from a notif press, for some reason this // callback gets triggered before iosNotificationOpened. In fact this // callback shouldn't be triggered at all. To avoid weirdness we are // ignoring any foreground notification received within the first second // of the app being started, since they are most likely to be erroneous. notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NO_DATA, ); return; } const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); let title = notification.getData().title; let body = notification.getData().body; if (title && body) { ({ title, body } = mergePrefixIntoBody({ title, body })); } else { body = notification.getMessage(); } if (body) { this.showInAppNotification(threadID, body, title); } else { console.log( 'Non-rescind foreground notification without alert received!', ); } notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; iosBackgroundNotificationReceived = ( backgroundData: CoreIOSNotificationBackgroundData, ) => { const convertedMessageInfos = backgroundData.messageInfosArray .flatMap(convertNotificationMessageInfoToNewIDSchema) .filter(Boolean); if (!convertedMessageInfos.length) { return; } this.saveMessageInfos(convertedMessageInfos); }; onPushNotifBootsApp() { if ( this.props.rootContext && this.props.rootContext.detectUnsupervisedBackground ) { this.props.rootContext.detectUnsupervisedBackground(false); } } iosNotificationOpened = (rawNotification: CoreIOSNotificationData) => { const notification = new CommIOSNotification(rawNotification); this.onPushNotifBootsApp(); const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); this.onPressNotificationForThread(threadID, true); notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; showInAppNotification(threadID: string, message: string, title?: ?string) { if (threadID === this.props.activeThread) { return; } this.setState({ inAppNotifProps: { customComponent: ( ), blurType: this.props.activeTheme === 'dark' ? 'xlight' : 'dark', onPress: () => { InAppNotification.hide(); this.onPressNotificationForThread(threadID, false); }, }, }); } androidNotificationOpened = async (threadID: string) => { const convertedThreadID = convertNonPendingIDToNewSchema( threadID, ashoatKeyserverID, ); this.onPushNotifBootsApp(); this.onPressNotificationForThread(convertedThreadID, true); }; androidMessageReceived = async (message: AndroidMessage) => { const parsedMessage = parseAndroidMessage(message); this.onPushNotifBootsApp(); const { messageInfos } = parsedMessage; this.saveMessageInfos(messageInfos); handleAndroidMessage( parsedMessage, this.props.updatesCurrentAsOf, this.handleAndroidNotificationIfActive, ); }; handleAndroidNotificationIfActive = ( threadID: string, texts: { body: string, title: ?string }, ): boolean => { if (this.currentState !== 'active') { return false; } this.showInAppNotification(threadID, texts.body, texts.title); return true; }; render(): React.Node { return ( ); } } const ConnectedPushHandler: React.ComponentType = React.memo(function ConnectedPushHandler(props: BaseProps) { const navContext = React.useContext(NavContext); const activeThread = activeMessageListSelector(navContext); const boundUnreadCount = useSelector(unreadCount); const deviceTokens = useSelector(deviceTokensSelector); const threadInfos = useSelector(threadInfoSelector); const notifPermissionAlertInfo = useSelector( state => state.notifPermissionAlertInfo, ); const connection = useSelector(connectionSelector(ashoatKeyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); const updatesCurrentAsOf = useSelector( updatesCurrentAsOfSelector(ashoatKeyserverID), ); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const loggedIn = useSelector(isLoggedIn); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceToken(); const callSetDeviceTokenFanout = useSetDeviceTokenFanout(); const rootContext = React.useContext(RootContext); return ( ); }); export default ConnectedPushHandler; diff --git a/native/roles/change-roles-screen.react.js b/native/roles/change-roles-screen.react.js index e65a64e14..7eec1fe67 100644 --- a/native/roles/change-roles-screen.react.js +++ b/native/roles/change-roles-screen.react.js @@ -1,304 +1,307 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text, Platform, ActivityIndicator } from 'react-native'; import { TouchableOpacity } from 'react-native-gesture-handler'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { changeThreadMemberRolesActionTypes } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { otherUsersButNoOtherAdmins } from 'lib/selectors/thread-selectors.js'; import { roleIsAdminRole } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { RelativeMemberInfo, ThreadInfo } from 'lib/types/thread-types.js'; +import type { + RelativeMemberInfo, + LegacyThreadInfo, +} from 'lib/types/thread-types.js'; import { values } from 'lib/utils/objects.js'; import ChangeRolesHeaderRightButton from './change-roles-header-right-button.react.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import type { ChatNavigationProp } from '../chat/chat.react'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; export type ChangeRolesScreenParams = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +memberInfo: RelativeMemberInfo, +role: ?string, }; type Props = { +navigation: ChatNavigationProp<'ChangeRolesScreen'>, +route: NavigationRoute<'ChangeRolesScreen'>, }; const changeRolesLoadingStatusSelector = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, ); function ChangeRolesScreen(props: Props): React.Node { const { navigation, route } = props; const { threadInfo, memberInfo, role } = props.route.params; invariant(role, 'Role must be defined'); const changeRolesLoadingStatus: LoadingStatus = useSelector( changeRolesLoadingStatusSelector, ); const styles = useStyles(unboundStyles); const [selectedRole, setSelectedRole] = React.useState(role); const roleOptions = React.useMemo( () => values(threadInfo.roles).map(threadRole => ({ id: threadRole.id, name: threadRole.name, })), [threadInfo.roles], ); const selectedRoleName = React.useMemo( () => roleOptions.find(roleOption => roleOption.id === selectedRole)?.name, [roleOptions, selectedRole], ); const onRoleChange = React.useCallback( (selectedIndex: ?number) => { if ( selectedIndex === undefined || selectedIndex === null || selectedIndex === roleOptions.length ) { return; } const newRole = roleOptions[selectedIndex].id; setSelectedRole(newRole); navigation.setParams({ threadInfo, memberInfo, role: newRole, }); }, [navigation, setSelectedRole, roleOptions, memberInfo, threadInfo], ); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const { showActionSheetWithOptions } = useActionSheet(); const insets = useSafeAreaInsets(); const showActionSheet = React.useCallback(() => { const options = Platform.OS === 'ios' ? [...roleOptions.map(roleOption => roleOption.name), 'Cancel'] : [...roleOptions.map(roleOption => roleOption.name)]; const cancelButtonIndex = Platform.OS === 'ios' ? options.length - 1 : -1; const containerStyle = { paddingBottom: insets.bottom, }; showActionSheetWithOptions( { options, cancelButtonIndex, containerStyle, userInterfaceStyle: activeTheme ?? 'dark', }, onRoleChange, ); }, [ roleOptions, onRoleChange, insets.bottom, activeTheme, showActionSheetWithOptions, ]); const otherUsersButNoOtherAdminsValue = useSelector( otherUsersButNoOtherAdmins(threadInfo.id), ); const memberIsAdmin = React.useMemo(() => { invariant(memberInfo.role, 'Expected member role to be defined'); return roleIsAdminRole(threadInfo.roles[memberInfo.role]); }, [threadInfo.roles, memberInfo.role]); const shouldRoleChangeBeDisabled = React.useMemo( () => otherUsersButNoOtherAdminsValue && memberIsAdmin, [otherUsersButNoOtherAdminsValue, memberIsAdmin], ); const roleSelector = React.useMemo(() => { if (shouldRoleChangeBeDisabled) { return ( {selectedRoleName} ); } return ( {selectedRoleName} ); }, [showActionSheet, styles, selectedRoleName, shouldRoleChangeBeDisabled]); const disabledRoleChangeMessage = React.useMemo(() => { if (!shouldRoleChangeBeDisabled) { return null; } return ( There must be at least one admin at any given time in a community. ); }, [ shouldRoleChangeBeDisabled, styles.disabledWarningBackground, styles.infoIcon, styles.disabledWarningText, ]); React.useEffect(() => { navigation.setOptions({ // eslint-disable-next-line react/display-name headerRight: () => { if (changeRolesLoadingStatus === 'loading') { return ( ); } return ( ); }, }); }, [ changeRolesLoadingStatus, navigation, styles.activityIndicator, route, shouldRoleChangeBeDisabled, ]); return ( Members can only be assigned one role at a time. Changing a member’s role will replace their previously assigned role. {memberInfo.username} {roleSelector} {disabledRoleChangeMessage} ); } const unboundStyles = { descriptionBackground: { backgroundColor: 'panelForeground', marginBottom: 20, }, descriptionText: { color: 'panelBackgroundLabel', padding: 16, fontSize: 14, }, memberInfo: { backgroundColor: 'panelForeground', padding: 16, marginBottom: 30, display: 'flex', flexDirection: 'column', alignItems: 'center', }, memberInfoUsername: { color: 'panelForegroundLabel', marginTop: 8, fontSize: 18, fontWeight: '500', }, roleSelectorLabel: { color: 'panelForegroundSecondaryLabel', marginLeft: 8, fontSize: 12, }, roleSelector: { backgroundColor: 'panelForeground', marginTop: 8, padding: 16, display: 'flex', alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', }, currentRole: { color: 'panelForegroundSecondaryLabel', fontSize: 16, }, disabledCurrentRole: { color: 'disabledButton', fontSize: 16, }, pencilIcon: { color: 'panelInputSecondaryForeground', }, disabledPencilIcon: { color: 'disabledButton', }, disabledWarningBackground: { backgroundColor: 'disabledButton', padding: 16, display: 'flex', marginTop: 20, flexDirection: 'row', justifyContent: 'center', width: '75%', alignSelf: 'center', }, disabledWarningText: { color: 'panelForegroundSecondaryLabel', fontSize: 14, marginRight: 8, display: 'flex', }, infoIcon: { color: 'panelForegroundSecondaryLabel', marginRight: 8, marginLeft: 8, marginBottom: 12, }, activityIndicator: { paddingRight: 15, }, }; export default ChangeRolesScreen; diff --git a/native/roles/community-roles-screen.react.js b/native/roles/community-roles-screen.react.js index 49f5c6b7b..45d60d82b 100644 --- a/native/roles/community-roles-screen.react.js +++ b/native/roles/community-roles-screen.react.js @@ -1,182 +1,182 @@ // @flow import * as React from 'react'; import { View, Text } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { useRoleMemberCountsForCommunity, useRoleUserSurfacedPermissions, } from 'lib/shared/thread-utils.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import RolePanelEntry from './role-panel-entry.react.js'; import type { RolesNavigationProp } from './roles-navigator.react.js'; import Button from '../components/button.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { CreateRolesScreenRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; export type CommunityRolesScreenParams = { - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, }; type CommunityRolesScreenProps = { +navigation: RolesNavigationProp<'CommunityRolesScreen'>, +route: NavigationRoute<'CommunityRolesScreen'>, }; function CommunityRolesScreen(props: CommunityRolesScreenProps): React.Node { const { threadInfo } = props.route.params; // This route is either accessed from the CommunityDrawer via the // CommunityActionsButton, or from navigating back after a successful // role creation in CreateRolesScreen. In the second case, we want to // manually pull in the threadInfo from the redux store, since the threadInfo // passed into the route params will not be updated automatically. const threadID = threadInfo.id; - const reduxThreadInfo: ?ThreadInfo = useSelector( + const reduxThreadInfo: ?LegacyThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const styles = useStyles(unboundStyles); const roleNamesToMembers = useRoleMemberCountsForCommunity(threadInfo); const roleNamesToUserSurfacedPermissions = useRoleUserSurfacedPermissions(threadInfo); const rolePanelList = React.useMemo(() => { const rolePanelEntries = []; Object.keys(roleNamesToMembers).forEach(roleName => { rolePanelEntries.push( , ); }); return rolePanelEntries; }, [ roleNamesToMembers, props.navigation, threadInfo, roleNamesToUserSurfacedPermissions, ]); const navigateToCreateRole = React.useCallback( () => props.navigation.navigate(CreateRolesScreenRouteName, { threadInfo, action: 'create_role', roleName: 'New role', rolePermissions: new Set(), }), [threadInfo, props.navigation], ); return ( Roles help you group community members together and assign them certain permissions. The Admins and Members roles are set by default and cannot be edited or deleted. When people join the community, they are automatically assigned the Members role. ROLES MEMBERS {rolePanelList} ); } const unboundStyles = { rolesInfoContainer: { backgroundColor: 'panelForeground', padding: 16, }, rolesInfoTextFirstLine: { color: 'panelBackgroundLabel', fontSize: 14, marginBottom: 14, }, rolesInfoTextSecondLine: { color: 'panelBackgroundLabel', fontSize: 14, }, rolesPanel: { marginTop: 30, }, rolePanelHeadersContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 8, }, rolePanelHeaderLeft: { color: 'panelBackgroundLabel', fontSize: 14, }, rolePanelHeaderRight: { color: 'panelBackgroundLabel', fontSize: 14, marginRight: 72, }, rolePanelList: { backgroundColor: 'panelForeground', marginTop: 8, padding: 4, maxHeight: 325, }, buttonContainer: { backgroundColor: 'panelForeground', padding: 2, }, createRoleButton: { justifyContent: 'center', alignItems: 'center', margin: 10, backgroundColor: 'purpleButton', height: 48, borderRadius: 10, }, createRoleButtonText: { color: 'whiteText', fontSize: 16, fontWeight: '500', }, }; export default CommunityRolesScreen; diff --git a/native/roles/create-roles-screen.react.js b/native/roles/create-roles-screen.react.js index f985e24e8..17b9ed6c2 100644 --- a/native/roles/create-roles-screen.react.js +++ b/native/roles/create-roles-screen.react.js @@ -1,292 +1,292 @@ // @flow import * as React from 'react'; import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { modifyCommunityRoleActionTypes } 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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type UserSurfacedPermissionOption, type UserSurfacedPermission, userSurfacedPermissionOptions, } from 'lib/types/thread-permission-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import CreateRolesHeaderRightButton from './create-roles-header-right-button.react.js'; import type { RolesNavigationProp } from './roles-navigator.react.js'; import EnumSettingsOption from '../components/enum-settings-option.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import TextInput from '../components/text-input.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; export type CreateRolesScreenParams = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +action: 'create_role' | 'edit_role', +existingRoleID?: string, +roleName: string, +rolePermissions: $ReadOnlySet, }; type CreateRolesScreenProps = { +navigation: RolesNavigationProp<'CreateRolesScreen'>, +route: NavigationRoute<'CreateRolesScreen'>, }; const createRolesLoadingStatusSelector = createLoadingStatusSelector( modifyCommunityRoleActionTypes, ); function CreateRolesScreen(props: CreateRolesScreenProps): React.Node { const { threadInfo, action, existingRoleID, roleName: defaultRoleName, rolePermissions: defaultRolePermissions, } = props.route.params; const createRolesLoadingStatus: LoadingStatus = useSelector( createRolesLoadingStatusSelector, ); const [customRoleName, setCustomRoleName] = React.useState(defaultRoleName); const [selectedPermissions, setSelectedPermissions] = React.useState< $ReadOnlySet, >(defaultRolePermissions); const [roleCreationFailed, setRoleCreationFailed] = React.useState(false); const styles = useStyles(unboundStyles); const errorStyles = React.useMemo( () => roleCreationFailed ? [styles.errorContainer, styles.errorContainerVisible] : styles.errorContainer, [roleCreationFailed, styles.errorContainer, styles.errorContainerVisible], ); const onClearPermissions = React.useCallback(() => { setSelectedPermissions(new Set()); }, []); const isSelectedPermissionsEmpty = selectedPermissions.size === 0; const clearPermissionsText = React.useMemo(() => { const textStyle = isSelectedPermissionsEmpty ? styles.clearPermissionsTextDisabled : styles.clearPermissionsText; return ( Clear permissions ); }, [ isSelectedPermissionsEmpty, onClearPermissions, styles.clearPermissionsText, styles.clearPermissionsTextDisabled, ]); const isUserSurfacedPermissionSelected = React.useCallback( (option: UserSurfacedPermissionOption) => selectedPermissions.has(option.userSurfacedPermission), [selectedPermissions], ); const onEnumValuePress = React.useCallback( (option: UserSurfacedPermissionOption) => setSelectedPermissions(currentPermissions => { if (currentPermissions.has(option.userSurfacedPermission)) { const newPermissions = new Set(currentPermissions); newPermissions.delete(option.userSurfacedPermission); return newPermissions; } else { return new Set([ ...currentPermissions, option.userSurfacedPermission, ]); } }), [], ); React.useEffect( () => props.navigation.setParams({ threadInfo, action, existingRoleID, roleName: customRoleName, rolePermissions: selectedPermissions, }), [ props.navigation, threadInfo, action, existingRoleID, customRoleName, selectedPermissions, ], ); const permissionsList = React.useMemo( () => [...userSurfacedPermissionOptions].map(permission => ( onEnumValuePress(permission)} /> )), [isUserSurfacedPermissionSelected, onEnumValuePress], ); const onChangeRoleNameInput = React.useCallback((roleName: string) => { setRoleCreationFailed(false); setCustomRoleName(roleName); }, []); React.useEffect( () => props.navigation.setOptions({ // eslint-disable-next-line react/display-name headerRight: () => { if (createRolesLoadingStatus === 'loading') { return ( ); } return ( ); }, }), [ createRolesLoadingStatus, props.navigation, styles.activityIndicator, props.route, ], ); return ( ROLE NAME There is already a role with this name in the community PERMISSIONS {clearPermissionsText} {permissionsList} ); } const unboundStyles = { roleNameContainer: { marginTop: 30, }, roleNameText: { color: 'panelBackgroundLabel', fontSize: 12, marginBottom: 5, marginLeft: 10, }, roleInput: { backgroundColor: 'panelForeground', padding: 12, flexDirection: 'row', justifyContent: 'space-between', }, roleInputComponent: { color: 'panelForegroundLabel', fontSize: 16, }, pencilIcon: { color: 'panelInputSecondaryForeground', }, errorContainer: { marginTop: 10, alignItems: 'center', opacity: 0, }, errorContainerVisible: { opacity: 1, }, errorText: { color: 'redText', fontSize: 14, }, permissionsContainer: { marginTop: 20, paddingBottom: 220, }, permissionsHeader: { flexDirection: 'row', justifyContent: 'space-between', }, permissionsText: { color: 'panelBackgroundLabel', fontSize: 12, marginLeft: 10, }, clearPermissionsText: { color: 'purpleLink', fontSize: 12, marginRight: 15, }, clearPermissionsTextDisabled: { color: 'disabledButton', fontSize: 12, marginRight: 15, }, permissionsListContainer: { backgroundColor: 'panelForeground', marginTop: 10, }, activityIndicator: { paddingRight: 15, }, }; export default CreateRolesScreen; diff --git a/native/roles/role-panel-entry.react.js b/native/roles/role-panel-entry.react.js index aa0285249..2ac64bda9 100644 --- a/native/roles/role-panel-entry.react.js +++ b/native/roles/role-panel-entry.react.js @@ -1,196 +1,196 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text, TouchableOpacity, Platform } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { UserSurfacedPermission } from 'lib/types/thread-permission-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useDisplayDeleteRoleAlert } from './role-utils.react.js'; import type { RolesNavigationProp } from './roles-navigator.react.js'; import CommIcon from '../components/comm-icon.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { CreateRolesScreenRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; type RolePanelEntryProps = { +navigation: RolesNavigationProp<'CommunityRolesScreen'>, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +roleName: string, +rolePermissions: $ReadOnlySet, +memberCount: number, }; function RolePanelEntry(props: RolePanelEntryProps): React.Node { const { navigation, threadInfo, roleName, rolePermissions, memberCount } = props; const styles = useStyles(unboundStyles); const existingRoleID = React.useMemo( () => Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].name === roleName, ), [roleName, threadInfo.roles], ); invariant(existingRoleID, 'Role ID must exist for an existing role'); const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); invariant(defaultRoleID, 'Default role ID must exist'); const displayDeleteRoleAlert = useDisplayDeleteRoleAlert( threadInfo, existingRoleID, defaultRoleID, memberCount, ); const options = React.useMemo(() => { const availableOptions = ['Edit role']; // Since the `Members` role is able to be renamed, we need to check if the // default role ID is the same as the existing role ID. if (defaultRoleID !== existingRoleID) { availableOptions.push('Delete role'); } if (Platform.OS === 'ios') { availableOptions.push('Cancel'); } return availableOptions; }, [defaultRoleID, existingRoleID]); const onOptionSelected = React.useCallback( (index: ?number) => { if (index === undefined || index === null || index === options.length) { return; } const selectedOption = options[index]; if (selectedOption === 'Edit role') { navigation.navigate(CreateRolesScreenRouteName, { threadInfo, action: 'edit_role', existingRoleID, roleName, rolePermissions, }); } else if (selectedOption === 'Delete role') { displayDeleteRoleAlert(); } }, [ navigation, options, existingRoleID, roleName, rolePermissions, threadInfo, displayDeleteRoleAlert, ], ); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const { showActionSheetWithOptions } = useActionSheet(); const insets = useSafeAreaInsets(); const showActionSheet = React.useCallback(() => { const cancelButtonIndex = Platform.OS === 'ios' ? options.length - 1 : -1; const containerStyle = { paddingBottom: insets.bottom, }; showActionSheetWithOptions( { options, cancelButtonIndex, containerStyle, userInterfaceStyle: activeTheme ?? 'dark', icons: [], }, onOptionSelected, ); }, [ options, onOptionSelected, insets.bottom, activeTheme, showActionSheetWithOptions, ]); const menuButton = React.useMemo(() => { if (roleName === 'Admins') { return ; } return ( ); }, [ roleName, styles.rolePanelEmptyMenuButton, styles.rolePanelMenuButton, showActionSheet, ]); return ( {roleName} {memberCount} {menuButton} ); } const unboundStyles = { rolePanelEntry: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 8, }, rolePanelNameEntry: { flex: 1, color: 'panelForegroundLabel', fontWeight: '600', fontSize: 14, }, rolePanelCountEntryContainer: { marginRight: 40, alignItmes: 'flex-end', }, rolePanelCountEntry: { color: 'panelForegroundLabel', fontWeight: '600', fontSize: 14, marginRight: 22, padding: 8, }, rolePanelEmptyMenuButton: { marginRight: 22, }, rolePanelMenuButton: { color: 'panelForegroundLabel', }, }; export default RolePanelEntry; diff --git a/native/roles/role-utils.react.js b/native/roles/role-utils.react.js index 3be977bdf..87830f185 100644 --- a/native/roles/role-utils.react.js +++ b/native/roles/role-utils.react.js @@ -1,63 +1,63 @@ // @flow import * as React from 'react'; import { useDeleteCommunityRole, deleteCommunityRoleActionTypes, } from 'lib/actions/thread-actions.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import { constructRoleDeletionMessagePrompt } from 'lib/utils/role-utils.js'; import Alert from '../utils/alert.js'; function useDisplayDeleteRoleAlert( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, existingRoleID: string, defaultRoleID: string, memberCount: number, ): () => void { const defaultRoleName = threadInfo.roles[defaultRoleID].name; const callDeleteCommunityRole = useDeleteCommunityRole(); const dispatchActionPromise = useDispatchActionPromise(); const onDeleteRole = React.useCallback(() => { dispatchActionPromise( deleteCommunityRoleActionTypes, callDeleteCommunityRole({ community: threadInfo.id, roleID: existingRoleID, }), ); }, [ callDeleteCommunityRole, dispatchActionPromise, existingRoleID, threadInfo.id, ]); const message = constructRoleDeletionMessagePrompt( defaultRoleName, memberCount, ); return React.useCallback( () => Alert.alert('Delete role', message, [ { text: 'Yes, delete role', style: 'destructive', onPress: onDeleteRole, }, { text: 'Cancel', style: 'cancel', }, ]), [message, onDeleteRole], ); } export { useDisplayDeleteRoleAlert }; diff --git a/native/search/message-search.react.js b/native/search/message-search.react.js index f2f1e56cd..d90a489ab 100644 --- a/native/search/message-search.react.js +++ b/native/search/message-search.react.js @@ -1,233 +1,233 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo } from 'lib/shared/message-utils.js'; import { useSearchMessages, filterChatMessageInfosForSearch, } from 'lib/shared/search-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import SearchFooter from './search-footer.react.js'; import { MessageSearchContext } from './search-provider.react.js'; import { useHeightMeasurer } from '../chat/chat-context.js'; import type { ChatNavigationProp } from '../chat/chat.react.js'; import { MessageListContextProvider } from '../chat/message-list-types.js'; import MessageResult from '../chat/message-result.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; export type MessageSearchParams = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, }; export type MessageSearchProps = { +navigation: ChatNavigationProp<'MessageSearch'>, +route: NavigationRoute<'MessageSearch'>, }; function MessageSearch(props: MessageSearchProps): React.Node { const searchContext = React.useContext(MessageSearchContext); invariant(searchContext, 'searchContext should be set'); const { query, clearQuery } = searchContext; const { threadInfo } = props.route.params; React.useEffect(() => { return props.navigation.addListener('beforeRemove', clearQuery); }, [props.navigation, clearQuery]); const [lastID, setLastID] = React.useState(); const [searchResults, setSearchResults] = React.useState< $ReadOnlyArray, >([]); const [endReached, setEndReached] = React.useState(false); const appendSearchResults = React.useCallback( ( newMessages: $ReadOnlyArray, end: boolean, queryID: number, ) => { if (queryID !== queryIDRef.current) { return; } setSearchResults(oldMessages => [...oldMessages, ...newMessages]); setEndReached(end); }, [], ); const searchMessages = useSearchMessages(); const queryIDRef = React.useRef(0); React.useEffect(() => { setSearchResults([]); setLastID(undefined); setEndReached(false); }, [query, searchMessages]); React.useEffect(() => { queryIDRef.current += 1; searchMessages( query, threadInfo.id, appendSearchResults, queryIDRef.current, lastID, ); }, [appendSearchResults, lastID, query, searchMessages, threadInfo.id]); const userInfos = useSelector(state => state.userStore.userInfos); const translatedSearchResults = React.useMemo(() => { const threadInfos = { [threadInfo.id]: threadInfo }; return searchResults .map(rawMessageInfo => createMessageInfo(rawMessageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [searchResults, threadInfo, userInfos]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedSearchResults), ); const filteredChatMessageInfos = React.useMemo(() => { const result = filterChatMessageInfosForSearch( chatMessageInfos, translatedSearchResults, ); if (result && !endReached) { return [...result, { itemType: 'loader' }]; } return result; }, [chatMessageInfos, endReached, translatedSearchResults]); const [measuredMessages, setMeasuredMessages] = React.useState< $ReadOnlyArray, >([]); const measureMessages = useHeightMeasurer(); const measureCallback = React.useCallback( (listDataWithHeights: $ReadOnlyArray) => { setMeasuredMessages(listDataWithHeights); }, [setMeasuredMessages], ); React.useEffect(() => { measureMessages(filteredChatMessageInfos, threadInfo, measureCallback); }, [filteredChatMessageInfos, measureCallback, measureMessages, threadInfo]); const [messageVerticalBounds, setMessageVerticalBounds] = React.useState(); const scrollViewContainerRef = React.useRef>(); const onLayout = React.useCallback(() => { scrollViewContainerRef.current?.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setMessageVerticalBounds({ height, y: pageY }); }, ); }, []); const renderItem = React.useCallback( ({ item }: { +item: ChatMessageItemWithHeight, ... }) => { if (item.itemType === 'loader') { return ; } return ( ); }, [messageVerticalBounds, props.navigation, props.route, threadInfo], ); const footer = React.useMemo(() => { if (query === '') { return ; } if (!endReached) { return null; } if (measuredMessages.length > 0) { return ; } const text = 'No results. Please try using different keywords to refine your search'; return ; }, [query, endReached, measuredMessages.length]); const onEndOfLoadedMessagesReached = React.useCallback(() => { if (endReached) { return; } setLastID(oldestMessageID(measuredMessages)); }, [endReached, measuredMessages, setLastID]); const styles = useStyles(unboundStyles); return ( ); } function oldestMessageID(data: $ReadOnlyArray) { for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return undefined; } const unboundStyles = { content: { height: '100%', backgroundColor: 'panelBackground', }, }; export default MessageSearch; diff --git a/native/search/search-messages-button.react.js b/native/search/search-messages-button.react.js index 6e0630558..6830f7bb7 100644 --- a/native/search/search-messages-button.react.js +++ b/native/search/search-messages-button.react.js @@ -1,45 +1,45 @@ // @flow import * as React from 'react'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import { type ThreadInfo } from 'lib/types/thread-types.js'; +import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { ChatNavigationProp } from '../chat/chat.react.js'; import Button from '../components/button.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { MessageSearchRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +navigate: $PropertyType, 'navigate'>, }; function SearchMessagesButton(props: Props): React.Node { const { threadInfo, navigate } = props; const styles = useStyles(unboundStyles); const onPress = React.useCallback(() => { navigate<'MessageSearch'>({ name: MessageSearchRouteName, params: { threadInfo }, key: `${MessageSearchRouteName}${threadInfo.id}`, }); }, [navigate, threadInfo]); return ( ); } const unboundStyles = { button: { color: 'panelForegroundLabel', paddingHorizontal: 10, }, }; export default SearchMessagesButton; diff --git a/native/selectors/calendar-selectors.js b/native/selectors/calendar-selectors.js index 7078381dc..50f327c33 100644 --- a/native/selectors/calendar-selectors.js +++ b/native/selectors/calendar-selectors.js @@ -1,66 +1,66 @@ // @flow import { createSelector } from 'reselect'; import { currentDaysToEntries, threadInfoSelector, } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import type { EntryInfo } from 'lib/types/entry-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { dateString } from 'lib/utils/date-utils.js'; import type { AppState } from '../redux/state-types.js'; export type SectionHeaderItem = { itemType: 'header', dateString: string, }; export type SectionFooterItem = { itemType: 'footer', dateString: string, }; export type LoaderItem = { itemType: 'loader', key: string, }; export type CalendarItem = | LoaderItem | SectionHeaderItem | SectionFooterItem | { itemType: 'entryInfo', entryInfo: EntryInfo, - threadInfo: ThreadInfo, + threadInfo: LegacyThreadInfo, }; const calendarListData: (state: AppState) => ?(CalendarItem[]) = createSelector( isLoggedIn, currentDaysToEntries, threadInfoSelector, ( loggedIn: boolean, daysToEntries: { +[dayString: string]: EntryInfo[] }, - threadInfos: { +[id: string]: ThreadInfo }, + threadInfos: { +[id: string]: LegacyThreadInfo }, ) => { if (!loggedIn || daysToEntries[dateString(new Date())] === undefined) { return null; } const items: CalendarItem[] = [{ itemType: 'loader', key: 'TopLoader' }]; for (const dayString in daysToEntries) { items.push({ itemType: 'header', dateString: dayString }); for (const entryInfo of daysToEntries[dayString]) { const threadInfo = threadInfos[entryInfo.threadID]; if (threadInfo) { items.push({ itemType: 'entryInfo', entryInfo, threadInfo }); } } items.push({ itemType: 'footer', dateString: dayString }); } items.push({ itemType: 'loader', key: 'BottomLoader' }); return items; }, ); export { calendarListData }; diff --git a/native/types/chat-types.js b/native/types/chat-types.js index e7d5a8083..0fa5b218b 100644 --- a/native/types/chat-types.js +++ b/native/types/chat-types.js @@ -1,79 +1,79 @@ // @flow import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import type { LocalMessageInfo, MultimediaMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types.js'; import type { TextMessageInfo } from 'lib/types/messages/text.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { EntityText } from 'lib/utils/entity-text.js'; import type { MessagePendingUploads } from '../input/input-state.js'; export type ChatRobotextMessageInfoItemWithHeight = { +itemType: 'message', +messageShapeType: 'robotext', +messageInfo: RobotextMessageInfo, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +robotext: EntityText, - +threadCreatedFromMessage: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +threadCreatedFromMessage: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, +contentHeight: number, +reactions: ReactionInfo, }; export type ChatTextMessageInfoItemWithHeight = { +itemType: 'message', +messageShapeType: 'text', +messageInfo: TextMessageInfo, +localMessageInfo: ?LocalMessageInfo, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +contentHeight: number, - +threadCreatedFromMessage: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +threadCreatedFromMessage: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, +reactions: ReactionInfo, +hasBeenEdited: ?boolean, +isPinned: ?boolean, }; // We "measure" the contentHeight of a multimedia message using the media // dimensions. This means for multimedia messages we only need to actually // measure the inline engagement node export type MultimediaContentSizes = { +imageHeight: number, +contentHeight: number, +contentWidth: number, }; export type ChatMultimediaMessageInfoItem = { ...MultimediaContentSizes, +itemType: 'message', +messageShapeType: 'multimedia', +messageInfo: MultimediaMessageInfo, +localMessageInfo: ?LocalMessageInfo, - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, - +threadCreatedFromMessage: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +threadCreatedFromMessage: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, +pendingUploads: ?MessagePendingUploads, +reactions: ReactionInfo, +hasBeenEdited: ?boolean, +isPinned: ?boolean, +inlineEngagementHeight: ?number, }; export type ChatMessageInfoItemWithHeight = | ChatRobotextMessageInfoItemWithHeight | ChatTextMessageInfoItemWithHeight | ChatMultimediaMessageInfoItem; export type ChatMessageItemWithHeight = | { itemType: 'loader' } | ChatMessageInfoItemWithHeight; diff --git a/native/user-profile/user-profile-menu-button.react.js b/native/user-profile/user-profile-menu-button.react.js index 4e5c3cd22..87b555a27 100644 --- a/native/user-profile/user-profile-menu-button.react.js +++ b/native/user-profile/user-profile-menu-button.react.js @@ -1,148 +1,148 @@ // @flow import { useNavigation, useRoute } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { View, TouchableOpacity } from 'react-native'; import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types'; import { userProfileMenuButtonHeight } from './user-profile-constants.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import { UserRelationshipTooltipModalRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; // We need to set onMenuButtonLayout in order to allow .measure() // to be on the ref const onMenuButtonLayout = () => {}; type Props = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +pendingPersonalThreadUserInfo: ?UserInfo, }; function UserProfileMenuButton(props: Props): React.Node { const { threadInfo, pendingPersonalThreadUserInfo } = props; const { otherUserInfo } = useRelationshipPrompt( threadInfo, undefined, pendingPersonalThreadUserInfo, ); const { navigate } = useNavigation(); const route = useRoute(); const styles = useStyles(unboundStyles); const overlayContext = React.useContext(OverlayContext); const menuButtonRef = React.useRef>(); const visibleTooltipActionEntryIDs = React.useMemo(() => { const result = []; if (otherUserInfo?.relationshipStatus === userRelationshipStatus.FRIEND) { result.push('unfriend'); result.push('block'); } else if ( otherUserInfo?.relationshipStatus === userRelationshipStatus.BOTH_BLOCKED || otherUserInfo?.relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { result.push('unblock'); } else { result.push('block'); } return result; }, [otherUserInfo?.relationshipStatus]); const onPressMenuButton = React.useCallback(() => { invariant( overlayContext, 'UserProfileMenuButton should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); const currentMenuButtonRef = menuButtonRef.current; if (!currentMenuButtonRef || !otherUserInfo) { return; } currentMenuButtonRef.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height, }; const verticalBounds = { height: userProfileMenuButtonHeight, y: pageY, }; const { relationshipStatus, ...restUserInfo } = otherUserInfo; const relativeUserInfo = { ...restUserInfo, isViewer: false, }; navigate<'UserRelationshipTooltipModal'>({ name: UserRelationshipTooltipModalRouteName, params: { presentedFrom: route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: visibleTooltipActionEntryIDs, relativeUserInfo, tooltipButtonIcon: 'menu', }, }); }); }, [ navigate, otherUserInfo, overlayContext, route.key, visibleTooltipActionEntryIDs, ]); const userProfileMenuButton = React.useMemo( () => ( ), [onPressMenuButton, styles.iconContainer, styles.moreIcon], ); return userProfileMenuButton; } const unboundStyles = { iconContainer: { alignSelf: 'flex-end', }, moreIcon: { color: 'modalButtonLabel', alignSelf: 'flex-end', }, }; export default UserProfileMenuButton; diff --git a/native/user-profile/user-profile-message-button.react.js b/native/user-profile/user-profile-message-button.react.js index 9667431b4..b03eca112 100644 --- a/native/user-profile/user-profile-message-button.react.js +++ b/native/user-profile/user-profile-message-button.react.js @@ -1,69 +1,69 @@ // @flow import { useBottomSheetModal } from '@gorhom/bottom-sheet'; import * as React from 'react'; import { Text } from 'react-native'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types'; import { useNavigateToThread } from '../chat/message-list-types.js'; import Button from '../components/button.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: ThreadInfo, + +threadInfo: LegacyThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, }; function UserProfileMessageButton(props: Props): React.Node { const { threadInfo, pendingPersonalThreadUserInfo } = props; const { dismiss: dismissBottomSheetModal } = useBottomSheetModal(); const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const onPressMessage = React.useCallback(() => { dismissBottomSheetModal(); navigateToThread({ threadInfo, pendingPersonalThreadUserInfo, }); }, [ dismissBottomSheetModal, navigateToThread, pendingPersonalThreadUserInfo, threadInfo, ]); return ( ); } const unboundStyles = { messageButtonContainer: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', backgroundColor: 'purpleButton', paddingVertical: 8, marginTop: 16, borderRadius: 8, }, messageButtonIcon: { color: 'floatingButtonLabel', paddingRight: 8, }, messageButtonText: { color: 'floatingButtonLabel', }, }; export default UserProfileMessageButton; diff --git a/native/user-profile/user-profile-relationship-button.react.js b/native/user-profile/user-profile-relationship-button.react.js index 5dc82b9ad..66212a26d 100644 --- a/native/user-profile/user-profile-relationship-button.react.js +++ b/native/user-profile/user-profile-relationship-button.react.js @@ -1,154 +1,154 @@ // @flow import * as React from 'react'; import { View, Text } from 'react-native'; import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js'; import type { SetState } from 'lib/types/hook-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types'; import { userProfileActionButtonHeight } from './user-profile-constants.js'; import RelationshipButton from '../components/relationship-button.react.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; const onErrorCallback = () => { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }]); }; type Props = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, +setUserProfileRelationshipButtonHeight: SetState, }; function UserProfileRelationshipButton(props: Props): React.Node { const { threadInfo, pendingPersonalThreadUserInfo, setUserProfileRelationshipButtonHeight, } = props; const { otherUserInfo, callbacks: { friendUser, unfriendUser }, } = useRelationshipPrompt( threadInfo, onErrorCallback, pendingPersonalThreadUserInfo, ); React.useLayoutEffect(() => { if ( !otherUserInfo || otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND ) { setUserProfileRelationshipButtonHeight(0); } else if ( otherUserInfo?.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { const incomingFriendRequestButtonsContainerHeight = 88; setUserProfileRelationshipButtonHeight( incomingFriendRequestButtonsContainerHeight, ); } else { setUserProfileRelationshipButtonHeight(userProfileActionButtonHeight); } }, [ otherUserInfo, otherUserInfo?.relationshipStatus, setUserProfileRelationshipButtonHeight, ]); const styles = useStyles(unboundStyles); const userProfileRelationshipButton = React.useMemo(() => { if ( !otherUserInfo || !otherUserInfo.username || otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND ) { return null; } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { return ( Incoming friend request ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT ) { return ( ); } return ( ); }, [ friendUser, otherUserInfo, styles.acceptFriendRequestButtonContainer, styles.incomingFriendRequestButtonsContainer, styles.incomingFriendRequestContainer, styles.incomingFriendRequestLabel, styles.rejectFriendRequestButtonContainer, styles.singleButtonContainer, unfriendUser, ]); return userProfileRelationshipButton; } const unboundStyles = { singleButtonContainer: { marginTop: 16, }, incomingFriendRequestContainer: { marginTop: 24, }, incomingFriendRequestLabel: { color: 'modalForegroundLabel', }, incomingFriendRequestButtonsContainer: { flexDirection: 'row', marginTop: 8, }, acceptFriendRequestButtonContainer: { flex: 1, marginRight: 4, }, rejectFriendRequestButtonContainer: { flex: 1, marginLeft: 4, }, }; export default UserProfileRelationshipButton; diff --git a/native/utils/drawer-utils.react.js b/native/utils/drawer-utils.react.js index cfef45979..1243c6d28 100644 --- a/native/utils/drawer-utils.react.js +++ b/native/utils/drawer-utils.react.js @@ -1,119 +1,119 @@ // @flow import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypeIsCommunityRoot } from 'lib/types/thread-types-enum.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { CommunityDrawerItemData } from 'lib/utils/drawer-utils.react.js'; import type { TextStyle } from '../types/styles.js'; export type CommunityDrawerItemDataFlattened = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +hasSubchannelsButton: boolean, +labelStyle: TextStyle, +hasChildren: boolean, +itemStyle: { +indentation: number, +background: 'none' | 'beginning' | 'middle' | 'end', }, }; const defaultIndentation = 8; const addedIndentation = 16; function flattenDrawerItemsData( data: $ReadOnlyArray>, expanded: $ReadOnlyArray, prevIndentation: ?number, ): $ReadOnlyArray { let results: Array = []; for (const item of data) { const isOpen = expanded.includes(item.threadInfo.id); const isCommunity = threadTypeIsCommunityRoot(item.threadInfo.type); let background = 'middle'; if (isCommunity) { background = isOpen ? 'beginning' : 'none'; } let indentation = defaultIndentation; if (!isCommunity && prevIndentation) { indentation = prevIndentation + addedIndentation; } results.push({ threadInfo: item.threadInfo, hasSubchannelsButton: item.hasSubchannelsButton, labelStyle: item.labelStyle, hasChildren: item.itemChildren?.length > 0, itemStyle: { indentation, background, }, }); if (!isOpen) { continue; } results = results.concat( flattenDrawerItemsData(item.itemChildren, expanded, indentation), ); if (isCommunity) { results[results.length - 1] = { ...results[results.length - 1], itemStyle: { ...results[results.length - 1].itemStyle, background: 'end', }, }; } } return results; } function findAllDescendantIDs( data: $ReadOnlyArray>, ): $ReadOnlyArray { const results = []; for (const item of data) { results.push(item.threadInfo.id); results.concat(findAllDescendantIDs(item.itemChildren)); } return results; } function findThreadChildrenItems( data: $ReadOnlyArray>, id: string, ): ?$ReadOnlyArray> { for (const item of data) { if (item.threadInfo.id === id) { return item.itemChildren; } const result = findThreadChildrenItems(item.itemChildren, id); if (result) { return result; } } return undefined; } function filterOutThreadAndDescendantIDs( idsToFilter: $ReadOnlyArray, allItems: $ReadOnlyArray>, threadID: string, ): $ReadOnlyArray { const childItems = findThreadChildrenItems(allItems, threadID); if (!childItems) { return []; } const descendants = findAllDescendantIDs(childItems); const descendantsSet = new Set(descendants); return idsToFilter.filter( item => !descendantsSet.has(item) && item !== threadID, ); } export { flattenDrawerItemsData, filterOutThreadAndDescendantIDs }; diff --git a/web/avatars/edit-thread-avatar-menu.react.js b/web/avatars/edit-thread-avatar-menu.react.js index 8a483783d..cf9960e8d 100644 --- a/web/avatars/edit-thread-avatar-menu.react.js +++ b/web/avatars/edit-thread-avatar-menu.react.js @@ -1,122 +1,125 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; +import type { + RawThreadInfo, + LegacyThreadInfo, +} from 'lib/types/thread-types.js'; import { useUploadAvatarMedia } from './avatar-hooks.react.js'; import css from './edit-avatar-menu.css'; import ThreadEmojiAvatarSelectionModal from './thread-emoji-avatar-selection-modal.react.js'; import MenuItem from '../components/menu-item.react.js'; import Menu from '../components/menu.react.js'; import { allowedMimeTypeString } from '../media/file-utils.js'; const editIcon = (
); type Props = { - +threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, }; function EditThreadAvatarMenu(props: Props): React.Node { const { threadInfo } = props; const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { baseSetThreadAvatar } = editThreadAvatarContext; const removeThreadAvatar = React.useCallback( () => baseSetThreadAvatar(threadInfo.id, { type: 'remove' }), [baseSetThreadAvatar, threadInfo.id], ); const removeMenuItem = React.useMemo( () => ( ), [removeThreadAvatar], ); const imageInputRef = React.useRef(); const onImageMenuItemClicked = React.useCallback( () => imageInputRef.current?.click(), [], ); const uploadAvatarMedia = useUploadAvatarMedia(); const onImageSelected = React.useCallback( async (event: SyntheticEvent) => { const { target } = event; invariant(target instanceof HTMLInputElement, 'target not input'); const uploadResult = await uploadAvatarMedia(target.files[0]); baseSetThreadAvatar(threadInfo.id, uploadResult); }, [baseSetThreadAvatar, threadInfo.id, uploadAvatarMedia], ); const imageMenuItem = React.useMemo( () => ( ), [onImageMenuItemClicked], ); const { pushModal } = useModalContext(); const openEmojiSelectionModal = React.useCallback( () => pushModal(), [pushModal, threadInfo], ); const emojiMenuItem = React.useMemo( () => ( ), [openEmojiSelectionModal], ); const menuItems = React.useMemo(() => { const items = [emojiMenuItem, imageMenuItem]; if (threadInfo.avatar) { items.push(removeMenuItem); } return items; }, [emojiMenuItem, imageMenuItem, removeMenuItem, threadInfo.avatar]); return (
{menuItems}
); } export default EditThreadAvatarMenu; diff --git a/web/avatars/edit-thread-avatar.react.js b/web/avatars/edit-thread-avatar.react.js index 9c636fcd3..d14cd1d13 100644 --- a/web/avatars/edit-thread-avatar.react.js +++ b/web/avatars/edit-thread-avatar.react.js @@ -1,49 +1,52 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; +import type { + RawThreadInfo, + LegacyThreadInfo, +} from 'lib/types/thread-types.js'; import EditThreadAvatarMenu from './edit-thread-avatar-menu.react.js'; import css from './edit-thread-avatar.css'; import ThreadAvatar from './thread-avatar.react.js'; type Props = { - +threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, +disabled?: boolean, }; function EditThreadAvatar(props: Props): React.Node { const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { threadAvatarSaveInProgress } = editThreadAvatarContext; const { threadInfo } = props; const canEditThreadAvatar = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_AVATAR, ); let editThreadAvatarMenu; if (canEditThreadAvatar && !threadAvatarSaveInProgress) { editThreadAvatarMenu = ; } return (
{editThreadAvatarMenu}
); } export default EditThreadAvatar; diff --git a/web/avatars/thread-avatar.react.js b/web/avatars/thread-avatar.react.js index 549cd962c..b2db42e75 100644 --- a/web/avatars/thread-avatar.react.js +++ b/web/avatars/thread-avatar.react.js @@ -1,57 +1,60 @@ // @flow import * as React from 'react'; import { useAvatarForThread, useENSResolvedAvatar, } from 'lib/shared/avatar-utils.js'; import { getSingleOtherUser } from 'lib/shared/thread-utils.js'; import type { AvatarSize } from 'lib/types/avatar-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import type { ThreadInfo, RawThreadInfo } from 'lib/types/thread-types.js'; +import type { + LegacyThreadInfo, + RawThreadInfo, +} from 'lib/types/thread-types.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { - +threadInfo: RawThreadInfo | ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, +size: AvatarSize, +showSpinner?: boolean, }; function ThreadAvatar(props: Props): React.Node { const { threadInfo, size, showSpinner } = props; const avatarInfo = useAvatarForThread(threadInfo); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); let displayUserIDForThread; if (threadInfo.type === threadTypes.PRIVATE) { displayUserIDForThread = viewerID; } else if (threadInfo.type === threadTypes.PERSONAL) { displayUserIDForThread = getSingleOtherUser(threadInfo, viewerID); } const displayUser = useSelector(state => displayUserIDForThread ? state.userStore.userInfos[displayUserIDForThread] : null, ); const resolvedThreadAvatar = useENSResolvedAvatar(avatarInfo, displayUser); return ( ); } export default ThreadAvatar; diff --git a/web/avatars/thread-emoji-avatar-selection-modal.react.js b/web/avatars/thread-emoji-avatar-selection-modal.react.js index 8b8455015..274996d92 100644 --- a/web/avatars/thread-emoji-avatar-selection-modal.react.js +++ b/web/avatars/thread-emoji-avatar-selection-modal.react.js @@ -1,54 +1,57 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { getDefaultAvatar, useAvatarForThread, } from 'lib/shared/avatar-utils.js'; import type { ClientAvatar, ClientEmojiAvatar, } from 'lib/types/avatar-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; +import type { + RawThreadInfo, + LegacyThreadInfo, +} from 'lib/types/thread-types.js'; import EmojiAvatarSelectionModal from './emoji-avatar-selection-modal.react.js'; type Props = { - +threadInfo: ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, }; function ThreadEmojiAvatarSelectionModal(props: Props): React.Node { const { threadInfo } = props; const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { baseSetThreadAvatar, threadAvatarSaveInProgress } = editThreadAvatarContext; const currentThreadAvatar: ClientAvatar = useAvatarForThread(threadInfo); const defaultThreadAvatar: ClientEmojiAvatar = getDefaultAvatar( threadInfo.id, threadInfo.color, ); const setEmojiAvatar = React.useCallback( (pendingEmojiAvatar: ClientEmojiAvatar): Promise => baseSetThreadAvatar(threadInfo.id, pendingEmojiAvatar), [baseSetThreadAvatar, threadInfo.id], ); return ( ); } export default ThreadEmojiAvatarSelectionModal; diff --git a/web/calendar/day.react.js b/web/calendar/day.react.js index 21da15729..bff1ce675 100644 --- a/web/calendar/day.react.js +++ b/web/calendar/day.react.js @@ -1,259 +1,261 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _some from 'lodash/fp/some.js'; import * as React from 'react'; import { createLocalEntry, createLocalEntryActionType, } from 'lib/actions/entry-actions.js'; import { useModalContext, type PushModal, } from 'lib/components/modal-provider.react.js'; import { onScreenThreadInfos as onScreenThreadInfosSelector } from 'lib/selectors/thread-selectors.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import type { EntryInfo } from 'lib/types/entry-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { dateString, dateFromString } from 'lib/utils/date-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import css from './calendar.css'; import type { InnerEntry } from './entry.react.js'; import Entry from './entry.react.js'; import LogInFirstModal from '../modals/account/log-in-first-modal.react.js'; import HistoryModal from '../modals/history/history-modal.react.js'; import ThreadPickerModal from '../modals/threads/thread-picker-modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { htmlTargetFromEvent } from '../vector-utils.js'; import { AddVector, HistoryVector } from '../vectors.react.js'; type BaseProps = { +dayString: string, +entryInfos: $ReadOnlyArray, +startingTabIndex: number, }; type Props = { ...BaseProps, - +onScreenThreadInfos: $ReadOnlyArray, + +onScreenThreadInfos: $ReadOnlyArray< + LegacyThreadInfo | MinimallyEncodedThreadInfo, + >, +viewerID: ?string, +loggedIn: boolean, +nextLocalID: number, +dispatch: Dispatch, +pushModal: PushModal, +popModal: () => void, }; type State = { +hovered: boolean, }; class Day extends React.PureComponent { state: State = { hovered: false, }; entryContainer: ?HTMLDivElement; entryContainerSpacer: ?HTMLDivElement; actionLinks: ?HTMLDivElement; entries: Map = new Map(); componentDidUpdate(prevProps: Props) { if (this.props.entryInfos.length > prevProps.entryInfos.length) { invariant(this.entryContainer, 'entryContainer ref not set'); this.entryContainer.scrollTop = this.entryContainer.scrollHeight; } } render(): React.Node { const now = new Date(); const isToday = dateString(now) === this.props.dayString; const tdClasses = classNames(css.day, { [css.currentDay]: isToday }); let actionLinks = null; const hovered = this.state.hovered; if (hovered) { const actionLinksClassName = `${css.actionLinks} ${css.dayActionLinks}`; actionLinks = ( ); } const entries = this.props.entryInfos .filter(entryInfo => _some(['id', entryInfo.threadID])(this.props.onScreenThreadInfos), ) .map((entryInfo, i) => { const key = entryKey(entryInfo); return ( ); }); const entryContainerClasses = classNames(css.entryContainer, { [css.focusedEntryContainer]: hovered, }); const date = dateFromString(this.props.dayString); return (

{date.getDate()}

{entries}
{actionLinks} ); } actionLinksRef = (actionLinks: ?HTMLDivElement) => { this.actionLinks = actionLinks; }; entryContainerRef = (entryContainer: ?HTMLDivElement) => { this.entryContainer = entryContainer; }; entryContainerSpacerRef = (entryContainerSpacer: ?HTMLDivElement) => { this.entryContainerSpacer = entryContainerSpacer; }; entryRef = (key: string, entry: InnerEntry) => { this.entries.set(key, entry); }; onMouseEnter = () => { this.setState({ hovered: true }); }; onMouseLeave = () => { this.setState({ hovered: false }); }; onClick = (event: SyntheticEvent) => { const target = htmlTargetFromEvent(event); invariant( this.entryContainer instanceof HTMLDivElement, "entryContainer isn't div", ); invariant( this.entryContainerSpacer instanceof HTMLDivElement, "entryContainerSpacer isn't div", ); if ( target === this.entryContainer || target === this.entryContainerSpacer || (this.actionLinks && target === this.actionLinks) ) { this.onAddEntry(event); } }; onAddEntry = (event: SyntheticEvent<*>) => { event.preventDefault(); invariant( this.props.onScreenThreadInfos.length > 0, "onAddEntry shouldn't be clicked if no onScreenThreadInfos", ); if (this.props.onScreenThreadInfos.length === 1) { this.createNewEntry(this.props.onScreenThreadInfos[0].id); } else if (this.props.onScreenThreadInfos.length > 1) { this.props.pushModal( , ); } }; createNewEntry = (threadID: string) => { if (!this.props.loggedIn) { this.props.pushModal(); return; } const viewerID = this.props.viewerID; invariant(viewerID, 'should have viewerID in order to create thread'); this.props.dispatch({ type: createLocalEntryActionType, payload: createLocalEntry( threadID, this.props.nextLocalID, this.props.dayString, viewerID, ), }); }; onHistory = (event: SyntheticEvent) => { event.preventDefault(); this.props.pushModal( , ); }; focusOnFirstEntryNewerThan = (time: number) => { const entryInfo = this.props.entryInfos.find( candidate => candidate.creationTime > time, ); if (entryInfo) { const entry = this.entries.get(entryKey(entryInfo)); invariant(entry, 'entry for entryinfo should be defined'); entry.focus(); } }; } const ConnectedDay: React.ComponentType = React.memo( function ConnectedDay(props) { const onScreenThreadInfos = useSelector(onScreenThreadInfosSelector); const viewerID = useSelector(state => state.currentUserInfo?.id); const loggedIn = useSelector( state => !!(state.currentUserInfo && !state.currentUserInfo.anonymous && true), ); const nextLocalID = useSelector(state => state.nextLocalID); const dispatch = useDispatch(); const { pushModal, popModal } = useModalContext(); return ( ); }, ); export default ConnectedDay; diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js index 2cab56d9e..5947491bc 100644 --- a/web/chat/chat-input-bar.react.js +++ b/web/chat/chat-input-bar.react.js @@ -1,692 +1,692 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import * as React from 'react'; import { joinThreadActionTypes, useJoinThread, newThreadActionTypes, } from 'lib/actions/thread-actions.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { useChatMentionContext, useThreadChatMentionCandidates, } from 'lib/hooks/chat-mention-hooks.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userStoreMentionSearchIndex } from 'lib/selectors/user-selectors.js'; import { getMentionTypeaheadUserSuggestions, getTypeaheadRegexMatches, getUserMentionsCandidates, getMentionTypeaheadChatSuggestions, type MentionTypeaheadSuggestionItem, type TypeaheadMatchedStrings, } from 'lib/shared/mention-utils.js'; import { localIDPrefix, trimMessage } from 'lib/shared/message-utils.js'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { - type ThreadInfo, + type LegacyThreadInfo, type ClientThreadJoinRequest, type ThreadJoinPayload, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import css from './chat-input-bar.css'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import Button from '../components/button.react.js'; import { type InputState, type PendingMultimediaUpload, } from '../input/input-state.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { allowedMimeTypeString } from '../media/file-utils.js'; import Multimedia from '../media/multimedia.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; import { webMentionTypeaheadRegex, getMentionTypeaheadTooltipActions, getMentionTypeaheadTooltipButtons, } from '../utils/typeahead-utils.js'; type BaseProps = { - +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, +inputState: InputState, }; type Props = { ...BaseProps, +viewerID: ?string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +isThreadActive: boolean, +userInfos: UserInfos, +dispatchActionPromise: DispatchActionPromise, +joinThread: (request: ClientThreadJoinRequest) => Promise, +typeaheadMatchedStrings: ?TypeaheadMatchedStrings, +suggestions: $ReadOnlyArray, - +parentThreadInfo: ?ThreadInfo, + +parentThreadInfo: ?LegacyThreadInfo, }; class ChatInputBar extends React.PureComponent { textarea: ?HTMLTextAreaElement; multimediaInput: ?HTMLInputElement; componentDidMount() { this.updateHeight(); if (this.props.isThreadActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isThreadActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props) { if (this.props.isThreadActive && !prevProps.isThreadActive) { this.addReplyListener(); } else if (!this.props.isThreadActive && prevProps.isThreadActive) { this.removeReplyListener(); } const { inputState } = this.props; const prevInputState = prevProps.inputState; if (inputState.draft !== prevInputState.draft) { this.updateHeight(); } if ( inputState.draft !== prevInputState.draft || inputState.textCursorPosition !== prevInputState.textCursorPosition ) { inputState.setTypeaheadState({ canBeVisible: true, }); } const curUploadIDs = ChatInputBar.unassignedUploadIDs( inputState.pendingUploads, ); const prevUploadIDs = ChatInputBar.unassignedUploadIDs( prevInputState.pendingUploads, ); const { multimediaInput, textarea } = this; if ( multimediaInput && _difference(prevUploadIDs)(curUploadIDs).length > 0 ) { // Whenever a pending upload is removed, we reset the file // HTMLInputElement's value field, so that if the same upload occurs again // the onChange call doesn't get filtered multimediaInput.value = ''; } else if ( textarea && _difference(curUploadIDs)(prevUploadIDs).length > 0 ) { // Whenever a pending upload is added, we focus the textarea textarea.focus(); return; } if ( (this.props.threadInfo.id !== prevProps.threadInfo.id || (inputState.textCursorPosition !== prevInputState.textCursorPosition && this.textarea?.selectionStart === this.textarea?.selectionEnd)) && this.textarea ) { this.textarea.focus(); this.textarea?.setSelectionRange( inputState.textCursorPosition, inputState.textCursorPosition, 'none', ); } } static unassignedUploadIDs( pendingUploads: $ReadOnlyArray, ): Array { return pendingUploads .filter( (pendingUpload: PendingMultimediaUpload) => !pendingUpload.messageID, ) .map((pendingUpload: PendingMultimediaUpload) => pendingUpload.localID); } updateHeight() { const textarea = this.textarea; if (textarea) { textarea.style.height = 'auto'; const newHeight = Math.min(textarea.scrollHeight, 150); textarea.style.height = `${newHeight}px`; } } 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); } render(): React.Node { 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 { buttonContent = ( <>

Join Chat

); } joinButton = (
); } const { pendingUploads, cancelPendingUpload } = this.props.inputState; const multimediaPreviews = pendingUploads.map(pendingUpload => { const { uri, mediaType, thumbHash, dimensions } = pendingUpload; let mediaSource = { thumbHash, dimensions }; if (mediaType !== 'encrypted_photo' && mediaType !== 'encrypted_video') { mediaSource = { ...mediaSource, type: mediaType, uri, thumbnailURI: null, }; } else { const { encryptionKey } = pendingUpload; invariant( encryptionKey, 'encryptionKey should be set for encrypted media', ); mediaSource = { ...mediaSource, type: mediaType, blobURI: uri, encryptionKey, thumbnailBlobURI: null, thumbnailEncryptionKey: null, }; } return ( ); }); const previews = multimediaPreviews.length > 0 ? (
{multimediaPreviews}
) : null; let content; // 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. const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); let sendButton; if (this.props.inputState.draft.length) { sendButton = ( ); } if ( threadHasPermission(this.props.threadInfo, threadPermissions.VOICED) || (this.props.threadCreationInProgress && defaultMembersAreVoiced) ) { content = (