diff --git a/landing/competitor-data.js b/landing/competitor-data.js index ba3088258..f4f919d80 100644 --- a/landing/competitor-data.js +++ b/landing/competitor-data.js @@ -1,619 +1,619 @@ // @flow const competitors = Object.freeze({ GENERAL: 'general', DISCORD: 'discord', KEYBASE: 'keybase', MATRIX: 'matrix', SIGNAL: 'signal', SLACK: 'slack', TELEGRAM: 'telegram', }); export type Competitors = $Values; export type FeatureComparison = { +title: string, +comingSoon: boolean, +competitorDescriptionShort: string, +commDescriptionShort: string, +competitorDescriptionLong: $ReadOnlyArray, +commDescriptionLong: $ReadOnlyArray, +furtherReadingLinks?: $ReadOnlyArray, }; export type Competitor = { +id: Competitors, +name: string, +featureComparison: $ReadOnlyArray, }; const competitorData: { [key: string]: Competitor } = Object.freeze({ general: { id: 'general', name: 'General', featureComparison: [ { title: 'Tree structure', comingSoon: false, competitorDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', commDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', competitorDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], commDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], }, { - title: 'Background tab', + title: 'Muted tab', comingSoon: false, competitorDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', commDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', competitorDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], commDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], }, { title: 'Integrated calendar', comingSoon: false, competitorDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', commDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', competitorDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], commDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], }, { title: 'Notif controls', comingSoon: false, competitorDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', commDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', competitorDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], commDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], }, { title: 'Unique threading model', comingSoon: false, competitorDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', commDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', competitorDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], commDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], }, { title: 'Inbox zero workflow', comingSoon: false, competitorDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', commDescriptionShort: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin.', competitorDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], commDescriptionLong: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vulputate vestibulum leo, vel sollicitudin est faucibus eu. Aliquam a nisi id mauris aliquet viverra. Vivamus blandit iaculis libero, vitae hendrerit mi posuere sodales.', ], }, ], }, discord: { id: 'discord', name: 'Discord', featureComparison: [ { title: 'Encryption', comingSoon: true, competitorDescriptionShort: 'Discord staff is able to read the contents of all messages sent on the app.', commDescriptionShort: 'Comm uses Signal’s Double Ratchet algorithm, the industry standard for E2E encryption.', competitorDescriptionLong: [ 'Discord staff is able to read the contents of all messages sent on the app. Their product team has indicated that they aren’t focused on privacy or encryption features.', ], commDescriptionLong: [ 'Comm’s servers do not have access to plaintext user content. DMs are stored on individual user devices, and communities are hosted on individual users’ keyservers.', 'Comm uses Matrix’s implementation of Signal’s Double Ratchet algorithm for DMs. Communication between keyservers and user devices is secured via TLS.', ], furtherReadingLinks: [ 'https://signal.org/docs/specifications/doubleratchet/', ], }, { title: 'Inbox', comingSoon: false, competitorDescriptionShort: 'Discord’s inbox is basically a simple notification queue tucked away in a corner.', commDescriptionShort: 'Comm’s inbox is the first thing you see when you open the app. It helps separate signal from noise.', competitorDescriptionLong: [ 'Discord’s inbox is basically a simple notification queue tucked away in a corner. It merges all tags (including @all tags) into a single interface. Since any member of any channel in a server is able to tag you, normally this interface is relatively low-signal.', ], commDescriptionLong: [ 'Comm’s inbox is the first thing you see when you open the app. It resembles a typical messaging app, with all of your chats appearing ordered by the most recent message.', 'Threads appear underneath their parent chat, and when a thread is bumped it bumps the whole parent chat to the top.', ], }, { title: 'Communities', comingSoon: false, competitorDescriptionShort: 'Discord supports communities with a flat list of channels.', commDescriptionShort: 'Comm allows you to nest channels inside other channels.', competitorDescriptionLong: [ 'Discord is built to support communities, also known as “servers” on the platform. Each server has a flat list of channels.', ], commDescriptionLong: [ 'Comm’s implementation of communities looks a lot like Discord’s. The core difference is that Comm supports a full tree structure of channels for each community.', 'Comm also supports a threads feature, similar to Discord’s. Comm threads appear in your inbox underneath their parent channel.', ], }, { title: 'Notifications', comingSoon: false, competitorDescriptionShort: 'Discord has a single function to mute notifs from a chat.', commDescriptionShort: 'Comm allows you to manage notif alerts separately from notif badging.', competitorDescriptionLong: [ 'Discord has a single function to mute notifs from a chat. You can mute notifs temporarily or permanently.', ], commDescriptionLong: [ - 'Comm allows you to manage notif alerts separately from notif badging (unread icon). Comm also sorts muted chats in a separate “Background” tab in order to avoid cluttering your inbox.', + 'Comm allows you to manage notif alerts separately from notif badging (unread icon). Comm also sorts muted chats in a separate “Muted” tab in order to avoid cluttering your inbox.', ], }, { title: 'Badging', comingSoon: false, competitorDescriptionShort: 'Discord’s unread count is based on the number of unread messages.', commDescriptionShort: 'Comm’s unread count is based on the number of unread chats.', competitorDescriptionLong: [ 'Discord’s unread count is based on the number of unread messages. If somebody sends 3 messages in a row to the same chat, your unread count will be incremented by 3.', ], commDescriptionLong: [ 'Comm’s unread count is based on the number of unread chats. If somebody sends 3 messages in a row to the same chat, your unread count will be incremented by 1.', ], }, ], }, keybase: { id: 'keybase', name: 'Keybase', featureComparison: [ { title: 'Active development', comingSoon: false, competitorDescriptionShort: 'Following its acquisition by Zoom, Keybase is no longer in active development.', commDescriptionShort: 'Comm is actively in development.', competitorDescriptionLong: [ 'Following its acquisition by Zoom, Keybase is no longer in active development.', ], commDescriptionLong: ['Comm is actively in development.'], }, { title: 'Encryption', comingSoon: true, competitorDescriptionShort: 'Keybase has a custom implementation of E2E encryption that doesn’t guarantee forward secrecy.', commDescriptionShort: 'Comm uses the Double Ratchet algorithm. Pioneered by Signal, Double Ratchet is the industry standard for E2E encryption.', competitorDescriptionLong: [ 'Keybase’s implementation of E2E encryption is unique in the industry. Whereas most E2E-encrypted messaging apps use something like Signal’s Double Ratchet to preserve forward secrecy, Keybase took a different approach because they wanted to allow new chat members to see the full history of the chat prior to when they joined.', ], commDescriptionLong: [ 'Comm uses Matrix.org’s implementation of Double Ratchet, and as such is able to preserve forward secrecy. We allow new chat members to see full chat history by querying peers’ keyservers.', ], }, { title: 'Search', comingSoon: false, competitorDescriptionShort: 'Keybase searches chats locally by downloading the full history to your client device.', commDescriptionShort: 'Comm utilizes user-hosted keyservers to handle search on the server side.', competitorDescriptionLong: [ 'Keybase, like most other E2E-encrypted apps, is only able to execute search queries on your local device. As such, in order to exhaustively execute a search query for a chat, Keybase must download that chat’s full history to the client device.', ], commDescriptionLong: [ 'Though much research has been done on searchable encryption over the past 20 years, it remains an unsolved problem. Comm is able to circumvent the problem by sending queries to user-hosted keyservers, which are able to access plaintext data.', ], }, { title: 'Key resets', comingSoon: false, competitorDescriptionShort: 'Keybase’s servers can reset anybody’s public keys in order to facilitate account recovery.', commDescriptionShort: 'Comm backs up user keys, and facilitates account recovery by recovering those original keys.', competitorDescriptionLong: [ 'Keybase’s servers have the ability to reset any account’s public keys at any time. This functionality is used to facilitate account recovery.', ], commDescriptionLong: [ 'Comm backs up user keys, and facilitates account recovery by recovering those original keys. Note that if the user forgets the secret securing their backup, they will not be able to recover their account.', ], furtherReadingLinks: [ 'https://twitter.com/CommDotApp/status/1545193952554336257', ], }, { title: 'Notifications', comingSoon: false, competitorDescriptionShort: 'Keybase has a single function to mute notifs from a chat.', commDescriptionShort: 'Comm allows you to manage notif alerts separately from notif badging.', competitorDescriptionLong: [ 'Keybase has a single function to mute notifs from a chat.', ], commDescriptionLong: [ - 'Comm allows you to manage notif alerts separately from notif badging (unread icon). Comm also sorts muted chats in a separate “Background” tab in order to avoid cluttering your inbox.', + 'Comm allows you to manage notif alerts separately from notif badging (unread icon). Comm also sorts muted chats in a separate “Muted” tab in order to avoid cluttering your inbox.', ], }, ], }, matrix: { id: 'matrix', name: 'Matrix', featureComparison: [ { title: 'Encryption', comingSoon: true, competitorDescriptionShort: 'E2E encryption is optional in Matrix. Two-person encrypted chats use Double Ratchet, but group chats use a less secure algorithm called Megolm.', commDescriptionShort: 'Comm uses Matrix’s implementation of Signal’s Double Ratchet algorithm for both two-person and group chats.', competitorDescriptionLong: [ 'E2E encryption is optional in Matrix. Two-person encrypted chats use Double Ratchet, but group chats use a less secure algorithm called Megolm.', 'Megolm is a “group ratchet” which trades off security/privacy guarantees to enable scale and performance. Many E2E-encrypted apps (such as WhatsApp) use “group ratchets” for group chats.', ], commDescriptionLong: [ 'Comm uses Matrix’s implementation of Signal’s Double Ratchet algorithm for both two-person and group chats. However, we don’t use Megolm due to concerns over privacy implications.', 'Instead of using a group ratchet, Comm arranges group chats in a “pairwise” way. When a user sends a message to a group chat, Comm handles that by sending that message individually to all users in the group chat.', 'The downside is that group chats don’t scale as well. Comm’s solution for large group chats is keyserver-hosted communities, which avoid sacrificing security/privacy guarantees by leveraging keyservers to federate the classic client/server paradigm.', ], furtherReadingLinks: ['https://nebuchadnezzar-megolm.github.io/'], }, { title: 'Search', comingSoon: false, competitorDescriptionShort: 'Matrix searches encrypted chats locally by downloading the full history to your client device.', commDescriptionShort: 'Comm utilizes user-hosted keyservers to handle search on the server side.', competitorDescriptionLong: [ 'While Matrix itself doesn’t handle searches of encrypted chats, the most popular Matrix client Element has support for this.', 'Element searches encrypted chats locally by downloading the full history to your client device. This isn’t supported in the web app, however.', ], commDescriptionLong: [ 'For keyserver-hosted chats, Comm utilizes the keyserver to handle search on the server side.', 'For users without keyservers, DMs are stored locally. In this case Comm handles search locally, and mirrors the full history of DMs across all of a user’s devices. Unlike Element, Comm’s search works on our web app.', ], }, { title: 'Key resets', comingSoon: false, competitorDescriptionShort: 'Matrix homeservers can change a user account’s associated public keys in order to facilitate account recovery.', commDescriptionShort: 'Comm backs up user keys, and facilitates account recovery by recovering those original keys.', competitorDescriptionLong: [ 'Matrix homeservers are responsible for owning a user’s identity. As such, those homeservers have the ability to reset an account, as well as the public keys used for E2E encryption. This functionality is used to facilitate account recovery.', ], commDescriptionLong: [ 'Comm backs up user keys, and facilitates account recovery by recovering those original keys. Note that if the user forgets the secret securing their backup, they will not be able to recover their account.', ], furtherReadingLinks: [ 'https://twitter.com/CommDotApp/status/1539397765536444416', 'https://twitter.com/CommDotApp/status/1545193952554336257', ], }, { title: 'Backup', comingSoon: true, competitorDescriptionShort: 'Matrix relies on individual implementations and homeservers to handle backup.', commDescriptionShort: 'Comm backs up all of your user data, encrypted with either a password or an Ethereum wallet.', competitorDescriptionLong: [ 'Matrix relies on homeservers to handle backup of unencrypted chats. Backup of encrypted chats is handled differently depending on which implementation of Matrix you’re using.', 'The most popular implementation of Matrix is Element. Element backs up a group chat’s messages just once for the group, encrypted with the chat keys. Meanwhile, a given user’s chat keys are backed up separately, and encrypted with the user’s Security Phrase/Key.', ], commDescriptionLong: [ 'Comm backs up all of your user data via our backup service. The backup is encrypted so that Comm is not able to access the data.', 'Since Comm doesn’t use a group ratchet due to privacy concerns (see “Encryption” section), group chat backups are not shared between users.', ], }, ], }, signal: { id: 'signal', name: 'Signal', featureComparison: [ { title: 'Backup', comingSoon: true, competitorDescriptionShort: 'Signal does not back up your data. Data is stored locally on your device.', commDescriptionShort: 'Comm backs up all of your encrypted user data via our backup service.', competitorDescriptionLong: [ 'With the exception of some group membership information, Signal does not back up your data. The only way to transfer data from an old phone to a new phone is via P2P transfer, which is not always possible.', ], commDescriptionLong: [ 'Comm backs up all of your user data via our backup service. The backup is encrypted so that Comm is not able to access the data.', ], furtherReadingLinks: [ 'https://signal.org/blog/signal-private-group-system/', ], }, { title: 'Communities', comingSoon: false, competitorDescriptionShort: 'Signal does not support communities with channels à la Discord or Slack.', commDescriptionShort: 'Comm supports communities with features including channels, roles, threads, and more.', competitorDescriptionLong: [ 'While Signal supports group chats, it does not support communities with channels à la Discord or Slack. There are no user-owned backends on Signal, which limits product functionality and scale.', ], commDescriptionLong: [ 'Comm leverages keyservers to support sophisticated community functionality, including channels, roles, threads, and much more.', ], }, { title: 'Identity', comingSoon: false, competitorDescriptionShort: 'Your identity on Signal is linked to a phone number.', commDescriptionShort: 'Comm accounts are associated with a username or an Ethereum wallet.', competitorDescriptionLong: [ 'Your identity on Signal is linked to a phone number, which limits the anonymity and sovereignty of user accounts.', ], commDescriptionLong: [ 'Comm accounts can be linked either to a pseudonymous username or to an Ethereum wallet.', ], }, { title: 'Key resets', comingSoon: false, competitorDescriptionShort: 'Signal’s servers can reset anybody’s public keys in order to facilitate account recovery.', commDescriptionShort: 'Comm backs up user keys, and facilitates account recovery by recovering those original keys.', competitorDescriptionLong: [ 'Signal’s servers have the ability to reset any phone number’s public keys at any time. This functionality is used to facilitate account recovery. Signal relies on users manually verifying “Safety Numbers” in order to verify that a public key reset is valid.', ], commDescriptionLong: [ 'Comm backs up user keys, and facilitates account recovery by recovering those original keys. Note that if the user forgets the secret securing their backup, they will not be able to recover their account.', ], furtherReadingLinks: [ 'https://twitter.com/CommDotApp/status/1545193952554336257', ], }, { title: 'Notifications', comingSoon: false, competitorDescriptionShort: 'Signal has a single function to mute notifs from a chat.', commDescriptionShort: 'Comm allows you to manage notif alerts separately from notif badging.', competitorDescriptionLong: [ 'Signal has a single function to mute notifs from a chat.', ], commDescriptionLong: [ - 'Comm allows you to manage notif alerts separately from notif badging (unread icon). Comm also sorts muted chats in a separate “Background” tab in order to avoid cluttering your inbox.', + 'Comm allows you to manage notif alerts separately from notif badging (unread icon). Comm also sorts muted chats in a separate “Muted” tab in order to avoid cluttering your inbox.', ], }, ], }, slack: { id: 'slack', name: 'Slack', featureComparison: [ { title: 'Encryption', comingSoon: true, competitorDescriptionShort: 'Slack staff is able to read the contents of all messages sent on the app.', commDescriptionShort: 'Comm uses Signal’s Double Ratchet algorithm, the industry standard for E2E encryption.', competitorDescriptionLong: [ 'Slack staff is able to read the contents of all messages sent on the app. Their product team hasn’t indicated any interest in privacy or encryption features.', ], commDescriptionLong: [ 'Comm’s servers do not have access to plaintext user content. DMs are stored on individual user devices, and communities are hosted on individual users’ keyservers.', 'Comm uses Matrix’s implementation of Signal’s Double Ratchet algorithm for DMs. Communication between keyservers and user devices is secured via TLS.', ], furtherReadingLinks: [ 'https://signal.org/docs/specifications/doubleratchet/', ], }, { title: 'Inbox', comingSoon: false, competitorDescriptionShort: 'Slack separates notifs into individual channels, threads, and DMs within individual communities.', commDescriptionShort: 'Comm has a unified inbox that shows all unread chats across all communities.', competitorDescriptionLong: [ 'To sort through notifs from different communities in Slack, you have to navigate into each individual community, and then use the sidebar to navigate into each individual channel, DM, or thread. While there is a feature to sort unread chats first, it’s easy to miss.', ], commDescriptionLong: [ 'Comm’s inbox is the first thing you see when you open the app. It resembles a typical messaging app, with all of your chats appearing ordered by the most recent message.', 'Threads appear underneath their parent chat, and when a thread is bumped it bumps the whole parent chat to the top.', ], }, { title: 'Communities', comingSoon: false, competitorDescriptionShort: 'Slack supports communities with a flat list of channels.', commDescriptionShort: 'Comm allows you to nest channels inside other channels.', competitorDescriptionLong: [ 'Slack is built to support communities, also known as “slacks” on the platform. Each “slack” has a flat list of channels.', ], commDescriptionLong: [ 'Comm’s implementation of communities looks a lot like Slack’s. The core difference is that Comm supports a full tree structure of channels for each community.', 'Comm also supports a threads feature, similar to Slack’s. Comm threads appear in your inbox underneath their parent channel.', ], }, { title: 'Notifications', comingSoon: false, competitorDescriptionShort: 'Slack has a single function to mute notifs from a chat.', commDescriptionShort: 'Comm allows you to manage notif alerts separately from notif badging.', competitorDescriptionLong: [ 'Slack has several features for managing notifs across a whole community. However, when it comes to managing notifs from a specific chat, Slack is more limited. There’s only one option, which is to completely mute a chat.', ], commDescriptionLong: [ - 'Comm allows you to manage notif alerts separately from notif badging (unread icon). Comm also sorts muted chats in a separate “Background” tab in order to avoid cluttering your inbox.', + 'Comm allows you to manage notif alerts separately from notif badging (unread icon). Comm also sorts muted chats in a separate “Muted” tab in order to avoid cluttering your inbox.', ], }, { title: 'Badging', comingSoon: false, competitorDescriptionShort: 'Slack’s unread count is based on the number of unread messages.', commDescriptionShort: 'Comm’s unread count is based on the number of unread chats.', competitorDescriptionLong: [ 'Slack’s unread count is based on the number of unread messages. If somebody sends 3 messages in a row to the same chat, your unread count will be incremented by 3.', ], commDescriptionLong: [ 'Comm’s unread count is based on the number of unread chats. If somebody sends 3 messages in a row to the same chat, your unread count will be incremented by 1.', ], }, { title: 'Unified account', comingSoon: false, competitorDescriptionShort: 'Your identity on Slack is specific to a community.', commDescriptionShort: 'Your identity on Comm is universal, and can be shared across communities.', competitorDescriptionLong: [ 'Each community on Slack is basically its own walled garden. Each user has a distinct identity for each community, which means a distinct set of DMs and a distinct profile.', ], commDescriptionLong: [ 'Your identity on Comm is universal, which means you can share the same profile and username across all the communities you’re a part of. Ethereum users can use their ENS name and avatar on Comm.', 'DMs exist outside the bounds of any particular community, which means you don’t have to maintain multiple conversations with the same user.', ], }, ], }, telegram: { id: 'telegram', name: 'Telegram', featureComparison: [ { title: 'Encryption', comingSoon: true, competitorDescriptionShort: 'Outside of rarely-used “Secret Chats”, Telegram staff is able to read the contents of all messages sent on the app.', commDescriptionShort: 'Comm uses Signal’s Double Ratchet algorithm, the industry standard for E2E encryption.', competitorDescriptionLong: [ 'Despite being presented as a privacy-focused messaging app, Telegram does not offer E2E encryption as a default. While there is a “Secret Chats” feature, it only works for 1-on-1 chats, and those chats only appear on a single primary device.', ], commDescriptionLong: [ 'Comm’s servers do not have access to plaintext user content. DMs are stored on individual user devices, and communities are hosted on individual users’ keyservers.', 'Comm uses Matrix’s implementation of Signal’s Double Ratchet algorithm for DMs. Communication between keyservers and user devices is secured via TLS.', ], furtherReadingLinks: [ 'https://signal.org/docs/specifications/doubleratchet/', 'https://telegra.ph/Why-Isnt-Telegram-End-to-End-Encrypted-by-Default-08-14', ], }, { title: 'Communities', comingSoon: false, competitorDescriptionShort: 'Telegram has a Topics feature for group chats, but communities aren’t a top-level concept.', commDescriptionShort: 'Comm supports communities as a core primitive, à la Slack or Discord.', competitorDescriptionLong: [ 'Telegram has a Topics feature for group chats. It allows group chats with more than 200 members to subdivide into multiple “Topics”. These topics have one level of depth, and it can be annoying to browse topics or to associate topics with a parent chat.', ], commDescriptionLong: [ 'Comm’s implementation of communities looks a lot like IRC, Slack, or Discord. The core difference is that Comm supports a full tree structure of channels for each community.', 'Comm also supports a threads feature, also similar to Slack and Discord. Comm threads appear in your inbox underneath their parent channel.', ], furtherReadingLinks: [ 'https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups', ], }, { title: 'Notifications', comingSoon: false, competitorDescriptionShort: 'Telegram has a single function to mute notifs from a chat.', commDescriptionShort: 'Comm allows you to manage notif alerts separately from notif badging.', competitorDescriptionLong: [ 'Telegram supports temporary muting notifs from a chat, disabling message previews, and changing the notification sound associated with a chat.', ], commDescriptionLong: [ - 'Comm allows you to manage notif alerts separately from notif badging (unread icon). Comm also sorts muted chats in a separate “Background” tab in order to avoid cluttering your inbox.', + 'Comm allows you to manage notif alerts separately from notif badging (unread icon). Comm also sorts muted chats in a separate “Muted” tab in order to avoid cluttering your inbox.', ], }, { title: 'Noisy chats', comingSoon: false, competitorDescriptionShort: 'Telegram has a Chat Folders feature, but it’s not easy to move a noisy chat out of your inbox.', commDescriptionShort: - 'When you disable notifs for a chat, Comm moves it out of your inbox into a separate Background tab.', + 'When you disable notifs for a chat, Comm moves it out of your inbox into a separate Muted tab.', competitorDescriptionLong: [ 'Telegram has a Chat Folders feature. Your primary inbox always shows all chats, but Chat Folders can be configured to show or hide a set of selected chats. If you want to separate all of your chats into two Chat Folders, it takes a lot of steps.', ], commDescriptionLong: [ - 'When you disable notifs for a chat, Comm moves it out of your inbox into a separate Background tab. The Background tab is a core primitive in Comm, and helps you separate signal from noise.', + 'When you disable notifs for a chat, Comm moves it out of your inbox into a separate Muted tab. The Muted tab is a core primitive in Comm, and helps you separate signal from noise.', ], }, { title: 'Badging', comingSoon: false, competitorDescriptionShort: 'Telegram’s unread count is based on the number of unread messages.', commDescriptionShort: 'Comm’s unread count is based on the number of unread chats.', competitorDescriptionLong: [ 'Telegram’s unread count is based on the number of unread messages. If somebody sends 3 messages in a row to the same chat, your unread count will be incremented by 3.', ], commDescriptionLong: [ 'Comm’s unread count is based on the number of unread chats. If somebody sends 3 messages in a row to the same chat, your unread count will be incremented by 1.', ], }, ], }, }); export { competitors, competitorData }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 9b9d0aa36..8b1363eb6 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1795 +1,1795 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _keyBy from 'lodash/fp/keyBy.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omit from 'lodash/fp/omit.js'; import _omitBy from 'lodash/fp/omitBy.js'; import * as React from 'react'; import { getUserAvatarForThread } from './avatar-utils.js'; import { generatePendingThreadColor } from './color-utils.js'; import { extractUserMentionsFromText } from './mention-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { hasPermission, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, } from '../permissions/minimally-encoded-thread-permissions.js'; import { specialRoles } from '../permissions/special-roles.js'; import type { SpecialRole } from '../permissions/special-roles.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; import type { ChatThreadItem } from '../selectors/chat-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, threadInfosSelectorForThreadType, onScreenThreadInfos, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import type { RelativeMemberInfo, RawThreadInfo, MemberInfoWithPermissions, RoleInfo, ThreadInfo, MinimallyEncodedThickMemberInfo, ThinRawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { decodeMinimallyEncodedRoleInfo, minimallyEncodeMemberInfo, minimallyEncodeRawThreadInfo, minimallyEncodeRoleInfo, minimallyEncodeThreadCurrentUserInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { defaultThreadSubscription } from '../types/subscription-types.js'; import { threadPermissionPropagationPrefixes, threadPermissions, configurableCommunityPermissions, type ThreadPermission, type ThreadPermissionsInfo, type ThreadRolePermissionsBlob, type UserSurfacedPermission, threadPermissionFilterPrefixes, threadPermissionsDisabledByBlock, type ThreadPermissionNotAffectedByBlock, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, threadTypeIsThick, assertThinThreadType, assertThickThreadType, } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo, ClientLegacyRoleInfo, ServerThreadInfo, ThickMemberInfo, UserProfileThreadInfo, MixedRawThreadInfos, LegacyMemberInfo, LegacyThinRawThreadInfo, } 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 { ET, type ThreadEntity, type UserEntity, } from '../utils/entity-text.js'; import { entries, values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; import { usingOlmViaTunnelbrokerForDMs } from '../utils/services-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { pendingThreadIDRegex } from '../utils/validation-utils.js'; function threadHasPermission( threadInfo: ?(ThreadInfo | LegacyRawThreadInfo | RawThreadInfo), permission: ThreadPermissionNotAffectedByBlock, ): 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); } type CommunityRootMembersToRoleType = { +[threadID: ?string]: { +[memberID: string]: ?RoleInfo, }, }; function useCommunityRootMembersToRole( threadInfos: $ReadOnlyArray, ): CommunityRootMembersToRoleType { const communityRootMembersToRole = React.useMemo(() => { const communityThreadInfos = threadInfos.filter(threadInfo => threadTypeIsCommunityRoot(threadInfo.type), ); if (communityThreadInfos.length === 0) { return {}; } const communityRoots = _keyBy('id')(communityThreadInfos); return _mapValues((threadInfo: ThreadInfo) => { const keyedMembers = _keyBy('id')(threadInfo.members); const keyedMembersToRole = _mapValues( (member: MemberInfoWithPermissions | RelativeMemberInfo) => { return member.role ? threadInfo.roles[member.role] : null; }, )(keyedMembers); return keyedMembersToRole; })(communityRoots); }, [threadInfos]); return communityRootMembersToRole; } function useThreadsWithPermission( threadInfos: $ReadOnlyArray, permission: ThreadPermission, ): $ReadOnlyArray { const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); const allThreadInfos = useSelector(state => state.threadStore.threadInfos); const allThreadInfosArray = React.useMemo( () => values(allThreadInfos), [allThreadInfos], ); const communityRootMembersToRole = useCommunityRootMembersToRole(allThreadInfosArray); return React.useMemo(() => { return threadInfos.filter((threadInfo: ThreadInfo) => { const membersToRole = communityRootMembersToRole[threadInfo.id]; const memberHasAdminRole = threadMembersWithoutAddedAdmin( threadInfo, ).some(member => roleIsAdminRole(membersToRole?.[member.id])); if (memberHasAdminRole || !loggedInUserInfo) { return hasPermission(threadInfo.currentUser.permissions, permission); } const threadFrozen = threadIsWithBlockedUserOnlyWithoutAdminRoleCheck( threadInfo, loggedInUserInfo.id, userInfos, false, ); const permissions = threadFrozen ? filterOutDisabledPermissions(threadInfo.currentUser.permissions) : threadInfo.currentUser.permissions; return hasPermission(permissions, permission); }); }, [ threadInfos, communityRootMembersToRole, loggedInUserInfo, userInfos, permission, ]); } function useThreadHasPermission( threadInfo: ?ThreadInfo, permission: ThreadPermission, ): boolean { const threads = useThreadsWithPermission( threadInfo ? [threadInfo] : [], permission, ); return threads.length === 1; } function viewerIsMember( threadInfo: ?(ThreadInfo | LegacyRawThreadInfo | RawThreadInfo), ): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function isMemberActive( memberInfo: MemberInfoWithPermissions | MinimallyEncodedThickMemberInfo, ): boolean { const role = memberInfo.role; return role !== null && role !== undefined; } function threadIsInHome(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList( threadInfo: ?(LegacyRawThreadInfo | RawThreadInfo | ThreadInfo), ): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function useIsThreadInChatList(threadInfo: ?ThreadInfo): boolean { const threadIsVisible = useThreadHasPermission( threadInfo, threadPermissions.VISIBLE, ); return viewerIsMember(threadInfo) && threadIsVisible; } function useThreadsInChatList( threadInfos: $ReadOnlyArray, ): $ReadOnlyArray { const visibleThreads = useThreadsWithPermission( threadInfos, threadPermissions.VISIBLE, ); return React.useMemo( () => visibleThreads.filter(viewerIsMember), [visibleThreads], ); } function threadIsTopLevel(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } function threadIsChannel(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } function threadIsSidebar(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return threadInfo?.type === threadTypes.SIDEBAR; } function threadInBackgroundChatList( threadInfo: ?(RawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(RawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(LegacyRawThreadInfo | RawThreadInfo | ThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(RawThreadInfo | ThreadInfo), 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); } type MemberIDAndRole = { +id: string, +role: ?string, ... }; function threadOtherMembers( memberInfos: $ReadOnlyArray, viewerID: ?string, ): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } function threadMembersWithoutAddedAdmin< T: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, >(threadInfo: T): $PropertyType { if (threadInfo.community !== genesis().id) { return threadInfo.members; } const adminID = extractKeyserverIDFromID(threadInfo.id); return threadInfo.members.filter( member => member.id !== adminID || member.role, ); } function threadIsGroupChat(threadInfo: ThreadInfo): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ) { return threadMembersWithoutAddedAdmin(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: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, 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, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs): ThreadInfo { 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: ThreadRolePermissionsBlob = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role: RoleInfo = { ...minimallyEncodeRoleInfo({ id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }), specialRole: specialRoles.DEFAULT_ROLE, }; let rawThreadInfo: RawThreadInfo; if (threadTypeIsThick(threadType)) { const thickThreadType = assertThickThreadType(threadType); rawThreadInfo = { minimallyEncoded: true, thick: true, id: threadID, type: thickThreadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID( parentThreadInfo, thickThreadType, ), community: getCommunity(parentThreadInfo), members: members.map(member => minimallyEncodeMemberInfo({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, subscription: defaultThreadSubscription, }), ), roles: { [role.id]: role, }, currentUser: minimallyEncodeThreadCurrentUserInfo({ role: role.id, permissions: membershipPermissions, subscription: defaultThreadSubscription, unread: false, }), repliesCount: 0, sourceMessageID, pinnedCount: 0, }; } else { const thinThreadType = assertThinThreadType(threadType); rawThreadInfo = { minimallyEncoded: true, id: threadID, type: thinThreadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID( parentThreadInfo, thinThreadType, ), community: getCommunity(parentThreadInfo), members: members.map(member => minimallyEncodeMemberInfo({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, }), ), roles: { [role.id]: role, }, currentUser: minimallyEncodeThreadCurrentUserInfo({ role: role.id, permissions: membershipPermissions, subscription: defaultThreadSubscription, 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, +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.GENESIS_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, ): 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, parentThreadInfo: ThreadInfo, ): AccountUserInfo[] { const mentionedMembersOfParent = extractMentionedMembers( messageText, parentThreadInfo, ); for (const member of threadInfo.members) { if (member.role) { mentionedMembersOfParent.delete(member.id); } } return [...mentionedMembersOfParent.values()]; } function pendingThreadType( numberOfOtherMembers: number, ): 4 | 6 | 7 | 13 | 14 | 15 { if (usingOlmViaTunnelbrokerForDMs) { if (numberOfOtherMembers === 0) { return threadTypes.PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.PERSONAL; } else { return threadTypes.LOCAL; } } else { if (numberOfOtherMembers === 0) { return threadTypes.GENESIS_PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.GENESIS_PERSONAL; } else { return threadTypes.COMMUNITY_SECRET_SUBTHREAD; } } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( threadType === threadTypes.GENESIS_PERSONAL || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.SIDEBAR || threadType === threadTypes.GENESIS_PRIVATE || threadType === threadTypes.PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.THICK_SIDEBAR || threadType === threadTypes.PRIVATE ); } type RawThreadInfoOptions = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, +filterVoicedInAnnouncementChannelsPermission?: boolean, +minimallyEncodePermissions?: boolean, +includeSpecialRoleFieldInRoles?: boolean, +allowAddingUsersToCommunityRoot?: boolean, +filterManageFarcasterChannelTagsPermission?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?LegacyThinRawThreadInfo | ?ThinRawThreadInfo { const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterManageInviteLinksPermission = options?.filterManageInviteLinksPermission; const filterVoicedInAnnouncementChannelsPermission = options?.filterVoicedInAnnouncementChannelsPermission; const shouldMinimallyEncodePermissions = options?.minimallyEncodePermissions; const shouldIncludeSpecialRoleFieldInRoles = options?.includeSpecialRoleFieldInRoles; const allowAddingUsersToCommunityRoot = options?.allowAddingUsersToCommunityRoot; const filterManageFarcasterChannelTagsPermission = options?.filterManageFarcasterChannelTagsPermission; const filterThreadPermissions = ( innerThreadPermissions: ThreadPermissionsInfo, ) => { if ( allowAddingUsersToCommunityRoot && (serverThreadInfo.type === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || serverThreadInfo.type === threadTypes.COMMUNITY_ROOT) ) { innerThreadPermissions = { ...innerThreadPermissions, [threadPermissions.ADD_MEMBERS]: { value: true, source: serverThreadInfo.id, }, }; } return _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)) || (filterManageFarcasterChannelTagsPermission && [threadPermissions.MANAGE_FARCASTER_CHANNEL_TAGS].includes(k)), )(innerThreadPermissions); }; 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: defaultThreadSubscription, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rolesWithFilteredThreadPermissions = _mapValues(role => ({ ...role, permissions: filterThreadPermissions(role.permissions), }))(serverThreadInfo.roles); const rolesWithoutSpecialRoleField = _mapValues(role => { const { specialRole, ...roleSansSpecialRole } = role; return roleSansSpecialRole; })(rolesWithFilteredThreadPermissions); 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: rolesWithoutSpecialRoleField, 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) { rawThreadInfo = { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } if (!shouldMinimallyEncodePermissions) { return rawThreadInfo; } const minimallyEncoded = minimallyEncodeRawThreadInfo(rawThreadInfo); invariant(!minimallyEncoded.thick, 'ServerThreadInfo should be thin thread'); if (shouldIncludeSpecialRoleFieldInRoles) { return minimallyEncoded; } const minimallyEncodedRolesWithoutSpecialRoleField = Object.fromEntries( entries(minimallyEncoded.roles).map(([key, role]) => [ key, { ..._omit('specialRole')(role), isDefault: roleIsDefaultRole(role), }, ]), ); return { ...minimallyEncoded, roles: minimallyEncodedRolesWithoutSpecialRoleField, }; } function threadUIName(threadInfo: ThreadInfo): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } const threadMembers: $ReadOnlyArray = threadInfo.members.filter(memberInfo => memberInfo.role); const memberEntities: $ReadOnlyArray = threadMembers.map(member => ET.user({ userInfo: member }), ); return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: memberEntities, ifJustViewer: threadInfo.type === threadTypes.GENESIS_PRIVATE ? 'viewer_username' : 'just_you_string', }; } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { let threadInfo: ThreadInfo = { minimallyEncoded: true, 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: rawThreadInfo.currentUser, 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.GENESIS_PERSONAL || rawThreadInfo.type === threadTypes.GENESIS_PRIVATE ) { threadInfo = { ...threadInfo, avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } return threadInfo; } function filterOutDisabledPermissions(permissionsBitmask: string): string { const decodedPermissions: ThreadPermissionsInfo = threadPermissionsFromBitmaskHex(permissionsBitmask); const updatedPermissions = { ...decodedPermissions, ...disabledPermissions }; const encodedUpdatedPermissions: string = permissionsToBitmaskHex(updatedPermissions); return encodedUpdatedPermissions; } function baseThreadIsWithBlockedUserOnly( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock: boolean, ) { 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 threadIsWithBlockedUserOnly( threadInfo: LegacyRawThreadInfo | RawThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } return baseThreadIsWithBlockedUserOnly( threadInfo, viewerID, userInfos, checkOnlyViewerBlock, ); } function threadIsWithBlockedUserOnlyWithoutAdminRoleCheck( threadInfo: ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock: boolean, ): boolean { if (threadOrParentThreadIsGroupChat(threadInfo)) { return false; } return baseThreadIsWithBlockedUserOnly( threadInfo, viewerID, userInfos, checkOnlyViewerBlock, ); } function threadFrozenDueToBlock( threadInfo: LegacyRawThreadInfo | RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, false); } function useThreadFrozenDueToViewerBlock( threadInfo: ThreadInfo, communityThreadInfo: ?ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { const communityThreadInfoArray = React.useMemo( () => (communityThreadInfo ? [communityThreadInfo] : []), [communityThreadInfo], ); const communityRootsMembersToRole = useCommunityRootMembersToRole( communityThreadInfoArray, ); const memberToRole = communityRootsMembersToRole[communityThreadInfo?.id]; const memberHasAdminRole = threadMembersWithoutAddedAdmin(threadInfo).some( m => roleIsAdminRole(memberToRole?.[m.id]), ); return React.useMemo(() => { if (memberHasAdminRole) { return false; } return threadIsWithBlockedUserOnlyWithoutAdminRoleCheck( threadInfo, viewerID, userInfos, true, ); }, [memberHasAdminRole, threadInfo, userInfos, viewerID]); } 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.', }; // 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: | { +minimallyEncoded: true, +permissions: string, ... } | { +minimallyEncoded?: void, +permissions: ThreadPermissionsInfo, ... }, ): boolean { if (memberInfo.minimallyEncoded) { return hasPermission(memberInfo.permissions, threadPermissions.CHANGE_ROLE); } return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsDefaultRole( roleInfo: ?ClientLegacyRoleInfo | ?RoleInfo, ): boolean { if (roleInfo?.specialRole === specialRoles.DEFAULT_ROLE) { return true; } return !!(roleInfo && roleInfo.isDefault); } function roleIsAdminRole(roleInfo: ?ClientLegacyRoleInfo | ?RoleInfo): boolean { if (roleInfo?.specialRole === specialRoles.ADMIN_ROLE) { return true; } return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?( | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo | ServerThreadInfo ), ): boolean { if (!threadInfo) { return false; } let hasSpecialRoleFieldBeenEncountered = false; for (const role of Object.values(threadInfo.roles)) { if (role.specialRole === specialRoles.ADMIN_ROLE) { return true; } if (role.specialRole !== undefined) { hasSpecialRoleFieldBeenEncountered = true; } } if (hasSpecialRoleFieldBeenEncountered) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: LegacyRawThreadInfo | RawThreadInfo, ) { return ( threadMembersWithoutAddedAdmin(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 = values( threadPermissionsDisabledByBlock, ); 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 ` + + `Muted 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.`; + `To move a chat over here, switch the “Muted” 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.GENESIS_PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Thread'; } else if (threadType === threadTypes.GENESIS_PRIVATE) { return 'Private'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS ) { return 'Community'; } else { return 'Secret'; } } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, ) => ?ThreadInfo; function useExistingThreadInfoFinder( baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const loggedInUserInfo = useLoggedInUserInfo(); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!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; return updatedThread; }, [baseThreadInfo, threadInfos, loggedInUserInfo, pendingToRealizedThreadIDs], ); } 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.GENESIS_PERSONAL || threadType === threadTypes.GENESIS_PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function checkIfDefaultMembersAreVoiced(threadInfo: ThreadInfo): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find(roleID => roleIsDefaultRole(threadInfo.roles[roleID]), ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; const defaultRolePermissions = decodeMinimallyEncodedRoleInfo(defaultRole).permissions; return !!defaultRolePermissions[threadPermissions.VOICED]; } const draftKeySuffix = '/message_composer'; function draftKeyFromThreadID(threadID: string): string { return `${threadID}${draftKeySuffix}`; } function getContainingThreadID( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, threadType: ThreadType, ): ?string { if (!parentThreadInfo) { return null; } if (threadType === threadTypes.SIDEBAR) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ): ?string { if (!parentThreadInfo) { return null; } const { id, community, type } = parentThreadInfo; if (community !== null && community !== undefined) { return community; } if (threadTypeIsCommunityRoot(type)) { return id; } return null; } function getThreadListSearchResults( chatListData: $ReadOnlyArray, searchText: string, threadFilter: ThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, 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.GENESIS_PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.GENESIS_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; } function reorderThreadSearchResults( threadInfos: $ReadOnlyArray, threadSearchResults: $ReadOnlyArray, ): T[] { const privateThreads = []; const personalThreads = []; const otherThreads = []; const threadSearchResultsSet = new Set(threadSearchResults); for (const threadInfo of threadInfos) { if (!threadSearchResultsSet.has(threadInfo.id)) { continue; } if (threadInfo.type === threadTypes.GENESIS_PRIVATE) { privateThreads.push(threadInfo); } else if (threadInfo.type === threadTypes.GENESIS_PERSONAL) { personalThreads.push(threadInfo); } else { otherThreads.push(threadInfo); } } return [...privateThreads, ...personalThreads, ...otherThreads]; } function useAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, threadInfo: ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { const canRemoveMembers = useThreadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = useThreadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); return React.useMemo(() => { const { role } = memberInfo; if (!canEdit || !role) { return []; } const result = []; if ( canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo) ) { result.push('change_role'); } if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || roleIsDefaultRole(threadInfo.roles[role])) ) { result.push('remove_user'); } return result; }, [canChangeRoles, canEdit, canRemoveMembers, memberInfo, threadInfo]); } function patchThreadInfoToIncludeMentionedMembersOfParent( threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, messageText: string, viewerID: string, ): ThreadInfo { 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, communityID: string, ): boolean { return threadInfo.community === communityID || threadInfo.id === communityID; } type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( threadInfo: ThreadInfo, ): 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.members, threadInfo.roles]); } function useRoleNamesToSpecialRole(threadInfo: ThreadInfo): { +[roleName: string]: ?SpecialRole, } { return React.useMemo(() => { const roleNamesToSpecialRole: { [roleName: string]: ?SpecialRole } = {}; values(threadInfo.roles).forEach(role => { if (roleNamesToSpecialRole[role.name] !== undefined) { return; } if (roleIsDefaultRole(role)) { roleNamesToSpecialRole[role.name] = specialRoles.DEFAULT_ROLE; } else if (roleIsAdminRole(role)) { roleNamesToSpecialRole[role.name] = specialRoles.ADMIN_ROLE; } else { roleNamesToSpecialRole[role.name] = null; } }); return roleNamesToSpecialRole; }, [threadInfo.roles]); } 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, ): RoleUserSurfacedPermissions { return React.useMemo(() => { const roleNamesToPermissions: { [string]: Set } = {}; Object.keys(threadInfo.roles).forEach(roleID => { const roleName = threadInfo.roles[roleID].name; const rolePermissions = Object.keys( decodeMinimallyEncodedRoleInfo(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.roles]); } function communityOrThreadNoun(threadInfo: RawThreadInfo | ThreadInfo): string { return threadTypeIsCommunityRoot(threadInfo.type) ? 'community' : threadNoun(threadInfo.type, threadInfo.parentThreadID); } function getThreadsToDeleteText( threadInfo: RawThreadInfo | ThreadInfo, ): 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.GENESIS_PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.GENESIS_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]; return privateThreadInfo ? { threadInfo: privateThreadInfo } : null; } if (usersWithPersonalThread.has(userID)) { const personalThreadInfo: ?ThreadInfo = 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, ]); } function assertAllThreadInfosAreLegacy(rawThreadInfos: MixedRawThreadInfos): { [id: string]: LegacyRawThreadInfo, } { return Object.fromEntries( Object.entries(rawThreadInfos).map(([id, rawThreadInfo]) => { invariant( !rawThreadInfo.minimallyEncoded, `rawThreadInfos shouldn't be minimallyEncoded`, ); return [id, rawThreadInfo]; }), ); } function useOnScreenEntryEditableThreadInfos(): $ReadOnlyArray { const visibleThreadInfos = useSelector(onScreenThreadInfos); const editableVisibleThreadInfos = useThreadsWithPermission( visibleThreadInfos, threadPermissions.EDIT_ENTRIES, ); return editableVisibleThreadInfos; } export { threadHasPermission, useCommunityRootMembersToRole, useThreadHasPermission, viewerIsMember, threadInChatList, useIsThreadInChatList, useThreadsInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, extractNewMentionedParentMembers, pendingThreadType, filterOutDisabledPermissions, threadFrozenDueToBlock, useThreadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, memberHasAdminPowers, roleIsDefaultRole, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useExistingThreadInfoFinder, getThreadTypeParentRequirement, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, reorderThreadSearchResults, useAvailableThreadMemberActions, threadMembersWithoutAddedAdmin, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleNamesToSpecialRole, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useUserProfileThreadInfo, assertAllThreadInfosAreLegacy, useOnScreenEntryEditableThreadInfos, extractMentionedMembers, isMemberActive, }; diff --git a/native/chat/background-chat-thread-list.react.js b/native/chat/background-chat-thread-list.react.js index df595403a..3be352614 100644 --- a/native/chat/background-chat-thread-list.react.js +++ b/native/chat/background-chat-thread-list.react.js @@ -1,77 +1,78 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import { unreadBackgroundCount } from 'lib/selectors/thread-selectors.js'; +import { threadSettingsNotificationsCopy } from 'lib/shared/thread-settings-notifications-utils.js'; import { threadInBackgroundChatList, emptyItemText, } from 'lib/shared/thread-utils.js'; import ChatThreadList from './chat-thread-list.react.js'; import type { ChatTopTabsNavigationProp } from './chat.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import BackgroundTabIllustration from '../vectors/background-tab-illustration.react.js'; type BackgroundChatThreadListProps = { navigation: ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, route: NavigationRoute<'BackgroundChatThreadList'>, }; export default function BackgroundChatThreadList( props: BackgroundChatThreadListProps, ): React.Node { const unreadBackgroundThreadsNumber = useSelector(state => unreadBackgroundCount(state), ); const prevUnreadNumber = React.useRef(0); React.useEffect(() => { if (unreadBackgroundThreadsNumber === prevUnreadNumber.current) { return; } prevUnreadNumber.current = unreadBackgroundThreadsNumber; - let title = 'Background'; + let title = threadSettingsNotificationsCopy.MUTED; if (unreadBackgroundThreadsNumber !== 0) { title += ` (${unreadBackgroundThreadsNumber})`; } props.navigation.setOptions({ title }); }, [props.navigation, unreadBackgroundThreadsNumber]); return ( ); } function EmptyItem() { const styles = useStyles(unboundStyles); return ( {emptyItemText} ); } const unboundStyles = { container: { alignItems: 'center', paddingVertical: 40, }, emptyList: { color: 'listBackgroundLabel', fontSize: 14, marginHorizontal: 20, marginVertical: 10, textAlign: 'center', }, }; diff --git a/native/chat/chat.react.js b/native/chat/chat.react.js index 9f5c1ad67..870754445 100644 --- a/native/chat/chat.react.js +++ b/native/chat/chat.react.js @@ -1,502 +1,502 @@ // @flow import type { MaterialTopTabNavigationProp, StackNavigationState, StackOptions, StackNavigationEventMap, StackNavigatorProps, ExtraStackNavigatorProps, StackHeaderProps, StackNavigationProp, StackNavigationHelpers, ParamListBase, StackRouterOptions, MaterialTopTabNavigationHelpers, HeaderTitleInputProps, StackHeaderLeftButtonProps, } from '@react-navigation/core'; import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; import { createNavigatorFactory, useNavigationBuilder, } from '@react-navigation/native'; import { StackView } from '@react-navigation/stack'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, View, useWindowDimensions } from 'react-native'; import MessageStorePruner from 'lib/components/message-store-pruner.react.js'; import ThreadDraftUpdater from 'lib/components/thread-draft-updater.react.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { threadSettingsNotificationsCopy } from 'lib/shared/thread-settings-notifications-utils.js'; import { threadIsPending, threadIsSidebar } from 'lib/shared/thread-utils.js'; import BackgroundChatThreadList from './background-chat-thread-list.react.js'; import ChatHeader from './chat-header.react.js'; import ChatRouter, { type ChatRouterNavigationHelpers, type ChatRouterNavigationAction, } from './chat-router.js'; import ComposeSubchannel from './compose-subchannel.react.js'; import ComposeThreadButton from './compose-thread-button.react.js'; import FullScreenThreadMediaGallery from './fullscreen-thread-media-gallery.react.js'; import HomeChatThreadList from './home-chat-thread-list.react.js'; import { MessageEditingContext } from './message-editing-context.react.js'; import MessageListContainer from './message-list-container.react.js'; import MessageListHeaderTitle from './message-list-header-title.react.js'; import PinnedMessagesScreen from './pinned-messages-screen.react.js'; import DeleteThread from './settings/delete-thread.react.js'; import EmojiThreadAvatarCreation from './settings/emoji-thread-avatar-creation.react.js'; import ThreadSettingsNotifications from './settings/thread-settings-notifications.react.js'; import ThreadSettings from './settings/thread-settings.react.js'; import ThreadScreenPruner from './thread-screen-pruner.react.js'; import ThreadSettingsButton from './thread-settings-button.react.js'; import ThreadSettingsHeaderTitle from './thread-settings-header-title.react.js'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { InputStateContext } from '../input/input-state.js'; import CommunityDrawerButton from '../navigation/community-drawer-button.react.js'; import HeaderBackButton from '../navigation/header-back-button.react.js'; import { activeThreadSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { defaultStackScreenOptions, transitionPreset, } from '../navigation/options.js'; import { ComposeSubchannelRouteName, DeleteThreadRouteName, ThreadSettingsRouteName, EmojiThreadAvatarCreationRouteName, FullScreenThreadMediaGalleryRouteName, PinnedMessagesScreenRouteName, MessageListRouteName, ChatThreadListRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, ThreadSettingsNotificationsRouteName, type ScreenParamList, type ChatParamList, type ChatTopTabsParamList, MessageSearchRouteName, ChangeRolesScreenRouteName, type NavigationRoute, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import ChangeRolesHeaderLeftButton from '../roles/change-roles-header-left-button.react.js'; import ChangeRolesScreen from '../roles/change-roles-screen.react.js'; import MessageSearch from '../search/message-search.react.js'; import SearchHeader from '../search/search-header.react.js'; import SearchMessagesButton from '../search/search-messages-button.react.js'; import { useColors, useStyles } from '../themes/colors.js'; const unboundStyles = { keyboardAvoidingView: { flex: 1, }, view: { flex: 1, backgroundColor: 'listBackground', }, threadListHeaderStyle: { elevation: 0, shadowOffset: { width: 0, height: 0 }, borderBottomWidth: 0, backgroundColor: 'tabBarBackground', }, }; export type ChatTopTabsNavigationProp< RouteName: $Keys = $Keys, > = MaterialTopTabNavigationProp; export type ChatTopTabsNavigationHelpers = MaterialTopTabNavigationHelpers; const homeChatThreadListOptions = { - title: 'Focused', + title: threadSettingsNotificationsCopy.HOME, tabBarIcon: ({ color }: { +color: string, ... }) => ( ), }; const backgroundChatThreadListOptions = { - title: 'Background', + title: threadSettingsNotificationsCopy.MUTED, tabBarIcon: ({ color }: { +color: string, ... }) => ( ), }; const ChatThreadsTopTab = createMaterialTopTabNavigator< ScreenParamList, ChatTopTabsParamList, ChatTopTabsNavigationHelpers, >(); function ChatThreadsComponent(): React.Node { const colors = useColors(); const { tabBarBackground, tabBarAccent } = colors; const screenOptions = React.useMemo( () => ({ tabBarShowIcon: true, tabBarStyle: { backgroundColor: tabBarBackground, }, tabBarItemStyle: { flexDirection: 'row', }, tabBarIndicatorStyle: { borderColor: tabBarAccent, borderBottomWidth: 2, }, }), [tabBarAccent, tabBarBackground], ); return ( ); } export type ChatNavigationHelpers = { ...$Exact>, ...ChatRouterNavigationHelpers, }; type ChatNavigatorProps = StackNavigatorProps>; function ChatNavigator({ initialRouteName, children, screenOptions, defaultScreenOptions, screenListeners, id, ...rest }: ChatNavigatorProps) { const { state, descriptors, navigation } = useNavigationBuilder< StackNavigationState, ChatRouterNavigationAction, StackOptions, StackRouterOptions, ChatNavigationHelpers<>, StackNavigationEventMap, ExtraStackNavigatorProps, >(ChatRouter, { id, initialRouteName, children, screenOptions, defaultScreenOptions, screenListeners, }); // Clear ComposeSubchannel screens after each message is sent. If a user goes // to ComposeSubchannel to create a new thread, but finds an existing one and // uses it instead, we can assume the intent behind opening ComposeSubchannel // is resolved const inputState = React.useContext(InputStateContext); invariant(inputState, 'InputState should be set in ChatNavigator'); const clearComposeScreensAfterMessageSend = React.useCallback(() => { navigation.clearScreens([ComposeSubchannelRouteName]); }, [navigation]); React.useEffect(() => { inputState.registerSendCallback(clearComposeScreensAfterMessageSend); return () => { inputState.unregisterSendCallback(clearComposeScreensAfterMessageSend); }; }, [inputState, clearComposeScreensAfterMessageSend]); return ( ); } const createChatNavigator = createNavigatorFactory< StackNavigationState, StackOptions, StackNavigationEventMap, ChatNavigationHelpers<>, ExtraStackNavigatorProps, >(ChatNavigator); const header = (props: StackHeaderProps) => { // Flow has trouble reconciling identical types between different libdefs, // and flow-typed has no way for one libdef to depend on another const castProps: StackHeaderProps = (props: any); return ; }; const headerRightStyle = { flexDirection: 'row' }; const messageListOptions = ({ navigation, route, }: { +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }) => { const isSearchEmpty = !!route.params.searching && route.params.threadInfo.members.length === 1; const areSettingsEnabled = !threadIsPending(route.params.threadInfo.id) && !isSearchEmpty; return { headerTitle: (props: HeaderTitleInputProps) => ( ), headerRight: areSettingsEnabled ? () => ( ) : undefined, headerBackTitleVisible: false, headerTitleAlign: isSearchEmpty ? 'center' : 'left', headerLeftContainerStyle: { width: Platform.OS === 'ios' ? 32 : 40 }, headerTitleStyle: areSettingsEnabled ? { marginRight: 20 } : undefined, }; }; const composeThreadOptions = { headerTitle: 'Compose chat', headerBackTitleVisible: false, }; const threadSettingsOptions = ({ route, }: { +route: NavigationRoute<'ThreadSettings'>, ... }) => ({ headerTitle: (props: HeaderTitleInputProps) => ( ), headerBackTitleVisible: false, }); const emojiAvatarCreationOptions = { headerTitle: 'Emoji avatar selection', headerBackTitleVisible: false, }; const fullScreenThreadMediaGalleryOptions = { headerTitle: 'All Media', headerBackTitleVisible: false, }; const deleteThreadOptions = { headerTitle: 'Delete chat', headerBackTitleVisible: false, }; const messageSearchOptions = { headerTitle: () => , headerBackTitleVisible: false, headerTitleContainerStyle: { width: '100%', }, }; const pinnedMessagesScreenOptions = { headerTitle: 'Pinned Messages', headerBackTitleVisible: false, }; const threadSettingsNotificationsOptions = ({ route, }: { +route: NavigationRoute<'ThreadSettingsNotifications'>, ... }) => ({ headerTitle: threadIsSidebar(route.params.threadInfo) ? threadSettingsNotificationsCopy.SIDEBAR_TITLE : threadSettingsNotificationsCopy.CHANNEL_TITLE, headerBackTitleVisible: false, }); const changeRolesScreenOptions = ({ route, }: { +route: NavigationRoute<'ChangeRolesScreen'>, ... }) => ({ headerLeft: (headerLeftProps: StackHeaderLeftButtonProps) => ( ), headerTitle: 'Change Role', presentation: 'modal', ...transitionPreset, }); export type ChatNavigationProp< RouteName: $Keys = $Keys, > = { ...StackNavigationProp, ...ChatRouterNavigationHelpers, }; const Chat = createChatNavigator< ScreenParamList, ChatParamList, ChatNavigationHelpers, >(); type Props = { +navigation: TabNavigationProp<'Chat'>, ... }; export default function ChatComponent(props: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); const loggedIn = useSelector(isLoggedIn); let draftUpdater = null; if (loggedIn) { draftUpdater = ; } const headerLeftButton = React.useCallback( (headerProps: StackHeaderLeftButtonProps) => { if (headerProps.canGoBack) { return ; } return ; }, [props.navigation], ); const messageEditingContext = React.useContext(MessageEditingContext); const editState = messageEditingContext?.editState; const editMode = !!editState?.editedMessage; const { width: screenWidth } = useWindowDimensions(); const screenOptions = React.useMemo( () => ({ ...defaultStackScreenOptions, header, headerLeft: headerLeftButton, headerStyle: { backgroundColor: colors.tabBarBackground, borderBottomWidth: 1, }, gestureEnabled: true, gestureResponseDistance: editMode ? 0 : screenWidth, }), [colors.tabBarBackground, headerLeftButton, screenWidth, editMode], ); const chatThreadListOptions = React.useCallback( ({ navigation, }: { +navigation: ChatNavigationProp<'ChatThreadList'>, ... }) => ({ headerTitle: 'Inbox', headerRight: Platform.OS === 'ios' ? () => : undefined, headerBackTitleVisible: false, headerStyle: styles.threadListHeaderStyle, }), [styles.threadListHeaderStyle], ); const frozen = useSelector(state => state.frozen); const navContext = React.useContext(NavContext); const activeThreadID = activeThreadSelector(navContext); return ( {draftUpdater} ); } diff --git a/native/profile/default-notifications-preferences.react.js b/native/profile/default-notifications-preferences.react.js index 866d8d439..8e3a1afce 100644 --- a/native/profile/default-notifications-preferences.react.js +++ b/native/profile/default-notifications-preferences.react.js @@ -1,214 +1,215 @@ // @flow import * as React from 'react'; import { View, Text, Platform } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useSetUserSettings, setUserSettingsActionTypes, } from 'lib/actions/user-actions.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; +import { threadSettingsNotificationsCopy } from 'lib/shared/thread-settings-notifications-utils.js'; import { type UpdateUserSettingsRequest, type NotificationTypes, type DefaultNotificationPayload, notificationTypes, userSettingsTypes, } from 'lib/types/account-types.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; import Action from '../components/action-row.react.js'; 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'; import { unknownErrorAlertDetails } from '../utils/alert-messages.js'; import Alert from '../utils/alert.js'; const CheckIcon = () => ( ); type ProfileRowProps = { +content: string, +onPress: () => void, +danger?: boolean, +selected?: boolean, }; function NotificationRow(props: ProfileRowProps): React.Node { const { content, onPress, danger, selected } = props; return ( {selected ? : null} ); } const unboundStyles = { scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, marginVertical: 2, }, icon: { lineHeight: Platform.OS === 'ios' ? 18 : 20, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, }; type BaseProps = { +navigation: ProfileNavigationProp<'DefaultNotifications'>, +route: NavigationRoute<'DefaultNotifications'>, }; type Props = { ...BaseProps, +styles: $ReadOnly, +dispatchActionPromise: DispatchActionPromise, +changeNotificationSettings: ( notificationSettingsRequest: UpdateUserSettingsRequest, ) => Promise, +selectedDefaultNotification: NotificationTypes, }; class DefaultNotificationsPreferences extends React.PureComponent { async updatedDefaultNotifications( data: NotificationTypes, ): Promise { const { changeNotificationSettings } = this.props; try { await changeNotificationSettings({ name: userSettingsTypes.DEFAULT_NOTIFICATIONS, data, }); } catch (e) { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: () => {} }], { cancelable: false }, ); } return { [userSettingsTypes.DEFAULT_NOTIFICATIONS]: data, }; } selectNotificationSetting = (data: NotificationTypes) => { const { dispatchActionPromise } = this.props; void dispatchActionPromise( setUserSettingsActionTypes, this.updatedDefaultNotifications(data), ); }; selectAllNotifications = () => { this.selectNotificationSetting(notificationTypes.FOCUSED); }; selectBackgroundNotifications = () => { this.selectNotificationSetting(notificationTypes.BACKGROUND); }; selectNoneNotifications = () => { this.selectNotificationSetting(notificationTypes.BADGE_ONLY); }; render(): React.Node { const { styles, selectedDefaultNotification } = this.props; return ( NOTIFICATIONS ); } } registerFetchKey(setUserSettingsActionTypes); const ConnectedDefaultNotificationPreferences: React.ComponentType = React.memo(function ConnectedDefaultNotificationPreferences( props: BaseProps, ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const changeNotificationSettings = useSetUserSettings(); const defaultNotification = userSettingsTypes.DEFAULT_NOTIFICATIONS; const selectedDefaultNotification = useSelector( ({ currentUserInfo }) => { if ( currentUserInfo?.settings && currentUserInfo?.settings[defaultNotification] ) { return currentUserInfo?.settings[defaultNotification]; } return notificationTypes.FOCUSED; }, ); return ( ); }); export default ConnectedDefaultNotificationPreferences; diff --git a/web/chat/chat-tabs.react.js b/web/chat/chat-tabs.react.js index ff7ca9f85..71df7bfef 100644 --- a/web/chat/chat-tabs.react.js +++ b/web/chat/chat-tabs.react.js @@ -1,71 +1,72 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { unreadBackgroundCount } from 'lib/selectors/thread-selectors.js'; +import { threadSettingsNotificationsCopy } from 'lib/shared/thread-settings-notifications-utils.js'; import css from './chat-tabs.css'; import ChatThreadList from './chat-thread-list.react.js'; import { ThreadListContext } from './thread-list-provider.js'; import Tabs, { type TabData } from '../components/tabs.react.js'; import { useSelector } from '../redux/redux-utils.js'; -type TabType = 'Background' | 'Focus'; +type TabType = 'Home' | 'Muted'; function ChatTabs(): React.Node { - let backgroundTitle = 'Background'; + let mutedTitle = threadSettingsNotificationsCopy.MUTED; const unreadBackgroundCountVal = useSelector(unreadBackgroundCount); if (unreadBackgroundCountVal) { - backgroundTitle += ` (${unreadBackgroundCountVal})`; + mutedTitle += ` (${unreadBackgroundCountVal})`; } const tabsData: $ReadOnlyArray> = React.useMemo( () => [ { - id: 'Focus', - header: 'Focused', + id: 'Home', + header: threadSettingsNotificationsCopy.HOME, }, { - id: 'Background', - header: backgroundTitle, + id: 'Muted', + header: mutedTitle, }, ], - [backgroundTitle], + [mutedTitle], ); const threadListContext = React.useContext(ThreadListContext); invariant( threadListContext, 'threadListContext should be set in ChatThreadList', ); const { activeTab, setActiveTab } = threadListContext; const tabs = React.useMemo( () => ( ), [activeTab, setActiveTab, tabsData], ); const chatTabs = React.useMemo( () => (
{tabs}
), [tabs], ); return chatTabs; } export default ChatTabs; diff --git a/web/chat/chat-thread-list.react.js b/web/chat/chat-thread-list.react.js index ace293138..a60edb07b 100644 --- a/web/chat/chat-thread-list.react.js +++ b/web/chat/chat-thread-list.react.js @@ -1,198 +1,198 @@ // @flow import invariant from 'invariant'; import _sum from 'lodash/fp/sum.js'; import * as React from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { VariableSizeList } from 'react-window'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import genesis from 'lib/facts/genesis.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { emptyItemText } from 'lib/shared/thread-utils.js'; import ChatThreadListItem from './chat-thread-list-item.react.js'; import ChatThreadListSearch from './chat-thread-list-search.react.js'; import css from './chat-thread-list.css'; import { ThreadListContext } from './thread-list-provider.js'; import BackgroundIllustration from '../assets/background-illustration.react.js'; import Button from '../components/button.react.js'; import ComposeSubchannelModal from '../modals/threads/create/compose-subchannel-modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOnClickNewThread } from '../selectors/thread-selectors.js'; type Item = ChatThreadItem | { +type: 'search' } | { +type: 'empty' }; const sizes = { search: 68, empty: 249, thread: 81, sidebars: { sidebar: 32, seeMore: 22, spacer: 6 }, }; const itemKey = (index: number, data: $ReadOnlyArray) => { if (data[index].type === 'search') { return 'search'; } else if (data[index].type === 'empty') { return 'empty'; } else { return data[index].threadInfo.id; } }; type RenderItemInput = { +index: number, +data: $ReadOnlyArray, +style: CSSStyleDeclaration, }; const renderItem: RenderItemInput => React.Node = ({ index, data, style }) => { let item; if (data[index].type === 'search') { item = ; } else if (data[index].type === 'empty') { item = ; } else { item = ; } return
{item}
; }; type VariableSizeListRef = { +resetAfterIndex: (index: number, shouldForceUpdate?: boolean) => void, ... }; function ChatThreadList(): React.Node { const threadListContext = React.useContext(ThreadListContext); invariant( threadListContext, 'threadListContext should be set in ChatThreadList', ); const { activeTab, threadList } = threadListContext; const onClickNewThread = useOnClickNewThread(); const isThreadCreation = useSelector( state => state.navInfo.chatMode === 'create', ); - const isBackground = activeTab === 'Background'; + const isMuted = activeTab === 'Muted'; const communityID = useSelector(state => state.communityPickerStore.chat); const communityThreadInfo = useSelector(state => { if (!communityID) { return null; } return threadInfoSelector(state)[communityID]; }); const { pushModal, popModal } = useModalContext(); const onClickCreateSubchannel = React.useCallback(() => { if (!communityThreadInfo) { return null; } return pushModal( , ); }, [popModal, pushModal, communityThreadInfo]); const isChatCreation = !communityID || communityID === genesis().id; const onClickCreate = isChatCreation ? onClickNewThread : onClickCreateSubchannel; const createButtonText = isChatCreation ? 'Create new chat' : 'Create new channel'; const threadListContainerRef = React.useRef(); const threads = React.useMemo( () => threadList.filter( item => !communityID || item.threadInfo.community === communityID || item.threadInfo.id === communityID, ), [communityID, threadList], ); React.useEffect(() => { if (threadListContainerRef.current) { threadListContainerRef.current.resetAfterIndex(0, false); } }, [threads]); const threadListContainer = React.useMemo(() => { const items: Item[] = [{ type: 'search' }, ...threads]; - if (isBackground && threads.length === 0) { + if (isMuted && threads.length === 0) { items.push({ type: 'empty' }); } const itemSize = (index: number) => { if (items[index].type === 'search') { return sizes.search; } else if (items[index].type === 'empty') { return sizes.empty; } const sidebarHeight = _sum( items[index].sidebars.map(s => sizes.sidebars[s.type]), ); return sizes.thread + sidebarHeight; }; return ( {({ height }) => ( {renderItem} )} ); - }, [isBackground, threads]); + }, [isMuted, threads]); return ( <>
{threadListContainer}
); } function EmptyItem() { return (
{emptyItemText}
); } export default ChatThreadList; diff --git a/web/chat/thread-list-provider.js b/web/chat/thread-list-provider.js index 45f8d27f0..3ae4d8abd 100644 --- a/web/chat/thread-list-provider.js +++ b/web/chat/thread-list-provider.js @@ -1,252 +1,250 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadListSearch } from 'lib/hooks/thread-search-hooks.js'; import { type ChatThreadItem, useFlattenedChatListData, } from 'lib/selectors/chat-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { threadInBackgroundChatList, threadInHomeChatList, getThreadListSearchResults, threadIsPending, useIsThreadInChatList, } from 'lib/shared/thread-utils.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { useSelector } from '../redux/redux-utils.js'; import { useChatThreadItem, activeChatThreadItem as activeChatThreadItemSelector, } from '../selectors/chat-selectors.js'; -type ChatTabType = 'Focus' | 'Background'; +type ChatTabType = 'Home' | 'Muted'; type ThreadListContextType = { +activeTab: ChatTabType, +setActiveTab: (newActiveTab: ChatTabType) => void, +threadList: $ReadOnlyArray, +searchText: string, +setSearchText: (searchText: string) => void, }; const ThreadListContext: React.Context = React.createContext(); type ThreadListProviderProps = { +children: React.Node, }; function ThreadListProvider(props: ThreadListProviderProps): React.Node { - const [activeTab, setActiveTab] = React.useState('Focus'); + const [activeTab, setActiveTab] = React.useState('Home'); const activeChatThreadItem = useSelector(activeChatThreadItemSelector); const activeThreadInfo = activeChatThreadItem?.threadInfo; const activeThreadID = activeThreadInfo?.id; const activeSidebarParentThreadInfo = useSelector(state => { if (!activeThreadInfo || activeThreadInfo.type !== threadTypes.SIDEBAR) { return null; } const { parentThreadID } = activeThreadInfo; invariant(parentThreadID, 'sidebar must have parent thread'); return threadInfoSelector(state)[parentThreadID]; }); const activeTopLevelThreadInfo = activeThreadInfo?.type === threadTypes.SIDEBAR ? activeSidebarParentThreadInfo : activeThreadInfo; const activeTopLevelThreadIsFromHomeTab = activeTopLevelThreadInfo?.currentUser.subscription.home; const activeTopLevelThreadIsFromDifferentTab = - (activeTab === 'Focus' && activeTopLevelThreadIsFromHomeTab) || - (activeTab === 'Background' && !activeTopLevelThreadIsFromHomeTab); + (activeTab === 'Home' && activeTopLevelThreadIsFromHomeTab) || + (activeTab === 'Muted' && !activeTopLevelThreadIsFromHomeTab); const activeTopLevelThreadIsInChatList = useIsThreadInChatList( activeTopLevelThreadInfo, ); const shouldChangeTab = activeTopLevelThreadIsInChatList && activeTopLevelThreadIsFromDifferentTab; const prevActiveThreadIDRef = React.useRef(); React.useEffect(() => { const prevActiveThreadID = prevActiveThreadIDRef.current; prevActiveThreadIDRef.current = activeThreadID; if (activeThreadID !== prevActiveThreadID && shouldChangeTab) { - setActiveTab(activeTopLevelThreadIsFromHomeTab ? 'Focus' : 'Background'); + setActiveTab(activeTopLevelThreadIsFromHomeTab ? 'Home' : 'Muted'); } }, [activeThreadID, shouldChangeTab, activeTopLevelThreadIsFromHomeTab]); const activeThreadOriginalTab = React.useMemo(() => { if (activeTopLevelThreadIsInChatList) { return null; } return activeTab; // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTopLevelThreadIsInChatList, activeThreadID]); const makeSureActivePendingThreadIsIncluded = React.useCallback( ( threadListData: $ReadOnlyArray, ): $ReadOnlyArray => { if ( activeChatThreadItem && threadIsPending(activeThreadID) && activeThreadInfo?.type !== threadTypes.SIDEBAR && !threadListData .map(thread => thread.threadInfo.id) .includes(activeThreadID) ) { return [activeChatThreadItem, ...threadListData]; } return threadListData; }, [activeChatThreadItem, activeThreadID, activeThreadInfo], ); const makeSureActiveSidebarIsIncluded = React.useCallback( (threadListData: $ReadOnlyArray) => { if ( !activeChatThreadItem || activeChatThreadItem.threadInfo.type !== threadTypes.SIDEBAR ) { return threadListData; } const sidebarParentIndex = threadListData.findIndex( thread => thread.threadInfo.id === activeChatThreadItem.threadInfo.parentThreadID, ); if (sidebarParentIndex === -1) { return threadListData; } const parentItem = threadListData[sidebarParentIndex]; for (const sidebarItem of parentItem.sidebars) { if (sidebarItem.type !== 'sidebar') { continue; } else if ( sidebarItem.threadInfo.id === activeChatThreadItem.threadInfo.id ) { return threadListData; } } let indexToInsert = parentItem.sidebars.findIndex( sidebar => sidebar.lastUpdatedTime === undefined || sidebar.lastUpdatedTime < activeChatThreadItem.lastUpdatedTime, ); if (indexToInsert === -1) { indexToInsert = parentItem.sidebars.length; } const activeSidebar = { type: 'sidebar', lastUpdatedTime: activeChatThreadItem.lastUpdatedTime, mostRecentNonLocalMessage: activeChatThreadItem.mostRecentNonLocalMessage, threadInfo: activeChatThreadItem.threadInfo, }; const newSidebarItems = [...parentItem.sidebars]; newSidebarItems.splice(indexToInsert, 0, activeSidebar); const newThreadListData = [...threadListData]; newThreadListData[sidebarParentIndex] = { ...parentItem, sidebars: newSidebarItems, }; return newThreadListData; }, [activeChatThreadItem], ); const chatListData = useFlattenedChatListData(); const [searchText, setSearchText] = React.useState(''); const loggedInUserInfo = useLoggedInUserInfo(); const viewerID = loggedInUserInfo?.id; const { threadSearchResults, usersSearchResults } = useThreadListSearch( searchText, viewerID, ); const threadFilter = - activeTab === 'Background' - ? threadInBackgroundChatList - : threadInHomeChatList; + activeTab === 'Muted' ? threadInBackgroundChatList : threadInHomeChatList; const chatListDataWithoutFilter = getThreadListSearchResults( chatListData, searchText, threadFilter, threadSearchResults, usersSearchResults, loggedInUserInfo, ); const activeTopLevelChatThreadItem = useChatThreadItem( activeTopLevelThreadInfo, ); const threadList = React.useMemo(() => { let threadListWithTopLevelItem = chatListDataWithoutFilter; if ( activeTopLevelChatThreadItem && !activeTopLevelThreadIsInChatList && activeThreadOriginalTab === activeTab ) { threadListWithTopLevelItem = [ activeTopLevelChatThreadItem, ...threadListWithTopLevelItem, ]; } const threadListWithCurrentPendingThread = makeSureActivePendingThreadIsIncluded(threadListWithTopLevelItem); return makeSureActiveSidebarIsIncluded(threadListWithCurrentPendingThread); }, [ activeTab, activeThreadOriginalTab, activeTopLevelChatThreadItem, activeTopLevelThreadIsInChatList, chatListDataWithoutFilter, makeSureActivePendingThreadIsIncluded, makeSureActiveSidebarIsIncluded, ]); const isChatCreationMode = useSelector( state => state.navInfo.chatMode === 'create', ); const orderedThreadList = React.useMemo(() => { if (!isChatCreationMode) { return threadList; } return [ ...threadList.filter(thread => thread.threadInfo.id === activeThreadID), ...threadList.filter(thread => thread.threadInfo.id !== activeThreadID), ]; }, [activeThreadID, isChatCreationMode, threadList]); const threadListContext = React.useMemo( () => ({ activeTab, threadList: orderedThreadList, setActiveTab, searchText, setSearchText, }), [activeTab, orderedThreadList, searchText], ); return ( {props.children} ); } export { ThreadListProvider, ThreadListContext };