diff --git a/.eslintrc.json b/.eslintrc.json index d5e8d3880..73a2700ef 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,89 +1,97 @@ { "root": true, "env": { "es6": true, "es2020": true }, "extends": [ "eslint:recommended", "plugin:react/recommended", "plugin:flowtype/recommended", "plugin:import/errors", "plugin:import/warnings", "plugin:prettier/recommended", "prettier" ], "parser": "@babel/eslint-parser", "plugins": [ "react", "react-hooks", "flowtype", "monorepo", "import", - "@stylistic/js" + "@stylistic/js", + "unicorn" ], "rules": { // Prettier is configured to keep lines to 80 chars, but there are two issues: // - It doesn't handle comments (leaves them as-is) // - It makes all import statements take one line (reformats them) // We want ESLint to warn us in the first case, but not in the second case // since Prettier forces us in the second case. By setting code to 5000, we // make sure ESLint defers to Prettier for import statements. "@stylistic/js/max-len": [ "error", { "code": 5000, "comments": 80, "ignoreUrls": true } ], "flowtype/require-valid-file-annotation": ["error", "always"], "flowtype/require-exact-type": ["error", "never"], "curly": "error", "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "monorepo/no-relative-import": "error", "no-empty": ["error", { "allowEmptyCatch": true }], "import/no-unresolved": 0, "no-unused-vars": ["error", { "ignoreRestSiblings": true }], "react/prop-types": ["error", { "skipUndeclared": true }], "no-shadow": 1, "no-var": "error", "import/extensions": ["error", "always"], "import/order": [ "warn", { "newlines-between": "always", "alphabetize": { "order": "asc", "caseInsensitive": true }, "groups": [["builtin", "external"], "internal"] } ], "prefer-const": "error", "react/jsx-curly-brace-presence": [ "error", { "props": "never", "children": "ignore" } ], "eqeqeq": ["error", "always"], "no-constant-condition": ["error", { "checkLoops": false }], - "consistent-return": "error" + "consistent-return": "error", + "unicorn/filename-case": [ + "error", + { + "case": "kebabCase", + "ignore": [".*_v.*\\.*\\.*\\.js", ".*ModuleSchema\\.js"] + } + ] }, "settings": { "react": { "version": "detect" }, "import/ignore": ["react-native"], "import/internal-regex": "^(lib|native|keyserver|web)/" }, "overrides": [ { "files": "*.cjs", "env": { "node": true, "commonjs": true }, "rules": { "flowtype/require-valid-file-annotation": 0, "flowtype/require-exact-type": 0 } } ] } diff --git a/.lintstagedrc.js b/.lintstagedrc.js index 702ecf34f..ef093934f 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -1,82 +1,82 @@ const { ESLint } = require('eslint'); -const { getClangPaths } = require('./scripts/get_clang_paths'); -const { findRustProjectPath } = require('./scripts/get_cargo_path'); +const { getClangPaths } = require('./scripts/get-clang-paths'); +const { findRustProjectPath } = require('./scripts/get-cargo-path'); const removeIgnoredFiles = async (files) => { const eslint = new ESLint(); const isIgnored = await Promise.all( files.map(file => eslint.isPathIgnored(file)), ); const filteredFiles = files.filter((_, i) => !isIgnored[i]); return filteredFiles.join(' '); } module.exports = { '*.{js,mjs,cjs}': async function eslint(files) { const filesToLint = await removeIgnoredFiles(files); return [`eslint --cache --fix --report-unused-disable-directives --max-warnings=0 ${filesToLint}`]; }, '*.{css,html,md,json}': function prettier(files) { return 'prettier --write ' + files.join(' '); }, '*.sh': function shellCheck(files) { return 'shellcheck -x -P SCRIPTDIR ' + files.join(' '); }, 'lib/**/*.js': function libFlow(files) { return 'yarn workspace lib flow --quiet'; }, 'lib/**/*.js': function libTest(files) { return 'yarn workspace lib test'; }, '{web,lib}/**/*.js': function webFlow(files) { return 'yarn workspace web flow --quiet'; }, 'web/**/*.js': function webTest(files) { return 'yarn workspace web test'; }, '{native,lib}/**/*.js': function nativeFlow(files) { return 'yarn workspace native flow --quiet'; }, '{native,lib}/**/*.js': function nativeFlow(files) { return 'yarn workspace native test'; }, '{keyserver,web,lib}/**/*.js': function keyserverFlow(files) { return 'yarn workspace keyserver flow --quiet'; }, '{keyserver,web,lib}/**/*.js': function keyserverTest(files) { return 'yarn workspace keyserver test'; }, '{landing,lib}/**/*.js': function landingFlow(files) { return 'yarn workspace landing flow --quiet'; }, '{desktop,lib}/**/*.js': function desktopFlow(files) { return 'yarn workspace desktop flow --quiet'; }, 'services/electron-update-server/**/*.js': function desktopFlow(files) { return 'yarn workspace electron-update-server flow --quiet'; }, 'services/identity/**/*.rs': function testIdentity(files) { return 'bash -c "cd services/identity && cargo test"'; }, '{native,services}/**/*.{h,cpp,java,mm}': function clangFormat(files) { files = files.filter(path => { if (path.indexOf('generated') !== -1) { return false; } for (const allowedPath of getClangPaths()) { if (path.indexOf(allowedPath) !== -1) { return true; } } return false; }); return 'clang-format -i ' + files.join(' '); }, '*.rs': function rustFormat(files) { const paths = new Set(files.map(findRustProjectPath).filter(Boolean)); return `yarn rust-pre-commit ${Array.from(paths).join(' ')}`; }, 'services/terraform/**/*.tf': function checkTerraform(files) { return 'yarn terraform-pre-commit'; }, }; diff --git a/keyserver/src/cron/cron.js b/keyserver/src/cron/cron.js index 33d83784a..c99a75d54 100644 --- a/keyserver/src/cron/cron.js +++ b/keyserver/src/cron/cron.js @@ -1,160 +1,160 @@ // @flow import type { Account as OlmAccount } from '@commapp/olm'; import cluster from 'cluster'; import schedule from 'node-schedule'; import { backupDB } from './backups.js'; import { createDailyUpdatesThread } from './daily-updates.js'; import { postLeaderboard } from './phab-leaderboard.js'; import { updateAndReloadGeoipDB } from './update-geoip-db.js'; import { updateIdentityReservedUsernames } from './update-identity-reserved-usernames.js'; import { deleteOrphanedActivity } from '../deleters/activity-deleters.js'; import { deleteExpiredCookies } from '../deleters/cookie-deleters.js'; import { deleteOrphanedDays } from '../deleters/day-deleters.js'; import { deleteOrphanedEntries } from '../deleters/entry-deleters.js'; import { deleteOrphanedMemberships } from '../deleters/membership-deleters.js'; import { deleteOrphanedMessages } from '../deleters/message-deleters.js'; import { deleteOrphanedNotifs } from '../deleters/notif-deleters.js'; import { deleteOrphanedRevisions } from '../deleters/revision-deleters.js'; import { deleteOrphanedRoles } from '../deleters/role-deleters.js'; import { deleteOrphanedSessions, deleteOldWebSessions, } from '../deleters/session-deleters.js'; import { deleteStaleSIWENonceEntries } from '../deleters/siwe-nonce-deleters.js'; import { deleteInaccessibleThreads } from '../deleters/thread-deleters.js'; import { deleteExpiredUpdates } from '../deleters/update-deleters.js'; import { deleteUnassignedUploads } from '../deleters/upload-deleters.js'; import { fetchCallUpdateOlmAccount } from '../updaters/olm-account-updater.js'; import { validateAndUploadAccountPrekeys } from '../utils/olm-utils.js'; -import { synchronizeInviteLinksWithBlobs } from '../utils/synchronizeInviteLinksWithBlobs.js'; +import { synchronizeInviteLinksWithBlobs } from '../utils/synchronize-invite-links-with-blobs.js'; if (cluster.isMaster) { schedule.scheduleJob( '30 3 * * *', // every day at 3:30 AM in the keyserver's timezone async () => { try { // Do everything one at a time to reduce load since we're in no hurry, // and since some queries depend on previous ones. await deleteExpiredCookies(); await deleteInaccessibleThreads(); await deleteOrphanedMemberships(); await deleteOrphanedDays(); await deleteOrphanedEntries(); await deleteOrphanedRevisions(); await deleteOrphanedRoles(); await deleteOrphanedMessages(); await deleteOrphanedActivity(); await deleteOrphanedNotifs(); await deleteOrphanedSessions(); await deleteOldWebSessions(); await deleteExpiredUpdates(); await deleteUnassignedUploads(); await deleteStaleSIWENonceEntries(); } catch (e) { console.warn('encountered error while trying to clean database', e); } }, ); schedule.scheduleJob( '0 */4 * * *', // every four hours async () => { try { await backupDB(); } catch (e) { console.warn('encountered error while trying to backup database', e); } }, ); schedule.scheduleJob( '0 3 ? * 0', // every Sunday at 3:00 AM in the keyserver's timezone async () => { try { await updateAndReloadGeoipDB(); } catch (e) { console.warn( 'encountered error while trying to update GeoIP database', e, ); } }, ); schedule.scheduleJob( '0 0 * * *', // every day at midnight in the keyserver's timezone async () => { try { if (process.env.RUN_COMM_TEAM_DEV_SCRIPTS) { // This is a job that the Comm internal team uses await createDailyUpdatesThread(); } } catch (e) { console.warn( 'encountered error while trying to create daily updates thread', e, ); } }, ); schedule.scheduleJob( '0 0 8 * *', // 8th of every month at midnight in the keyserver's timezone async () => { try { if (process.env.RUN_COMM_TEAM_DEV_SCRIPTS) { // This is a job that the Comm internal team uses await postLeaderboard(); } } catch (e) { console.warn( 'encountered error while trying to post Phabricator leaderboard', e, ); } }, ); schedule.scheduleJob( '0 5 * * *', // every day at 5:00 AM in the keyserver's timezone async () => { try { await updateIdentityReservedUsernames(); } catch (e) { console.warn( 'encountered error while trying to update reserved usernames on ' + 'identity service', e, ); } }, ); schedule.scheduleJob( '0 0 * * *', // every day at midnight in the keyserver's timezone async () => { try { await fetchCallUpdateOlmAccount( 'content', (contentAccount: OlmAccount) => fetchCallUpdateOlmAccount( 'notifications', (notifAccount: OlmAccount) => validateAndUploadAccountPrekeys(contentAccount, notifAccount), ), ); } catch (e) { console.warn('encountered error while trying to validate prekeys', e); } }, ); schedule.scheduleJob( '0 2 * * *', // every day at 2:00 AM in the keyserver's timezone async () => { try { await synchronizeInviteLinksWithBlobs(); } catch (e) { console.warn( 'encountered an error while trying to synchronize invite links with blobs', e, ); } }, ); } diff --git a/keyserver/src/database/migration-config.js b/keyserver/src/database/migration-config.js index ba6a5260b..68ccd9a63 100644 --- a/keyserver/src/database/migration-config.js +++ b/keyserver/src/database/migration-config.js @@ -1,858 +1,858 @@ // @flow import fs from 'fs'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { policyTypes } from 'lib/facts/policies.js'; import { specialRoles } from 'lib/permissions/special-roles.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { permissionsToRemoveInMigration } from 'lib/utils/migration-utils.js'; import { dbQuery, SQL } from '../database/database.js'; import { processMessagesInDBForSearch } from '../database/search-utils.js'; import { deleteThread } from '../deleters/thread-deleters.js'; import { createScriptViewer } from '../session/scripts.js'; import { fetchOlmAccount } from '../updaters/olm-account-updater.js'; import { updateRolesAndPermissionsForAllThreads } from '../updaters/thread-permission-updaters.js'; import { updateThread } from '../updaters/thread-updaters.js'; import { ensureUserCredentials } from '../user/checks.js'; import { createPickledOlmAccount, publishPrekeysToIdentity, } from '../utils/olm-utils.js'; -import { synchronizeInviteLinksWithBlobs } from '../utils/synchronizeInviteLinksWithBlobs.js'; +import { synchronizeInviteLinksWithBlobs } from '../utils/synchronize-invite-links-with-blobs.js'; const botViewer = createScriptViewer(bots.commbot.userID); const migrations: $ReadOnlyMap Promise> = new Map([ [ 0, async () => { await makeSureBaseRoutePathExists('facts/commapp_url.json'); await makeSureBaseRoutePathExists('facts/squadcal_url.json'); }, ], [ 1, async () => { try { await fs.promises.unlink('facts/url.json'); } catch {} }, ], [ 2, async () => { await fixBaseRoutePathForLocalhost('facts/commapp_url.json'); await fixBaseRoutePathForLocalhost('facts/squadcal_url.json'); }, ], [3, updateRolesAndPermissionsForAllThreads], [ 4, async () => { await dbQuery(SQL`ALTER TABLE uploads ADD INDEX container (container)`); }, ], [ 5, async () => { await dbQuery(SQL` ALTER TABLE cookies ADD device_id varchar(255) DEFAULT NULL, ADD public_key varchar(255) DEFAULT NULL, ADD social_proof varchar(255) DEFAULT NULL; `); }, ], [ 7, async () => { await dbQuery( SQL` ALTER TABLE users DROP COLUMN IF EXISTS public_key, MODIFY hash char(60) COLLATE utf8mb4_bin DEFAULT NULL; ALTER TABLE sessions DROP COLUMN IF EXISTS public_key; `, { multipleStatements: true }, ); }, ], [ 8, async () => { await dbQuery( SQL` ALTER TABLE users ADD COLUMN IF NOT EXISTS ethereum_address char(42) DEFAULT NULL; `, ); }, ], [ 9, async () => { await dbQuery( SQL` ALTER TABLE messages ADD COLUMN IF NOT EXISTS target_message bigint(20) DEFAULT NULL; ALTER TABLE messages ADD INDEX target_message (target_message); `, { multipleStatements: true }, ); }, ], [ 10, async () => { await dbQuery(SQL` CREATE TABLE IF NOT EXISTS policy_acknowledgments ( user bigint(20) NOT NULL, policy varchar(255) NOT NULL, date bigint(20) NOT NULL, confirmed tinyint(1) UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (user, policy) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; `); }, ], [ 11, async () => { const time = Date.now(); await dbQuery(SQL` INSERT IGNORE INTO policy_acknowledgments (policy, user, date, confirmed) SELECT ${policyTypes.tosAndPrivacyPolicy}, id, ${time}, 1 FROM users `); }, ], [ 12, async () => { await dbQuery(SQL` CREATE TABLE IF NOT EXISTS siwe_nonces ( nonce char(17) NOT NULL, creation_time bigint(20) NOT NULL, PRIMARY KEY (nonce) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; `); }, ], [ 13, async () => { await Promise.all([ writeSquadCalRoute('facts/squadcal_url.json'), moveToNonApacheConfig('facts/commapp_url.json', '/comm/'), moveToNonApacheConfig('facts/landing_url.json', '/commlanding/'), ]); }, ], [ 14, async () => { await dbQuery(SQL` ALTER TABLE cookies MODIFY COLUMN social_proof mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL; `); }, ], [ 15, async () => { await dbQuery( SQL` ALTER TABLE uploads ADD COLUMN IF NOT EXISTS thread bigint(20) DEFAULT NULL, ADD INDEX IF NOT EXISTS thread (thread); UPDATE uploads SET thread = ( SELECT thread FROM messages WHERE messages.id = uploads.container ); `, { multipleStatements: true }, ); }, ], [ 16, async () => { await dbQuery( SQL` ALTER TABLE cookies DROP COLUMN IF EXISTS public_key; ALTER TABLE cookies ADD COLUMN IF NOT EXISTS signed_identity_keys mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL; `, { multipleStatements: true }, ); }, ], [ 17, async () => { await dbQuery( SQL` ALTER TABLE cookies DROP INDEX device_token, DROP INDEX user_device_token; ALTER TABLE cookies MODIFY device_token mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, ADD UNIQUE KEY device_token (device_token(512)), ADD KEY user_device_token (user,device_token(512)); `, { multipleStatements: true }, ); }, ], [18, updateRolesAndPermissionsForAllThreads], [19, updateRolesAndPermissionsForAllThreads], [ 20, async () => { await dbQuery(SQL` ALTER TABLE threads ADD COLUMN IF NOT EXISTS avatar varchar(191) COLLATE utf8mb4_bin DEFAULT NULL; `); }, ], [ 21, async () => { await dbQuery(SQL` ALTER TABLE reports DROP INDEX IF EXISTS user, ADD INDEX IF NOT EXISTS user_type_platform_creation_time (user, type, platform, creation_time); `); }, ], [ 22, async () => { await dbQuery( SQL` ALTER TABLE cookies MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE entries MODIFY creator varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE focused MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE memberships MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE messages MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE notifications MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE reports MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE revisions MODIFY author varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE sessions MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE threads MODIFY creator varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE updates MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE uploads MODIFY uploader varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE users MODIFY id varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE relationships_undirected MODIFY user1 varchar(255) CHARSET latin1 COLLATE latin1_bin, MODIFY user2 varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE relationships_directed MODIFY user1 varchar(255) CHARSET latin1 COLLATE latin1_bin, MODIFY user2 varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE user_messages MODIFY recipient varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE settings MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE policy_acknowledgments MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; `, { multipleStatements: true }, ); }, ], [23, updateRolesAndPermissionsForAllThreads], [24, updateRolesAndPermissionsForAllThreads], [ 25, async () => { await dbQuery( SQL` CREATE TABLE IF NOT EXISTS message_search ( original_message_id bigint(20) NOT NULL, message_id bigint(20) NOT NULL, processed_content mediumtext COLLATE utf8mb4_bin ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; ALTER TABLE message_search ADD PRIMARY KEY (original_message_id), ADD FULLTEXT INDEX processed_content (processed_content); `, { multipleStatements: true }, ); }, ], [26, processMessagesInDBForSearch], [ 27, async () => { await dbQuery(SQL` ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned tinyint(1) UNSIGNED NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS pin_time bigint(20) DEFAULT NULL, ADD INDEX IF NOT EXISTS thread_pinned (thread, pinned); `); }, ], [ 28, async () => { await dbQuery(SQL` ALTER TABLE threads ADD COLUMN IF NOT EXISTS pinned_count int UNSIGNED NOT NULL DEFAULT 0; `); }, ], [29, updateRolesAndPermissionsForAllThreads], [ 30, async () => { await dbQuery(SQL`DROP TABLE versions;`); }, ], [ 31, async () => { await dbQuery( SQL` CREATE TABLE IF NOT EXISTS invite_links ( id bigint(20) NOT NULL, name varchar(255) CHARSET latin1 NOT NULL, \`primary\` tinyint(1) UNSIGNED NOT NULL DEFAULT 0, role bigint(20) NOT NULL, community bigint(20) NOT NULL, expiration_time bigint(20), limit_of_uses int UNSIGNED, number_of_uses int UNSIGNED NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8; ALTER TABLE invite_links ADD PRIMARY KEY (id), ADD UNIQUE KEY (name), ADD INDEX community_primary (community, \`primary\`); `, { multipleStatements: true }, ); }, ], [ 32, async () => { await dbQuery(SQL` UPDATE messages SET target_message = JSON_VALUE(content, "$.sourceMessageID") WHERE type = ${messageTypes.SIDEBAR_SOURCE}; `); }, ], [ 33, async () => { await dbQuery( SQL` CREATE TABLE IF NOT EXISTS olm_sessions ( cookie_id bigint(20) NOT NULL, is_content tinyint(1) NOT NULL, version bigint(20) NOT NULL, pickled_olm_session text CHARACTER SET latin1 COLLATE latin1_bin NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_bin; ALTER TABLE olm_sessions ADD PRIMARY KEY (cookie_id, is_content); `, { multipleStatements: true }, ); }, ], [ 34, async () => { await dbQuery( SQL` CREATE TABLE IF NOT EXISTS olm_accounts ( is_content tinyint(1) NOT NULL, version bigint(20) NOT NULL, pickling_key text CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, pickled_olm_account text CHARACTER SET latin1 COLLATE latin1_bin NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_bin; ALTER TABLE olm_accounts ADD PRIMARY KEY (is_content); `, { multipleStatements: true }, ); }, ], [ 35, async () => { await createOlmAccounts(); }, ], [36, updateRolesAndPermissionsForAllThreads], [ 37, async () => { await dbQuery( SQL` DELETE FROM olm_accounts; DELETE FROM olm_sessions; `, { multipleStatements: true }, ); await createOlmAccounts(); }, ], [ 38, async () => { const [result] = await dbQuery(SQL` SELECT t.id FROM threads t INNER JOIN memberships m ON m.thread = t.id AND m.role > 0 INNER JOIN users u ON u.id = m.user WHERE t.type = ${threadTypes.PRIVATE} AND t.name = u.ethereum_address `); const threadIDs = result.map(({ id }) => id.toString()); while (threadIDs.length > 0) { // Batch 10 updateThread calls at a time const batch = threadIDs.splice(0, 10); await Promise.all( batch.map(threadID => updateThread( botViewer, { threadID, changes: { name: '', }, }, { silenceMessages: true, ignorePermissions: true, }, ), ), ); } }, ], [39, ensureUserCredentials], [ 40, // Tokens from identity service are 512 characters long () => dbQuery( SQL` ALTER TABLE metadata MODIFY COLUMN data VARCHAR(1023) `, ), ], [ 41, () => dbQuery( SQL` ALTER TABLE memberships DROP INDEX user, ADD KEY user_role_thread (user, role, thread) `, ), ], [ 42, async () => { await dbQuery(SQL` ALTER TABLE roles ADD UNIQUE KEY thread_name (thread, name); `); }, ], [ 43, () => dbQuery( SQL` UPDATE threads SET pinned_count = ( SELECT COUNT(*) FROM messages WHERE messages.thread = threads.id AND messages.pinned = 1 ) `, ), ], [ 44, async () => { const { SIDEBAR_SOURCE, TOGGLE_PIN } = messageTypes; const [result] = await dbQuery(SQL` SELECT m1.thread FROM messages m1 LEFT JOIN messages m2 ON m2.id = m1.target_message WHERE m1.type = ${SIDEBAR_SOURCE} AND m2.type = ${TOGGLE_PIN} `); const threadIDs = new Set(); for (const row of result) { threadIDs.add(row.thread.toString()); } await Promise.all( [...threadIDs].map(threadID => deleteThread(botViewer, { threadID }, { ignorePermissions: true }), ), ); }, ], [ 45, () => dbQuery( SQL` ALTER TABLE uploads CHARSET utf8mb4 COLLATE utf8mb4_bin, MODIFY COLUMN type varchar(255) CHARSET latin1 COLLATE latin1_swedish_ci NOT NULL, MODIFY COLUMN filename varchar(255) CHARSET utf8mb4 COLLATE utf8mb4_bin NOT NULL, MODIFY COLUMN mime varchar(255) CHARSET latin1 COLLATE latin1_swedish_ci NOT NULL, MODIFY COLUMN secret varchar(255) CHARSET latin1 COLLATE latin1_swedish_ci NOT NULL; `, ), ], [ 46, async () => { try { const [content, notif] = await Promise.all([ fetchOlmAccount('content'), fetchOlmAccount('notifications'), ]); await publishPrekeysToIdentity(content.account, notif.account); } catch (e) { console.warn('Encountered error while trying to publish prekeys', e); if (process.env.NODE_ENV !== 'development') { throw e; } } }, ], [ 47, () => dbQuery(SQL`ALTER TABLE cookies MODIFY COLUMN hash char(64) NOT NULL`), ], [ 48, async () => { const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; const query = SQL` UPDATE memberships mm LEFT JOIN ( SELECT m.thread, MAX(m.id) AS message FROM messages m WHERE m.type != ${messageTypes.CREATE_SUB_THREAD} AND m.thread = ${genesis.id} GROUP BY m.thread ) all_users_query ON mm.thread = all_users_query.thread LEFT JOIN ( SELECT m.thread, stm.user, MAX(m.id) AS message FROM messages m LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content WHERE JSON_EXTRACT(stm.permissions, ${visibleExtractString}) IS TRUE AND m.thread = ${genesis.id} GROUP BY m.thread, stm.user ) last_subthread_message_for_user_query ON mm.thread = last_subthread_message_for_user_query.thread AND mm.user = last_subthread_message_for_user_query.user SET mm.last_message = GREATEST(COALESCE(all_users_query.message, 0), COALESCE(last_subthread_message_for_user_query.message, 0)) WHERE mm.thread = ${genesis.id}; `; await dbQuery(query); }, ], [ 49, async () => { if (isDockerEnvironment()) { return; } const defaultCorsConfig = { domain: 'http://localhost:3000', }; await writeJSONToFile(defaultCorsConfig, 'facts/webapp_cors.json'); }, ], [ 50, async () => { await moveToNonApacheConfig('facts/webapp_url.json', '/webapp/'); await moveToNonApacheConfig('facts/keyserver_url.json', '/keyserver/'); }, ], [ 51, async () => { if (permissionsToRemoveInMigration.length === 0) { return; } const setClause = SQL`permissions = JSON_REMOVE(permissions, ${permissionsToRemoveInMigration.map( path => `$.${path}`, )})`; const updateQuery = SQL` UPDATE roles r LEFT JOIN threads t ON t.id = r.thread `; updateQuery.append(SQL`SET `.append(setClause)); updateQuery.append(SQL` WHERE r.name != 'Admins' AND (t.type = ${threadTypes.COMMUNITY_ROOT} OR t.type = ${threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT}) `); await dbQuery(updateQuery); }, ], [ 52, async () => { await dbQuery( SQL` ALTER TABLE roles ADD COLUMN IF NOT EXISTS special_role tinyint(2) UNSIGNED DEFAULT NULL `, ); await updateRolesAndPermissionsForAllThreads(); }, ], [ 53, async () => dbQuery(SQL` ALTER TABLE invite_links ADD COLUMN blob_holder char(36) CHARSET latin1 `), ], [ 54, async () => { await dbQuery( SQL` ALTER TABLE roles ADD COLUMN IF NOT EXISTS special_role tinyint(2) UNSIGNED DEFAULT NULL, DROP KEY IF EXISTS thread, ADD KEY IF NOT EXISTS thread_special_role (thread, special_role); UPDATE roles r JOIN threads t ON r.id = t.default_role SET r.special_role = ${specialRoles.DEFAULT_ROLE}; `, { multipleStatements: true }, ); }, ], [ 55, async () => { await dbQuery( SQL` ALTER TABLE threads DROP COLUMN IF EXISTS default_role `, ); }, ], [ 56, async () => { await dbQuery( SQL` UPDATE roles SET special_role = ${specialRoles.ADMIN_ROLE} WHERE name = 'Admins' `, ); }, ], [57, synchronizeInviteLinksWithBlobs], ]); const newDatabaseVersion: number = Math.max(...migrations.keys()); async function writeJSONToFile(data: any, filePath: string): Promise { console.warn(`updating ${filePath} to ${JSON.stringify(data)}`); const fileHandle = await fs.promises.open(filePath, 'w'); await fileHandle.writeFile(JSON.stringify(data, null, ' '), 'utf8'); await fileHandle.close(); } async function makeSureBaseRoutePathExists(filePath: string): Promise { let readFile, json; try { readFile = await fs.promises.open(filePath, 'r'); const contents = await readFile.readFile('utf8'); json = JSON.parse(contents); } catch { return; } finally { if (readFile) { await readFile.close(); } } if (json.baseRoutePath) { return; } let baseRoutePath; if (json.baseDomain === 'http://localhost') { baseRoutePath = json.basePath; } else if (filePath.endsWith('commapp_url.json')) { baseRoutePath = '/commweb/'; } else { baseRoutePath = '/'; } const newJSON = { ...json, baseRoutePath }; console.warn(`updating ${filePath} to ${JSON.stringify(newJSON)}`); await writeJSONToFile(newJSON, filePath); } async function fixBaseRoutePathForLocalhost(filePath: string): Promise { let readFile, json; try { readFile = await fs.promises.open(filePath, 'r'); const contents = await readFile.readFile('utf8'); json = JSON.parse(contents); } catch { return; } finally { if (readFile) { await readFile.close(); } } if (json.baseDomain !== 'http://localhost') { return; } const baseRoutePath = '/'; json = { ...json, baseRoutePath }; console.warn(`updating ${filePath} to ${JSON.stringify(json)}`); await writeJSONToFile(json, filePath); } async function moveToNonApacheConfig( filePath: string, routePath: string, ): Promise { if (isDockerEnvironment()) { return; } // Since the non-Apache config is so opinionated, just write expected config const newJSON = { baseDomain: 'http://localhost:3000', basePath: routePath, baseRoutePath: routePath, https: false, proxy: 'none', }; await writeJSONToFile(newJSON, filePath); } async function writeSquadCalRoute(filePath: string): Promise { if (isDockerEnvironment()) { return; } // Since the non-Apache config is so opinionated, just write expected config const newJSON = { baseDomain: 'http://localhost:3000', basePath: '/comm/', baseRoutePath: '/', https: false, proxy: 'apache', }; await writeJSONToFile(newJSON, filePath); } async function createOlmAccounts() { const [pickledContentAccount, pickledNotificationsAccount] = await Promise.all([createPickledOlmAccount(), createPickledOlmAccount()]); await dbQuery( SQL` INSERT INTO olm_accounts (is_content, version, pickling_key, pickled_olm_account) VALUES ( TRUE, 0, ${pickledContentAccount.picklingKey}, ${pickledContentAccount.pickledAccount} ), ( FALSE, 0, ${pickledNotificationsAccount.picklingKey}, ${pickledNotificationsAccount.pickledAccount} ); `, ); } function isDockerEnvironment(): boolean { return !!process.env.COMM_DATABASE_HOST; } export { migrations, newDatabaseVersion, createOlmAccounts }; diff --git a/keyserver/src/responders/website-responders.js b/keyserver/src/responders/website-responders.js index 9b14f4a88..3b8e8af20 100644 --- a/keyserver/src/responders/website-responders.js +++ b/keyserver/src/responders/website-responders.js @@ -1,401 +1,401 @@ // @flow import html from 'common-tags/lib/html/index.js'; import { detect as detectBrowser } from 'detect-browser'; import type { $Response, $Request } from 'express'; import fs from 'fs'; import * as React from 'react'; // eslint-disable-next-line import/extensions import ReactDOMServer from 'react-dom/server'; import { promisify } from 'util'; import stores from 'lib/facts/stores.js'; -import getTitle from 'web/title/getTitle.js'; +import getTitle from 'web/title/get-title.js'; import { waitForStream } from '../utils/json-stream.js'; import { getAndAssertKeyserverURLFacts, getAppURLFactsFromRequestURL, getWebAppURLFacts, } from '../utils/urls.js'; // eslint-disable-next-line react/no-deprecated const { renderToNodeStream } = ReactDOMServer; const access = promisify(fs.access); const readFile = promisify(fs.readFile); const googleFontsURL = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600&display=swap'; const localFontsURL = 'fonts/local-fonts.css'; async function getFontsURL() { try { await access(localFontsURL); return localFontsURL; } catch { return googleFontsURL; } } type AssetInfo = { +jsURL: string, +fontsURL: string, +cssInclude: string, +olmFilename: string, +commQueryExecutorFilename: string, +opaqueURL: string, }; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'development') { const fontsURL = await getFontsURL(); assetInfo = { jsURL: 'http://localhost:8080/dev.build.js', fontsURL, cssInclude: '', olmFilename: '', commQueryExecutorFilename: '', opaqueURL: 'http://localhost:8080/opaque-ke.wasm', }; return assetInfo; } try { const manifestString = await readFile('../web/dist/manifest.json', 'utf8'); const manifest = JSON.parse(manifestString); const webworkersManifestString = await readFile( '../web/dist/webworkers/manifest.json', 'utf8', ); const webworkersManifest = JSON.parse(webworkersManifestString); assetInfo = { jsURL: `compiled/${manifest['browser.js']}`, fontsURL: googleFontsURL, cssInclude: html` `, olmFilename: manifest['olm.wasm'], commQueryExecutorFilename: webworkersManifest['comm_query_executor.wasm'], opaqueURL: `compiled/${manifest['comm_opaque2_wasm_bg.wasm']}`, }; return assetInfo; } catch { throw new Error( 'Could not load manifest.json for web build. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } let webpackCompiledRootComponent: ?React.ComponentType<{}> = null; async function getWebpackCompiledRootComponentForSSR() { if (webpackCompiledRootComponent) { return webpackCompiledRootComponent; } try { // $FlowFixMe web/dist doesn't always exist const webpackBuild = await import('web/dist/app.build.cjs'); webpackCompiledRootComponent = webpackBuild.app.default; return webpackCompiledRootComponent; } catch { throw new Error( 'Could not load app.build.cjs. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } function stripLastSlash(input: string): string { return input.replace(/\/$/, ''); } async function websiteResponder(req: $Request, res: $Response): Promise { const { basePath } = getAppURLFactsFromRequestURL(req.originalUrl); const baseURL = stripLastSlash(basePath); const keyserverURLFacts = getAndAssertKeyserverURLFacts(); const keyserverURL = `${keyserverURLFacts.baseDomain}${stripLastSlash( keyserverURLFacts.basePath, )}`; const loadingPromise = getWebpackCompiledRootComponentForSSR(); const assetInfoPromise = getAssetInfo(); const { jsURL, fontsURL, cssInclude, olmFilename, opaqueURL, commQueryExecutorFilename, } = await assetInfoPromise; // prettier-ignore res.write(html` ${getTitle(0)} ${cssInclude}
`); const Loading = await loadingPromise; const reactStream = renderToNodeStream(); reactStream.pipe(res, { end: false }); await waitForStream(reactStream); res.end(html`
`); } const inviteSecretRegex = /^[a-z0-9]+$/i; // On native, if this responder is called, it means that the app isn't // installed. async function inviteResponder(req: $Request, res: $Response): Promise { const { secret } = req.params; const userAgent = req.get('User-Agent'); const detectionResult = detectBrowser(userAgent); if (detectionResult.os === 'Android OS') { const isSecretValid = inviteSecretRegex.test(secret); const referrer = isSecretValid ? `&referrer=${encodeURIComponent(`utm_source=invite/${secret}`)}` : ''; const redirectUrl = `${stores.googlePlayUrl}${referrer}`; res.writeHead(301, { Location: redirectUrl, }); res.end(); return; } else if (detectionResult.os !== 'iOS') { const urlFacts = getWebAppURLFacts(); const baseDomain = urlFacts?.baseDomain ?? ''; const basePath = urlFacts?.basePath ?? '/'; const redirectUrl = `${baseDomain}${basePath}handle/invite/${secret}`; res.writeHead(301, { Location: redirectUrl, }); res.end(); return; } const fontsURL = await getFontsURL(); res.end(html` Comm

Comm

To join this community, download the Comm app and reopen this invite link

Download Comm Invite Link
Visit Comm’s website arrow up right `); } export { websiteResponder, inviteResponder }; diff --git a/keyserver/src/utils/synchronizeInviteLinksWithBlobs.js b/keyserver/src/utils/synchronize-invite-links-with-blobs.js similarity index 100% rename from keyserver/src/utils/synchronizeInviteLinksWithBlobs.js rename to keyserver/src/utils/synchronize-invite-links-with-blobs.js diff --git a/landing/hero.react.js b/landing/hero.react.js index 8a9c88f3c..16854f2ca 100644 --- a/landing/hero.react.js +++ b/landing/hero.react.js @@ -1,30 +1,30 @@ // @flow import * as React from 'react'; import HeroContent from './hero-content.react.js'; import css from './hero.css'; -import Picture from './Picture.react.js'; +import Picture from './picture.react.js'; type Props = { +url: string, +alt: string, }; function Hero(props: Props): React.Node { const { url, alt } = props; return (
); } export default Hero; diff --git a/landing/Picture.react.js b/landing/picture.react.js similarity index 100% rename from landing/Picture.react.js rename to landing/picture.react.js diff --git a/lib/components/connected-wallet-info.react.js b/lib/components/connected-wallet-info.react.js index 084c31c0c..2b0c1191a 100644 --- a/lib/components/connected-wallet-info.react.js +++ b/lib/components/connected-wallet-info.react.js @@ -1,72 +1,72 @@ // @flow import { emojiAvatarForAddress, useAccountModal } from '@rainbow-me/rainbowkit'; import * as React from 'react'; import { useAccount, useEnsAvatar } from 'wagmi'; import css from './connected-wallet-info.css'; -import SWMansionIcon from './SWMansionIcon.react.js'; +import SWMansionIcon from './swmansion-icon.react.js'; import { useENSName } from '../hooks/ens-cache.js'; function shortenAddressToFitWidth(address: string): string { if (address.length < 22) { return address; } return `${address.slice(0, 10)}…${address.slice(-10)}`; } type RainbowKitEmojiAvatar = { +color: string, +emoji: string, }; function ConnectedWalletInfo(): React.Node { const { address } = useAccount(); const { openAccountModal } = useAccountModal(); const potentiallyENSName = useENSName(address); const emojiAvatar: RainbowKitEmojiAvatar = React.useMemo( () => emojiAvatarForAddress(address), [address], ); const emojiAvatarStyle = React.useMemo( () => ({ backgroundColor: emojiAvatar.color }), [emojiAvatar.color], ); const emojiAvatarView = React.useMemo( () =>

{emojiAvatar.emoji}

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

{shortenAddressToFitWidth(potentiallyENSName)}

); } export default ConnectedWalletInfo; diff --git a/lib/components/SWMansionIcon.react.js b/lib/components/swmansion-icon.react.js similarity index 100% rename from lib/components/SWMansionIcon.react.js rename to lib/components/swmansion-icon.react.js diff --git a/native/codegen/src/CodeGen.js b/native/codegen/src/code-gen.js similarity index 100% rename from native/codegen/src/CodeGen.js rename to native/codegen/src/code-gen.js diff --git a/native/codegen/src/index.js b/native/codegen/src/index.js index a461b36f3..efcde0d18 100644 --- a/native/codegen/src/index.js +++ b/native/codegen/src/index.js @@ -1,22 +1,22 @@ /** * @flow strict-local * @format */ import path from 'path'; -import codeGen from './CodeGen.js'; +import codeGen from './code-gen.js'; ('use strict'); const outPath = path.resolve('./cpp/CommonCpp/_generated'); // CommCoreModule const coreModuleSchemaPath = path.resolve('./schema/CommCoreModuleSchema.js'); codeGen('comm', coreModuleSchemaPath, ['cpp', 'h'], outPath); // CommUtilsModule const utilsModuleSchemaPath = path.resolve('./schema/CommUtilsModuleSchema.js'); codeGen('utils', utilsModuleSchemaPath, ['cpp', 'h'], outPath); // CommRustModule const rustModuleSchemaPath = path.resolve('./schema/CommRustModuleSchema.js'); codeGen('rust', rustModuleSchemaPath, ['cpp', 'h'], outPath); diff --git a/package.json b/package.json index ebb126465..dfe299366 100644 --- a/package.json +++ b/package.json @@ -1,58 +1,59 @@ { "private": true, "license": "BSD-3-Clause", "workspaces": [ "lib", "web", "native", "keyserver", "landing", "desktop", "keyserver/addons/rust-node-addon", "native/expo-modules/comm-expo-package", "services/electron-update-server", "web/opaque-ke-wasm" ], "scripts": { "clean": "yarn workspace lib clean && yarn workspace web clean && yarn workspace native clean && yarn workspace keyserver clean && yarn workspace landing clean && yarn workspace desktop clean && yarn workspace rust-node-addon clean && yarn workspace electron-update-server clean && rm -rf node_modules/", "cleaninstall": "(killall flow || pkill flow || true) && yarn clean && yarn", "ci-cleaninstall": "yarn cleaninstall --frozen-lockfile --skip-optional --network-timeout 180000", "eslint:all": "eslint --report-unused-disable-directives .", "eslint:fix": "eslint --fix --report-unused-disable-directives .", "clang-format-all": "eval `node scripts/get_clang_paths_cli.js` | xargs clang-format -i", "rust-pre-commit": "./scripts/rust_pre_commit.sh", "terraform-pre-commit": "./scripts/terraform_pre_commit.sh", "prepare": "husky install", "arcpatch": "git pull --all --tags && arc patch", "postinstall": "bash ./postinstall.sh", "flow:all": "yarn workspace lib flow && yarn workspace web flow && yarn workspace landing flow && yarn workspace native flow && yarn workspace keyserver flow && yarn workspace desktop flow && yarn workspace electron-update-server flow", "jest:all": "yarn workspace lib test && yarn workspace keyserver test && yarn workspace web test && yarn workspace native test" }, "devDependencies": { "@babel/eslint-parser": "^7.23.3", "@stylistic/eslint-plugin-js": "^1.5.3", "clang-format": "^1.8.0", "core-js": "^3.6.5", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-flowtype": "^8.0.3", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^27.6.1", "eslint-plugin-monorepo": "^0.3.2", "eslint-plugin-prettier": "^5.1.2", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-native": "^4.1.0", + "eslint-plugin-unicorn": "^51.0.1", "find-up": "^5.0.0", "flow-mono-cli": "^1.5.0", "gaxios": "^4.3.2", "husky": "^7.0.0", "lint-staged": "^12.1.4", "patch-package": "^6.4.7", "postinstall-postinstall": "^2.0.0", "prettier": "^3.1.1" }, "resolutions": { "react-native-flipper": "https://registry.yarnpkg.com/@favware/skip-dependency/-/skip-dependency-1.1.1.tgz" } } diff --git a/scripts/get_cargo_path.js b/scripts/get-cargo-path.js similarity index 100% rename from scripts/get_cargo_path.js rename to scripts/get-cargo-path.js diff --git a/scripts/get_clang_paths_cli.js b/scripts/get-clang-paths-cli.js similarity index 88% rename from scripts/get_clang_paths_cli.js rename to scripts/get-clang-paths-cli.js index 7e2e79c3c..5d51afb8b 100644 --- a/scripts/get_clang_paths_cli.js +++ b/scripts/get-clang-paths-cli.js @@ -1,20 +1,20 @@ // @flow -const { clangPaths } = require('./get_clang_paths.js'); +const { clangPaths } = require('./get-clang-paths.js'); (() => { let command = '('; clangPaths.forEach(pathItem => { const { path, extensions, excludes } = pathItem; command += `find ${path} `; command += extensions .map(extension => `-name '*.${extension}' `) .join('-o '); if (excludes) { command += `| grep -v '${excludes.map(exclude => exclude).join('\\|')}'`; } command += '; '; }); command += ')'; console.log(command); })(); diff --git a/scripts/get_clang_paths.js b/scripts/get-clang-paths.js similarity index 100% rename from scripts/get_clang_paths.js rename to scripts/get-clang-paths.js diff --git a/web/account/password-input.react.js b/web/account/password-input.react.js index 453fbe54f..4aa8b27aa 100644 --- a/web/account/password-input.react.js +++ b/web/account/password-input.react.js @@ -1,51 +1,51 @@ // @flow import * as React from 'react'; import SWMansionIcon, { type Icon, -} from 'lib/components/SWMansionIcon.react.js'; +} from 'lib/components/swmansion-icon.react.js'; import type { ReactRefSetter } from 'lib/types/react-types.js'; import css from './password-input.css'; import Button from '../components/button.react.js'; import Input, { type BaseInputProps } from '../modals/input.react.js'; type PasswordInputProps = BaseInputProps; function PasswordInput( props: PasswordInputProps, ref: ReactRefSetter, ): React.Node { const [htmlInputType, setHtmlInputType] = React.useState<'password' | 'text'>( 'password', ); const onToggleShowPassword = React.useCallback(() => { setHtmlInputType(oldType => (oldType === 'password' ? 'text' : 'password')); }, []); const icon: Icon = htmlInputType === 'password' ? 'eye-open' : 'eye-closed'; return (
); } const ForwardedPasswordInput: React.AbstractComponent< PasswordInputProps, HTMLInputElement, > = React.forwardRef(PasswordInput); export default ForwardedPasswordInput; diff --git a/web/account/siwe-login-form.react.js b/web/account/siwe-login-form.react.js index 2906a9b2a..b9d93c705 100644 --- a/web/account/siwe-login-form.react.js +++ b/web/account/siwe-login-form.react.js @@ -1,265 +1,265 @@ // @flow import '@rainbow-me/rainbowkit/styles.css'; import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { useAccount, useWalletClient } from 'wagmi'; import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js'; import { getSIWENonce, getSIWENonceActionTypes, siweAuth, siweAuthActionTypes, } from 'lib/actions/siwe-actions.js'; import ConnectedWalletInfo from 'lib/components/connected-wallet-info.react.js'; -import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; +import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import stores from 'lib/facts/stores.js'; import { logInExtraInfoSelector } from 'lib/selectors/account-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LogInStartingPayload, LogInExtraInfo, } from 'lib/types/account-types.js'; import type { OLMIdentityKeys } from 'lib/types/crypto-types.js'; import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { createSIWEMessage, getSIWEStatementForPublicKey, siweMessageSigningExplanationStatements, } from 'lib/utils/siwe-utils.js'; import { useGetSignedIdentityKeysBlob } from './account-hooks.js'; import HeaderSeparator from './header-separator.react.js'; import css from './siwe.css'; import Button from '../components/button.react.js'; import OrBreak from '../components/or-break.react.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { useSelector } from '../redux/redux-utils.js'; type SIWELogInError = 'account_does_not_exist'; type SIWELoginFormProps = { +cancelSIWEAuthFlow: () => void, }; const getSIWENonceLoadingStatusSelector = createLoadingStatusSelector( getSIWENonceActionTypes, ); const siweAuthLoadingStatusSelector = createLoadingStatusSelector(siweAuthActionTypes); function SIWELoginForm(props: SIWELoginFormProps): React.Node { const { address } = useAccount(); const { data: signer } = useWalletClient(); const dispatchActionPromise = useDispatchActionPromise(); const getSIWENonceCall = useLegacyAshoatKeyserverCall(getSIWENonce); const getSIWENonceCallLoadingStatus = useSelector( getSIWENonceLoadingStatusSelector, ); const siweAuthLoadingStatus = useSelector(siweAuthLoadingStatusSelector); const siweAuthCall = useLegacyAshoatKeyserverCall(siweAuth); const logInExtraInfo = useSelector(logInExtraInfoSelector); const [siweNonce, setSIWENonce] = React.useState(null); const siweNonceShouldBeFetched = !siweNonce && getSIWENonceCallLoadingStatus !== 'loading'; React.useEffect(() => { if (!siweNonceShouldBeFetched) { return; } void dispatchActionPromise( getSIWENonceActionTypes, (async () => { const response = await getSIWENonceCall(); setSIWENonce(response); })(), ); }, [dispatchActionPromise, getSIWENonceCall, siweNonceShouldBeFetched]); const primaryIdentityPublicKeys: ?OLMIdentityKeys = useSelector( state => state.cryptoStore?.primaryIdentityKeys, ); const getSignedIdentityKeysBlob = useGetSignedIdentityKeysBlob(); const callSIWEAuthEndpoint = React.useCallback( async (message: string, signature: string, extraInfo: LogInExtraInfo) => { const signedIdentityKeysBlob = await getSignedIdentityKeysBlob(); invariant( signedIdentityKeysBlob, 'signedIdentityKeysBlob must be set in attemptSIWEAuth', ); try { return await siweAuthCall({ message, signature, signedIdentityKeysBlob, doNotRegister: true, ...extraInfo, }); } catch (e) { if ( e instanceof ServerError && e.message === 'account_does_not_exist' ) { setError('account_does_not_exist'); } throw e; } }, [getSignedIdentityKeysBlob, siweAuthCall], ); const attemptSIWEAuth = React.useCallback( (message: string, signature: string) => { return dispatchActionPromise( siweAuthActionTypes, callSIWEAuthEndpoint(message, signature, logInExtraInfo), undefined, ({ calendarQuery: logInExtraInfo.calendarQuery }: LogInStartingPayload), ); }, [callSIWEAuthEndpoint, dispatchActionPromise, logInExtraInfo], ); const dispatch = useDispatch(); const onSignInButtonClick = React.useCallback(async () => { invariant(signer, 'signer must be present during SIWE attempt'); invariant(siweNonce, 'nonce must be present during SIWE attempt'); invariant( primaryIdentityPublicKeys, 'primaryIdentityPublicKeys must be present during SIWE attempt', ); const statement = getSIWEStatementForPublicKey( primaryIdentityPublicKeys.ed25519, ); const message = createSIWEMessage(address, statement, siweNonce); const signature = await signer.signMessage({ message }); await attemptSIWEAuth(message, signature); dispatch({ type: setDataLoadedActionType, payload: { dataLoaded: true, }, }); }, [ address, attemptSIWEAuth, primaryIdentityPublicKeys, signer, siweNonce, dispatch, ]); const { cancelSIWEAuthFlow } = props; const backButtonColor = React.useMemo( () => ({ backgroundColor: '#211E2D' }), [], ); const signInButtonColor = React.useMemo( () => ({ backgroundColor: '#6A20E3' }), [], ); const [error, setError] = React.useState(); const mainMiddleAreaClassName = classNames({ [css.mainMiddleArea]: true, [css.hidden]: !!error, }); const errorOverlayClassNames = classNames({ [css.errorOverlay]: true, [css.hidden]: !error, }); if ( siweAuthLoadingStatus === 'loading' || !siweNonce || !primaryIdentityPublicKeys ) { return (
); } let errorText; if (error === 'account_does_not_exist') { errorText = ( <>

No Comm account found for that Ethereum wallet!

We require that users register on their mobile devices. Comm relies on a primary device capable of scanning QR codes in order to authorize secondary devices.

You can install our iOS app  here , or our Android app  here .

); } return (

Sign in with Ethereum

Wallet Connected

{siweMessageSigningExplanationStatements}

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

{errorText}
); } export default SIWELoginForm; diff --git a/web/app-list/app-list-item.react.js b/web/app-list/app-list-item.react.js index 81a98042a..a373649c4 100644 --- a/web/app-list/app-list-item.react.js +++ b/web/app-list/app-list-item.react.js @@ -1,39 +1,39 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import SWMansionIcon, { type Icon, -} from 'lib/components/SWMansionIcon.react.js'; +} from 'lib/components/swmansion-icon.react.js'; import type { WebNavigationTab } from 'lib/types/nav-types.js'; import css from './app-list-item.css'; import { useSelector } from '../redux/redux-utils.js'; import { navTabSelector } from '../selectors/nav-selectors.js'; type Props = { +id: WebNavigationTab, +name: string, +icon: Icon, +onClick: () => mixed, }; function AppListItem(props: Props): React.Node { const { id, name, icon, onClick } = props; const currentSelectedApp = useSelector(navTabSelector); const className = classNames(css.container, { [css.selected]: currentSelectedApp === id, }); return (
{name}
); } export default AppListItem; diff --git a/web/avatars/edit-thread-avatar-menu.react.js b/web/avatars/edit-thread-avatar-menu.react.js index ba321f385..d2ec10eb1 100644 --- a/web/avatars/edit-thread-avatar-menu.react.js +++ b/web/avatars/edit-thread-avatar-menu.react.js @@ -1,124 +1,124 @@ // @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 SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import type { ThreadInfo, RawThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-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, }; 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]); await 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-user-avatar-menu.react.js b/web/avatars/edit-user-avatar-menu.react.js index 6cabea41f..c4083f73e 100644 --- a/web/avatars/edit-user-avatar-menu.react.js +++ b/web/avatars/edit-user-avatar-menu.react.js @@ -1,158 +1,158 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditUserAvatarContext } from 'lib/components/edit-user-avatar-provider.react.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; -import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; +import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import { useENSAvatar } from 'lib/hooks/ens-cache.js'; import { getETHAddressForUserInfo } from 'lib/shared/account-utils.js'; import { useUploadAvatarMedia } from './avatar-hooks.react.js'; import css from './edit-avatar-menu.css'; import UserEmojiAvatarSelectionModal from './user-emoji-avatar-selection-modal.react.js'; -import CommIcon from '../CommIcon.react.js'; +import CommIcon from '../comm-icon.react.js'; import MenuItem from '../components/menu-item.react.js'; import Menu from '../components/menu.react.js'; import { allowedMimeTypeString } from '../media/file-utils.js'; import { useSelector } from '../redux/redux-utils.js'; const editIcon = (
); function EditUserAvatarMenu(): React.Node { const currentUserInfo = useSelector(state => state.currentUserInfo); const ethAddress: ?string = React.useMemo( () => getETHAddressForUserInfo(currentUserInfo), [currentUserInfo], ); const ensAvatarURI: ?string = useENSAvatar(ethAddress); const editUserAvatarContext = React.useContext(EditUserAvatarContext); invariant(editUserAvatarContext, 'editUserAvatarContext should be set'); const { baseSetUserAvatar } = editUserAvatarContext; const removeUserAvatar = React.useCallback( () => baseSetUserAvatar({ type: 'remove' }), [baseSetUserAvatar], ); const { pushModal } = useModalContext(); const openEmojiSelectionModal = React.useCallback( () => pushModal(), [pushModal], ); const emojiMenuItem = React.useMemo( () => ( ), [openEmojiSelectionModal], ); 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]); await baseSetUserAvatar(uploadResult); }, [baseSetUserAvatar, uploadAvatarMedia], ); const imageMenuItem = React.useMemo( () => ( ), [onImageMenuItemClicked], ); const setENSUserAvatar = React.useCallback( () => baseSetUserAvatar({ type: 'ens' }), [baseSetUserAvatar], ); const ethereumIcon = React.useMemo( () => , [], ); const ensMenuItem = React.useMemo( () => ( ), [ethereumIcon, setENSUserAvatar], ); const removeMenuItem = React.useMemo( () => ( ), [removeUserAvatar], ); const menuItems = React.useMemo(() => { const items = [emojiMenuItem, imageMenuItem]; if (ensAvatarURI) { items.push(ensMenuItem); } if (currentUserInfo?.avatar) { items.push(removeMenuItem); } return items; }, [ currentUserInfo?.avatar, emojiMenuItem, ensAvatarURI, ensMenuItem, imageMenuItem, removeMenuItem, ]); return (
{menuItems}
); } export default EditUserAvatarMenu; diff --git a/web/avatars/emoji-avatar-selection-modal.react.js b/web/avatars/emoji-avatar-selection-modal.react.js index ba73759b0..91f63478a 100644 --- a/web/avatars/emoji-avatar-selection-modal.react.js +++ b/web/avatars/emoji-avatar-selection-modal.react.js @@ -1,190 +1,190 @@ // @flow import data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; -import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; +import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import type { ClientAvatar, ClientEmojiAvatar, } from 'lib/types/avatar-types.js'; import Avatar from './avatar.react.js'; import css from './emoji-avatar-selection-modal.css'; import Button, { buttonThemes } from '../components/button.react.js'; import Tabs, { type TabData } from '../components/tabs.react.js'; import LoadingIndicator from '../loading-indicator.react.js'; import Modal from '../modals/modal.react.js'; import ColorSelector from '../modals/threads/color-selector.react.js'; type TabType = 'emoji' | 'color'; const tabsData: $ReadOnlyArray> = [ { id: 'emoji', header: 'Emoji', }, { id: 'color', header: 'Color', }, ]; type Props = { +currentAvatar: ClientAvatar, +defaultAvatar: ClientEmojiAvatar, +setEmojiAvatar: (pendingEmojiAvatar: ClientEmojiAvatar) => Promise, +avatarSaveInProgress: boolean, }; function EmojiAvatarSelectionModal(props: Props): React.Node { const { popModal } = useModalContext(); const { currentAvatar, defaultAvatar, setEmojiAvatar, avatarSaveInProgress } = props; const [updateAvatarStatus, setUpdateAvatarStatus] = React.useState(); const [pendingAvatarEmoji, setPendingAvatarEmoji] = React.useState( currentAvatar.type === 'emoji' ? currentAvatar.emoji : defaultAvatar.emoji, ); const [pendingAvatarColor, setPendingAvatarColor] = React.useState( currentAvatar.type === 'emoji' ? currentAvatar.color : defaultAvatar.color, ); const pendingEmojiAvatar: ClientEmojiAvatar = React.useMemo( () => ({ type: 'emoji', emoji: pendingAvatarEmoji, color: pendingAvatarColor, }), [pendingAvatarColor, pendingAvatarEmoji], ); const onEmojiSelect = React.useCallback( (selection: { +native: string, ... }) => { setUpdateAvatarStatus(); setPendingAvatarEmoji(selection.native); }, [], ); const onColorSelection = React.useCallback((hex: string) => { setUpdateAvatarStatus(); setPendingAvatarColor(hex); }, []); const onSaveAvatar = React.useCallback(async () => { try { await setEmojiAvatar(pendingEmojiAvatar); setUpdateAvatarStatus('success'); } catch { setUpdateAvatarStatus('failure'); } }, [setEmojiAvatar, pendingEmojiAvatar]); let saveButtonContent; let buttonColor; if (avatarSaveInProgress) { buttonColor = buttonThemes.standard; saveButtonContent = ; } else if (updateAvatarStatus === 'success') { buttonColor = buttonThemes.success; saveButtonContent = ( <> {'Avatar update succeeded.'} ); } else if (updateAvatarStatus === 'failure') { buttonColor = buttonThemes.danger; saveButtonContent = ( <> {'Avatar update failed. Please try again.'} ); } else { buttonColor = buttonThemes.standard; saveButtonContent = 'Save Avatar'; } const [currentTabType, setCurrentTabType] = React.useState('emoji'); const subheader = React.useMemo( () => ( <>
), [avatarSaveInProgress, currentTabType, pendingEmojiAvatar], ); const saveButton = React.useMemo( () => ( ), [avatarSaveInProgress, buttonColor, onSaveAvatar, saveButtonContent], ); const tabContent = React.useMemo(() => { if (currentTabType === 'emoji') { return (
); } return (
); }, [currentTabType, onColorSelection, onEmojiSelect, pendingAvatarColor]); return (
{tabContent}
); } export default EmojiAvatarSelectionModal; diff --git a/web/calendar/calendar.react.js b/web/calendar/calendar.react.js index afd67528b..c252bf5c7 100644 --- a/web/calendar/calendar.react.js +++ b/web/calendar/calendar.react.js @@ -1,292 +1,292 @@ // @flow import dateFormat from 'dateformat'; import invariant from 'invariant'; import * as React from 'react'; import { updateCalendarQueryActionTypes, useUpdateCalendarQuery, } from 'lib/actions/entry-actions.js'; import type { UpdateCalendarQueryInput } from 'lib/actions/entry-actions.js'; -import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; +import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import { currentDaysToEntries } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { type EntryInfo, type CalendarQuery, type CalendarQueryUpdateResult, type CalendarQueryUpdateStartingPayload, } from 'lib/types/entry-types.js'; import type { WebNavInfo } from 'lib/types/nav-types.js'; import { getDate, dateString, startDateForYearAndMonth, endDateForYearAndMonth, } from 'lib/utils/date-utils.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import css from './calendar.css'; import Day from './day.react.js'; import FilterPanel from './filter-panel.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { yearAssertingSelector, monthAssertingSelector, webCalendarQuery, } from '../selectors/nav-selectors.js'; import { canonicalURLFromReduxState } from '../url-utils.js'; type StartAndEndDates = { +startDate: string, +endDate: string, }; type BaseProps = { +url: string, }; type Props = { ...BaseProps, +year: number, +month: number, +daysToEntries: { +[dayString: string]: EntryInfo[] }, +navInfo: WebNavInfo, +currentCalendarQuery: () => CalendarQuery, +loggedIn: boolean, +dispatchActionPromise: DispatchActionPromise, +updateCalendarQuery: ( input: UpdateCalendarQueryInput, ) => Promise, }; type State = { +filterPanelOpen: boolean, }; class Calendar extends React.PureComponent { state: State = { filterPanelOpen: false, }; getDate( dayOfMonth: number, monthInput: ?number = undefined, yearInput: ?number = undefined, ): Date { return getDate( yearInput ? yearInput : this.props.year, monthInput ? monthInput : this.props.month, dayOfMonth, ); } prevMonthDates(): StartAndEndDates { const { year, month } = this.props; const lastMonthDate = getDate(year, month - 1, 1); const prevYear = lastMonthDate.getFullYear(); const prevMonth = lastMonthDate.getMonth() + 1; return { startDate: startDateForYearAndMonth(prevYear, prevMonth), endDate: endDateForYearAndMonth(prevYear, prevMonth), }; } nextMonthDates(): StartAndEndDates { const { year, month } = this.props; const nextMonthDate = getDate(year, month + 1, 1); const nextYear = nextMonthDate.getFullYear(); const nextMonth = nextMonthDate.getMonth() + 1; return { startDate: startDateForYearAndMonth(nextYear, nextMonth), endDate: endDateForYearAndMonth(nextYear, nextMonth), }; } render(): React.Node { const { year, month } = this.props; const monthName = dateFormat(getDate(year, month, 1), 'mmmm'); const prevURL = canonicalURLFromReduxState( { ...this.props.navInfo, ...this.prevMonthDates() }, this.props.url, this.props.loggedIn, ); const nextURL = canonicalURLFromReduxState( { ...this.props.navInfo, ...this.nextMonthDates() }, this.props.url, this.props.loggedIn, ); const lastDayOfMonth = this.getDate(0, this.props.month + 1); const totalDaysInMonth = lastDayOfMonth.getDate(); const firstDayToPrint = 1 - this.getDate(1).getDay(); const lastDayToPrint = totalDaysInMonth + 6 - lastDayOfMonth.getDay(); const rows = []; let columns = []; let week = 1; let tabIndex = 1; for ( let curDayOfMonth = firstDayToPrint; curDayOfMonth <= lastDayToPrint; curDayOfMonth++ ) { if (curDayOfMonth < 1 || curDayOfMonth > totalDaysInMonth) { columns.push(); } else { const dayString = dateString( this.props.year, this.props.month, curDayOfMonth, ); const entries = this.props.daysToEntries[dayString]; invariant( entries, 'the currentDaysToEntries selector should make sure all dayStrings ' + `in the current range have entries, but ${dayString} did not`, ); columns.push( , ); tabIndex += entries.length; } if (columns.length === 7) { rows.push({columns}); columns = []; } } let filterPanel = null; let filterButton = null; if (this.state.filterPanelOpen) { filterPanel = ; } else { filterButton = ( ); } return (
{filterPanel}
{filterButton}
{rows}
Sunday Monday Tuesday Wednesday Thursday Friday Saturday
); } toggleFilters = (event: SyntheticEvent) => { event.preventDefault(); this.setState({ filterPanelOpen: !this.state.filterPanelOpen }); }; onClickPrevURL = (event: SyntheticEvent) => { event.preventDefault(); const currentCalendarQuery = this.props.currentCalendarQuery(); const newCalendarQuery = { ...currentCalendarQuery, ...this.prevMonthDates(), }; void this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery({ calendarQuery: newCalendarQuery, reduxAlreadyUpdated: true, }), undefined, ({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload), ); }; onClickNextURL = (event: SyntheticEvent) => { event.preventDefault(); const currentCalendarQuery = this.props.currentCalendarQuery(); const newCalendarQuery = { ...currentCalendarQuery, ...this.nextMonthDates(), }; void this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery({ calendarQuery: newCalendarQuery, reduxAlreadyUpdated: true, }), undefined, ({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload), ); }; } const ConnectedCalendar: React.ComponentType = React.memo( function ConnectedCalendar(props) { const year = useSelector(yearAssertingSelector); const month = useSelector(monthAssertingSelector); const daysToEntries = useSelector(currentDaysToEntries); const navInfo = useSelector(state => state.navInfo); const currentCalendarQuery = useSelector(webCalendarQuery); const loggedIn = useSelector(isLoggedIn); const callUpdateCalendarQuery = useUpdateCalendarQuery(); const dispatchActionPromise = useDispatchActionPromise(); return ( ); }, ); export default ConnectedCalendar; diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js index 3aa370a24..dd24c6b6e 100644 --- a/web/chat/chat-input-bar.react.js +++ b/web/chat/chat-input-bar.react.js @@ -1,680 +1,680 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import * as React from 'react'; import { joinThreadActionTypes, newThreadActionTypes, useJoinThread, } from 'lib/actions/thread-actions.js'; -import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; +import SWMansionIcon from 'lib/components/swmansion-icon.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 { getTypeaheadRegexMatches, type MentionTypeaheadSuggestionItem, type TypeaheadMatchedStrings, useMentionTypeaheadChatSuggestions, useMentionTypeaheadUserSuggestions, useUserMentionsCandidates, } from 'lib/shared/mention-utils.js'; import { localIDPrefix, trimMessage } from 'lib/shared/message-utils.js'; import { checkIfDefaultMembersAreVoiced, threadActualMembers, threadFrozenDueToViewerBlock, threadHasPermission, viewerIsMember, } 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { 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/redux-promise-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 { getMentionTypeaheadTooltipActions, getMentionTypeaheadTooltipButtons, webMentionTypeaheadRegex, } from '../utils/typeahead-utils.js'; type BaseProps = { +threadInfo: ThreadInfo, +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, }; 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 = (