diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 7de4138929fd..6eab84c175e5 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -619,6 +619,18 @@ func ReadArr() (data []byte, err error) { n, err := currentConn.Read(buffer) if n > 0 { + // Coalesce already-pending writes into this delivery. Every ReadArr + // return costs a full native->JS bridge hop, and the JSI side + // batches however many frames arrive in one call. + if lb, ok := currentConn.(*libkb.LoopbackConn); ok { + for n < len(buffer) { + m, terr := lb.TryRead(buffer[n:]) + if m <= 0 || terr != nil { + break + } + n += m + } + } // Deliver data even if err != nil (allowed by the net.Conn // contract); the error will surface on the next call. Returning a // view of the shared buffer is safe because gomobile copies the diff --git a/go/libkb/loopback.go b/go/libkb/loopback.go index 081f1fbabe6f..274d03af5caa 100644 --- a/go/libkb/loopback.go +++ b/go/libkb/loopback.go @@ -137,6 +137,31 @@ func (lc *LoopbackConn) Read(b []byte) (n int, err error) { return n, nil } +// TryRead is a non-blocking Read: it returns immediately with n == 0 when no +// data is pending. Used by the mobile bridge to coalesce already-pending +// writes into one delivery, amortizing the per-call native->JS bridge hop. +func (lc *LoopbackConn) TryRead(b []byte) (n int, err error) { + lc.rMutex.Lock() + defer lc.rMutex.Unlock() + + if lc.buf.Len() > 0 { + return lc.buf.Read(b) + } + select { + case msg, ok := <-lc.partnerCh: + if !ok { + return 0, io.EOF + } + n = copy(b, msg) + if n < len(msg) { + lc.buf.Write(msg[n:]) + } + return n, nil + default: + return 0, nil + } +} + // Write writes data to the connection. func (lc *LoopbackConn) Write(b []byte) (n int, err error) { lc.wMutex.Lock() diff --git a/plans/flow-test.md b/plans/flow-test.md index 7671ae6797c3..8b73923507ff 100644 --- a/plans/flow-test.md +++ b/plans/flow-test.md @@ -4,11 +4,17 @@ Each bucket is a logical group for one or more PRs. Items are ordered easiest-first within each bucket. Validate after each bucket before moving on. -**Pairing rule:** Do Electron and iOS for each bucket together before moving to the next bucket. +**Pairing rule:** Buckets 1–15: do Electron and iOS together. Buckets 16+ (visual-coverage expansion): Electron first; iOS gets a follow-up pass once the desktop suite is stable. **Branch scripts:** `yarn test:e2e:desktop:branch` and `yarn test:e2e:ios:branch` run only the new flows being developed. When a flow is verified working on both platforms, remove it from the branch scripts. When adding a new bucket's test files, add them to both scripts. -Out of scope = screens that create, delete, add, invite, or remove something. Everything else is in scope even if it requires app state to reach. +**Goal:** 100% visual coverage of the app. Every test's final screenshot is a visual-regression baseline (playwright `screenshot: 'on'` + `yarn test:e2e:desktop:save-baseline`), and the dark-mode project doubles every shot for free. So coverage = one test per distinct visual state: routes, modals, popups, scroll positions, filled inputs. + +**Mutations are now IN SCOPE** when the flow is reproducible: +- **Create → cleanup in the same test.** A test that creates something must delete it before it ends (git repo, channel, email address, paper key). Use fixed `e2e-vis-*` names and delete any leftover at test start so a crashed run self-heals. +- **Open → cancel is always fine.** Any modal/wizard can be opened and screenshotted as long as the test cancels before the final submit when the mutation isn't cleanly reversible. +- **Never touch:** account deletion/reset, revoking a real device (paper keys created by the test are OK), logging out, changing the password, verifying a phone number, creating/revoking real proofs, leaving or deleting real teams, blocking real users. See the Forbidden list at the bottom. +- Avoid visual nondeterminism: created objects use fixed names, message sends go to a dedicated e2e conversation (accept that its history grows — screenshot the input/modal states, not the message list). **testID rule:** Never wrap existing component content in a new `Kb.Box2` (or any container) just to attach a `testID`. Instead, add the `testID` prop directly to an element that already exists in the component — an input, a scroll view, a pre-existing wrapper, etc. @@ -50,17 +56,17 @@ Open an existing conversation. No sending. From an open conversation, open each of these. Dismiss/cancel without submitting. -- [ ] Info panel (the ⓘ / conversation info button) -- [ ] Message popup / context menu (long-press or right-click a message) -- [ ] Emoji picker (tap emoji button in input area) -- [ ] Search bots modal (tap bot icon) -- [ ] Bot info / install preview — open a bot, view, don't install (`chatInstallBot`) +- [x] Info panel (the ⓘ / conversation info button) (Electron ✓ chat-modals.test.ts, iOS ✓ visual-states.test.ts via native More→Info menu) +- [x] Message popup / context menu (long-press or right-click a message) (Electron ✓) +- [x] Emoji picker (tap emoji button in input area) (Electron ✓) +- [x] Search bots modal (info panel → Bots → Add a bot) (Electron ✓) +- [x] Bot info / install preview — open a bot, view, don't install (`chatInstallBot`) (Electron ✓) - [ ] Bot team picker (`chatInstallBotPick`) — view destinations, cancel -- [ ] Forward message pick (`chatForwardMsgPick`) — view destinations, cancel -- [ ] Attachment fullscreen (`chatAttachmentFullscreen`) — requires a message with an image -- [ ] PDF viewer (`chatPDF`) — requires a message with a PDF +- [x] Forward message pick (`chatForwardMsgPick`) — view destinations, cancel (Electron ✓) +- [x] Attachment fullscreen (`chatAttachmentFullscreen`) — requires a message with an image (Electron ✓, iOS ✓) +- [ ] PDF viewer (`chatPDF`) — requires a seeded PDF message - [ ] Location map popup (`chatUnfurlMapPopup`) — requires a message with a location unfurl -- [ ] External link warning (`chatConfirmNavigateExternal`) — click an http link in a message +- [ ] External link warning (`chatConfirmNavigateExternal`) — click an http link in a message (seed via send) --- @@ -73,7 +79,7 @@ Navigate from the Settings nav. Confirm renders, go back. - [x] Display (Electron ✓, iOS written) - [x] Notifications (Electron ✓, iOS written) - [x] Feedback (Electron ✓, iOS written) -- [ ] Password (modal: `settingsTabs.password`) — needs Account settings navigation; no testID yet +- [x] Password (modal: `settingsTabs.password`) (Electron ✓ misc-modals.test.ts, iOS ✓ visual-states.test.ts) --- @@ -85,7 +91,7 @@ Same pattern. Devices and Git reuse their main tab screen components. - [x] Files (Electron ✓, iOS written via settings-subpages.yaml) - [x] Git — reuses git root component (Electron ✓, iOS ✓ via git.yaml) - [x] Devices — reuses devices root component (Electron ✓, iOS ✓ via devices-view.yaml) -- [ ] Wallet +- [x] Wallet (Electron ✓ misc-modals.test.ts, iOS ✓ visual-states.test.ts) - [x] Archive / Backup (Electron ✓, iOS written via settings-subpages.yaml) - [ ] Contacts (mobile only, `settingsTabs.contactsTab`) - [x] Screen Protector (mobile only, `settingsTabs.screenprotector`) (Electron ✓, iOS: Android only) @@ -96,7 +102,7 @@ Same pattern. Devices and Git reuse their main tab screen components. Settings-adjacent modals that are viewable without mutating. -- [ ] Archive modal (`archiveModal`) — view the backup flow, cancel +- [x] Archive modal (`archiveModal`) — view the backup flow, cancel (Electron ✓ misc-modals.test.ts, iOS ✓ visual-states.test.ts) - [ ] Contacts joined (`settingsContactsJoined`) — notification screen (hard to trigger naturally; may need investigation) - [ ] Push prompt (`settingsPushPrompt`) — mobile only, view and skip - [ ] Proxy settings (`proxySettingsModal`) — from the login screen or settings; view and cancel @@ -127,12 +133,12 @@ Open a team, navigate each internal tab. From within a team. - [x] Team member page (Electron ✓, iOS written) — taps smoke user's username in member list -- [ ] Edit channel modal — open, cancel (`teamEditChannel`) -- [ ] Team description edit modal — open, cancel (`teamEditTeamDescription`) -- [ ] Team info edit modal — open, cancel (`teamEditTeamInfo`) +- [x] Edit channel modal — open, cancel (`teamEditChannel`) (Electron ✓ teams-modals.test.ts) +- [x] Team description edit modal — open, cancel (`teamEditTeamDescription`) (Electron ✓ teams-modals.test.ts) +- [x] Team info edit modal — open, cancel (`teamEditTeamInfo`) (Electron ✓ teams-modals.test.ts) - [ ] Invite link join page (`teamInviteLinkJoin`) — view the page, don't join - [ ] Open team warning modal (`openTeamWarning`) — view and dismiss -- [ ] Retention warning modal (`retentionWarning`) — view and dismiss +- [x] Retention warning modal (`retentionWarning`) — view and dismiss (Electron ✓ teams-modals.test.ts) - [ ] External team page (`teamExternalTeam`) — view a public/open team without membership --- @@ -140,7 +146,7 @@ From within a team. ## Bucket 11 — Profile page and modals - [x] Profile page renders (Electron ✓ via People tab header; iOS written — conditional on username in feed) -- [ ] Proofs list modal (`profileProofsList`) — open from a profile, view, close +- [x] Proofs list modal (`profileProofsList`) — open from a profile, view, close (Electron ✓ profile-modals.test.ts) - [ ] Showcase team offer (`profileShowcaseTeamOffer`) — open from own profile, view, cancel --- @@ -168,96 +174,144 @@ From the Files root, tap each TLF type then back. ## Bucket 14 — People and account switcher -- [ ] Account switcher modal (People tab → avatar in header) +- [x] Account switcher (desktop: "Hi user!" menu in tab bar; mobile: People tab → avatar) (Electron ✓ misc-modals.test.ts) --- ## Bucket 15 — Wallets -- [ ] Wallet root screen renders (`walletsRoot`, accessible via Settings → Wallet) -- [ ] Remove account modal (`removeAccount`) — open, cancel (view-only intent, cancel before submitting) +- [x] Wallet root screen renders (`walletsRoot`, accessible via Settings → Wallet) (Electron ✓ misc-modals.test.ts) +- ~~Remove account modal (`removeAccount`)~~ — moved to Forbidden per user; don't automate stellar account removal at all + +--- + +## Bucket 16 — Scroll depth and list states (desktop) + +Same screen, more of it. Scroll to a deterministic position (bottom, or a fixed element) before the final screenshot. + +- [x] Chat inbox scrolled to bottom of conversation list (Electron ✓ scroll-states.test.ts) +- [x] Chat conversation scrolled up into older messages (Electron ✓, iOS ✓ visual-states.test.ts) +- [x] Settings → Advanced scrolled to bottom (dev/proxy section) (Electron ✓, iOS ✓) +- [x] Settings → Notifications scrolled to bottom (Electron ✓ scroll-states.test.ts) +- [x] Team members tab scrolled to bottom of member list (Electron ✓) +- [x] Files TLF list scrolled to bottom (Electron ✓) +- [x] Profile page scrolled to proofs/folders section (Electron ✓) +- [x] People feed scrolled to bottom (Electron ✓) + +--- + +## Bucket 17 — Chat compose states + +Distinct visual states of the input area in the dedicated e2e conversation. No sends needed except where noted. + +- [x] `@`-mention suggestion popup (type `@` + partial name) (Electron ✓ chat-compose.test.ts, iOS ✓ visual-states.test.ts) +- [x] Channel-mention popup (type `#` in a team conversation) (Electron ✓ chat-interactions.test.ts) +- [x] Emoji picker open from input bar (Electron ✓ chat-modals.test.ts) +- [x] `/`-command suggestion popup (type `/`) (Electron ✓ chat-compose.test.ts) +- [x] Giphy preview row (type `/giphy something`) (Electron ✓ chat-interactions.test.ts) +- [x] Multiline input grown (type several lines) (Electron ✓ chat-compose.test.ts) +- [x] Edit-message mode (up-arrow on own last message; escape to cancel) (Electron ✓ chat-interactions.test.ts) +- [x] Reply-quote state (reply to a message, input shows quote; escape to cancel) (Electron ✓ chat-interactions.test.ts) + +--- + +## Bucket 18 — Chat message interactions + +From the dedicated e2e conversation. + +- [x] Message context menu (right-click a text message) (Electron ✓ chat-modals.test.ts) +- [x] Reaction picker (hover toolbar → react) (Electron ✓ chat-interactions.test.ts) +- [ ] Reacji tooltip (hover an existing reaction) +- [x] Info panel members tab / attachments tab / settings tab (Electron ✓ chat-modals + chat-interactions) +- [ ] Attachment fullscreen (`chatAttachmentFullscreen`) — needs a seeded image message +- [ ] PDF viewer (`chatPDF`) — needs a seeded PDF message +- [ ] External link warning (`chatConfirmNavigateExternal`) — click an http link in a seeded message + +--- + +## Bucket 19 — Chat mutations (reproducible) + +- [x] `chatNewChat` — open new-conversation team builder, screenshot, cancel (Electron ✓ chat-mutations.test.ts, iOS ✓ visual-states.test.ts) +- [ ] Send message to dedicated e2e conversation (self-conversation or e2e team channel); history grows — screenshot the send state, not the list +- [x] `chatCreateChannel` — create `e2e-vis-chan` → screenshot → delete channel (`teamDeleteChannel` covered as cleanup) (Electron ✓ team-wizard-channel.test.ts) +- [x] `chatDeleteHistoryWarning` — open, screenshot, cancel (Electron ✓ chat-mutations.test.ts) +- [x] `chatBlockingModal` — open block dialog, screenshot, cancel (never submit) (Electron ✓ chat-mutations.test.ts) +- [ ] `chatAttachmentGetTitles` — attach a file, screenshot the titles modal, cancel +- [ ] `chatShowNewTeamDialog` — open, screenshot, cancel + +--- + +## Bucket 20 — Git mutations (reproducible) + +- [x] `gitNewRepo` — new-repo modal screenshot → create `e2e-vis-repo` → repo row renders → `gitDeleteRepo` delete-confirm screenshot → confirm delete (full cycle, self-cleaning) (Electron ✓ git-mutations.test.ts; iOS ✓ menu-open state only — FloatingMenu bottom-sheet items are a11y-invisible, can't tap through) +- [ ] `gitSelectChannel` — open from a team repo, screenshot, cancel + +--- + +## Bucket 21 — Team modals and wizards (open → cancel) + +All in the dedicated e2e team unless noted. + +- [x] `teamNewTeamDialog` wizard purpose + name screens, cancel before create (Electron ✓ team-wizard-channel.test.ts); deeper wizard steps (size/channels/subteams) still todo +- [ ] `teamsTeamBuilder` — add-members builder, screenshot, cancel +- [x] `teamAddToTeamFromWhere` wizard first screens + email screen, cancel (Electron ✓ teams-modals.test.ts, iOS ✓ visual-states.test.ts) +- [ ] `teamInviteByEmail` — screenshot, cancel +- [x] `teamEditChannel` — open, screenshot, cancel (Electron ✓ teams-modals.test.ts) +- [x] `teamEditTeamDescription` / `teamEditTeamInfo` — open, screenshot, cancel (Electron ✓ teams-modals.test.ts) +- [x] `teamAddEmoji` — open, screenshot, cancel (Electron ✓ teams-modals.test.ts); `teamAddEmojiAlias` still todo +- [x] `retentionWarning` — change retention dropdown to trigger, screenshot, cancel (Electron ✓ teams-modals.test.ts) +- [ ] `openTeamWarning` — toggle open-team setting to trigger, screenshot, cancel + +--- + +## Bucket 22 — Settings mutations (reproducible) + +- [x] `settingsAddEmail` — add/delete full cycle (Electron ✓ settings-mutations.test.ts; iOS skipped per user) +- [x] `settingsAddPhone` — open, screenshot, cancel (never verify) (Electron ✓ settings-mutations.test.ts) +- [ ] Password modal (`settingsTabs.password`) — open, screenshot, cancel (never save) +- [x] `settingsLogOutTab` — view the screen only, close modal (never log out) (Electron ✓ settings-mutations.test.ts) +- [ ] `archiveModal` — open, screenshot, cancel + +--- + +## Bucket 23 — Devices mutations (careful) + +- [x] `deviceAdd` — add-device chooser, screenshot, cancel (Electron ✓ device-wallet-modals.test.ts, iOS ✓ visual-states.test.ts); provisioning instruction sub-screens still todo +- ~~`devicePaperKey` / `deviceRevoke` paper-key cycle~~ — skipped per user; don't automate paper key creation/revocation + +--- + +## Bucket 24 — Profile and people mutations (open → cancel) + +- [x] `profileEdit` — open own-profile edit, screenshot, cancel (Electron ✓ profile-modals.test.ts) +- [ ] `profileEditAvatar` — open, screenshot, cancel +- [ ] `peopleTeamBuilder` — open from People, screenshot, cancel +- [ ] `profileAddToTeam` — open on another user's profile, screenshot, cancel +- [x] `cryptoTeamBuilder` — encrypt recipients picker, screenshot, cancel (Electron ✓ device-wallet-modals.test.ts) +- [ ] Proof flows (`profilePgp`, `profileProveWebsiteChoice`) — first screen only, screenshot, cancel (never post a proof) + +--- + +## Bucket 25 — Route audit for 100% + +- [ ] Enumerate every route in `shared/router-v2` route maps and cross-check against this file; add missing screens as new checklist items +- [ ] Verify the dark project produces a shot for every light shot in the report --- -## Out of scope — mutations - -These create, delete, add, invite, or remove something. All explicitly listed so nothing is accidentally omitted from evaluation. - -**Chat:** -- `chatNewChat` — create new chat / new conversation -- `chatCreateChannel` — create a channel -- `chatAddToChannel` — add members to a channel -- `chatBlockingModal` — block / report / filter a user -- `chatConfirmRemoveBot` — remove a bot from a conversation -- `chatDeleteHistoryWarning` — delete conversation history -- `chatShowNewTeamDialog` — create a new team from a conversation -- `chatSendToChat` — send a file to a chat (FS share flow) -- `chatAttachmentGetTitles` — set titles before sending attachments -- `chatLocationPreview` — send your location (input side, not unfurl view) - -**Crypto:** -- `cryptoTeamBuilder` — pick recipients for encrypt (team builder modal) - -**Devices:** -- `deviceRevoke` — revoke a device -- `deviceAdd` — add a new device -- `devicePaperKey` — paper key display (only shown during provisioning) -- All provision sub-routes — device provisioning flow - -**Files:** -- `confirmDelete` — delete a file/folder - -**Git:** -- `gitNewRepo` — create a new git repo -- `gitDeleteRepo` — delete a git repo - -**Login/signup/recover:** -- All `login`, `signup*`, `recoverPassword*`, `reset*` routes — not relevant while logged in -- `recoverPasswordSetPassword` — set a new password -- `proxySettingsModal` moved to Bucket 7 (view + cancel is fine) - -**People:** -- `peopleTeamBuilder` — the team builder launched from People (adds people to something) - -**Profile:** -- `profileEdit` — edit your own profile bio/location -- `profileEditAvatar` — change avatar -- `profileImport` — import a PGP key -- `profilePgp` — start a PGP proof flow -- `profileProveWebsiteChoice` — start a website proof -- `profileRevoke` — revoke a proof -- `profileAddToTeam` — add the viewed user to one of your teams - -**Settings:** -- `settingsAddEmail` — add an email address -- `settingsAddPhone` — add a phone number -- `settingsDeleteAddress` — delete an email or phone -- `settingsVerifyPhone` — verify a phone number -- `settingsLogOutTab` — log out -- `checkPassphraseBeforeDeleteAccount` — step in account deletion -- `deleteConfirm` — delete account - -**Teams:** -- `teamNewTeamDialog` — create a team -- `teamsTeamBuilder` — add members to a team -- `teamAddEmoji` — add a custom emoji -- `teamAddEmojiAlias` — add an emoji alias -- `teamAddToChannels` — add a user to channels -- `teamAddToTeamFromWhere` / `teamAddToTeamConfirm` / `teamAddToTeamContacts` / `teamAddToTeamEmail` / `teamAddToTeamPhone` — add members wizard -- `teamCreateChannels` — create channels -- `teamDeleteChannel` — delete a channel -- `teamDeleteTeam` — delete a team -- `teamInviteByContact` — invite via contacts -- `teamInviteByEmail` — invite via email -- `teamJoinTeamDialog` — join a team (adds self) -- `teamReallyLeaveTeam` — leave a team -- `teamReallyRemoveChannelMember` — remove someone from a channel -- `teamReallyRemoveMember` — kick a member -- `teamRename` — rename a subteam -- `teamWizard1TeamPurpose` / `teamWizard2TeamInfo` / `teamWizard4TeamSize` / `teamWizard5Channels` / `teamWizard6Subteams` / `teamWizardSubteamMembers` — new team creation wizard - -**Wallets:** -- `reallyRemoveAccount` — confirm removal of a wallet account - -**Incoming share:** -- `incomingShareNew` — triggered by the OS share sheet; not reachable from within the app +## Forbidden — never automate + +Not reproducible or account-damaging. Do not add tests for these. + +- Account deletion/reset: `deleteConfirm`, `checkPassphraseBeforeDeleteAccount`, `reset*` +- Logging out (`settingsLogOutTab` submit), changing the password (`recoverPasswordSetPassword`, password modal save) +- Revoking any device the test didn't create (paper keys created in Bucket 23 are the only exception) +- Actually creating a team (names are permanent), leaving/deleting real teams, renaming subteams (`teamRename`), kicking members (`teamReallyRemoveMember`, `teamReallyRemoveChannelMember`) +- Blocking/reporting real users (submit side of `chatBlockingModal`), removing bots (`chatConfirmRemoveBot`) +- Verifying a phone number (`settingsVerifyPhone`), posting/revoking real proofs (`profileRevoke`, proof-flow submits), importing PGP keys (`profileImport`) +- Joining teams (`teamJoinTeamDialog`) +- Wallet/stellar account removal — the ENTIRE flow (`removeAccount`, `reallyRemoveAccount`), including open-and-cancel; per user, don't automate anything that deletes stellar accounts +- Files `confirmDelete` on real user data (test-created files OK) +- Login/signup/provision routes — unreachable while logged in +- `incomingShareNew` — OS share sheet only, unreachable +- `chatSendToChat`, `chatLocationPreview` — FS-share / location send paths, mobile/OS-dependent diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 7dd238c6406f..a4a41db637c4 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -56,13 +56,9 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T return NAME } - @ReactMethod - override fun addListener(eventName: String) { - } - - @ReactMethod - override fun removeListeners(count: Double) { - } + // mEventEmitterCallback is only set once JS creates the TurboModule; + // the generated emit helpers would NPE before then. + private fun canEmit(): Boolean = mEventEmitterCallback != null @ReactMethod override fun clearLocalLogs(promise: Promise) { @@ -100,10 +96,23 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T return GuiConfig.getInstance(reactContext.filesDir)?.asString() } - @ReactMethod(isBlockingSynchronousMethod = true) - override fun getTypedConstants(): WritableMap { - val versionCode: String = getBuildConfigValue("VERSION_CODE").toString() - val versionName: String = getBuildConfigValue("VERSION_NAME").toString() + private data class KbConstants( + val isDeviceSecure: Boolean, + val versionCode: String, + val versionName: String, + val cacheDir: String, + val downloadDir: String, + val guiConfig: String?, + val serverConfig: String, + val uses24HourClock: Boolean, + val version: String, + ) + + // getTypedConstants is a blocking synchronous JS call that does file I/O + // and reflection; built once, prewarmed off the JS thread in init. + private val cachedConstants: KbConstants by lazy { buildConstants() } + + private fun buildConstants(): KbConstants { var isDeviceSecure = false try { val keyguardManager: KeyguardManager = reactContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager @@ -117,33 +126,34 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } catch (e: Exception) { NativeLogger.warn(": Error reading server config", e) } - var cacheDir = "" - run { - val dir: File? = reactContext.cacheDir - if (dir != null) { - cacheDir = dir.absolutePath - } - } - var downloadDir = "" - run { - val dir: File? = reactContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) - if (dir != null) { - downloadDir = dir.absolutePath - } - } + return KbConstants( + isDeviceSecure = isDeviceSecure, + versionCode = getBuildConfigValue("VERSION_CODE").toString(), + versionName = getBuildConfigValue("VERSION_NAME").toString(), + cacheDir = reactContext.cacheDir?.absolutePath ?: "", + downloadDir = reactContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath ?: "", + guiConfig = readGuiConfig(), + serverConfig = serverConfig, + uses24HourClock = DateFormat.is24HourFormat(reactContext), + version = version(), + ) + } + @ReactMethod(isBlockingSynchronousMethod = true) + override fun getTypedConstants(): WritableMap { + val c = cachedConstants val constants: WritableMap = Arguments.createMap() - constants.putBoolean("androidIsDeviceSecure", isDeviceSecure) + constants.putBoolean("androidIsDeviceSecure", c.isDeviceSecure) constants.putBoolean("androidIsTestDevice", misTestDevice) - constants.putString("appVersionCode", versionCode) - constants.putString("appVersionName", versionName) + constants.putString("appVersionCode", c.versionCode) + constants.putString("appVersionName", c.versionName) constants.putBoolean("darkModeSupported", false) - constants.putString("fsCacheDir", cacheDir) - constants.putString("fsDownloadDir", downloadDir) - constants.putString("guiConfig", readGuiConfig()) - constants.putString("serverConfig", serverConfig) - constants.putBoolean("uses24HourClock", DateFormat.is24HourFormat(reactContext)) - constants.putString("version", version()) + constants.putString("fsCacheDir", c.cacheDir) + constants.putString("fsDownloadDir", c.downloadDir) + constants.putString("guiConfig", c.guiConfig) + constants.putString("serverConfig", c.serverConfig) + constants.putBoolean("uses24HourClock", c.uses24HourClock) + constants.putString("version", c.version) return constants } @@ -290,6 +300,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T this.reactContext = reactContext!! instance = this misTestDevice = isTestDevice(reactContext) + Thread { cachedConstants }.start() } private fun normalizePath(path: String): String { @@ -379,10 +390,10 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } private fun emitPushNotificationInternal(notification: Bundle) { - if (reactContext.hasActiveReactInstance()) { + if (reactContext.hasActiveReactInstance() && canEmit()) { try { val payload = Arguments.fromBundle(notification) - reactContext.emitDeviceEvent("onPushNotification", payload) + emitOnPushNotification(payload) } catch (e: Exception) { NativeLogger.error("emitPushNotificationInternal failed to emit: " + e.message) } @@ -391,6 +402,18 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } } + internal fun emitShareDataInternal(data: WritableMap) { + if (reactContext.hasActiveReactInstance() && canEmit()) { + try { + emitOnShareData(data) + } catch (e: Exception) { + NativeLogger.error("emitShareDataInternal failed to emit: " + e.message) + } + } else { + NativeLogger.warn("emitShareDataInternal no active react instance") + } + } + @ReactMethod override fun removeAllPendingNotificationRequests() { } @@ -426,11 +449,19 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T promise.resolve(null) } + private fun relayReset() { + if (!reactContext.hasActiveReactInstance() || !canEmit()) { + NativeLogger.info("$NAME: JS Bridge is dead, Can't send EOF message") + } else { + emitOnMetaEvent(RPC_META_EVENT_ENGINE_RESET) + } + } + @ReactMethod override fun engineReset() { try { Keybase.reset() - relayReset(reactContext) + relayReset() } catch (e: Exception) { NativeLogger.error("Exception in engineReset", e) } @@ -503,7 +534,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T fun destroy() { try { Keybase.reset() - relayReset(reactContext) + relayReset() } catch (e: Exception) { NativeLogger.error("Exception in KeybaseEngine.destroy", e) } @@ -538,9 +569,9 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } private fun sendHardwareKeyEvent(keyName: String) { - val params = Arguments.createMap() - params.putString("pressedKey", keyName) - reactContext.emitDeviceEvent(HW_KEY_EVENT, params) + if (canEmit()) { + emitOnHardwareKeyPressed(keyName) + } } companion object { @@ -549,11 +580,9 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } const val NAME: String = "Kb" - private const val RPC_META_EVENT_NAME: String = "kb-meta-engine-event" private const val RPC_META_EVENT_ENGINE_RESET: String = "kb-engine-reset" private const val MAX_TEXT_FILE_SIZE = 100 * 1024 // 100 kiB private val LINE_SEPARATOR: String? = System.getProperty("line.separator") - private const val HW_KEY_EVENT: String = "hardwareKeyPressed" var instance: KbModule? = null @JvmStatic @@ -585,6 +614,16 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T module.emitPushNotificationInternal(notification) } + @JvmStatic + fun emitShareData(data: WritableMap) { + val module = instance + if (module == null) { + android.util.Log.w("KbModule", "emitShareData called but instance is null (app may not be running)") + return + } + module.emitShareDataInternal(data) + } + // Is this a robot controlled test device? (i.e. pre-launch report?) private fun isTestDevice(context: ReactApplicationContext): Boolean { val testLabSetting: String? = Settings.System.getString(context.contentResolver, "firebase.test.lab") @@ -592,14 +631,5 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } private const val FILE_PREFIX_BUNDLE_ASSET: String = "bundle-assets://" - - // engine - private fun relayReset(reactContext: ReactApplicationContext) { - if (!reactContext.hasActiveReactInstance()) { - NativeLogger.info("$NAME: JS Bridge is dead, Can't send EOF message") - } else { - reactContext.emitDeviceEvent(RPC_META_EVENT_NAME, RPC_META_EVENT_ENGINE_RESET) - } - } } } diff --git a/rnmodules/react-native-kb/ios/Kb.h b/rnmodules/react-native-kb/ios/Kb.h index 09bbbfb1ce94..929434082d22 100644 --- a/rnmodules/react-native-kb/ios/Kb.h +++ b/rnmodules/react-native-kb/ios/Kb.h @@ -2,14 +2,14 @@ #import "react-native-kb.h" #endif -#import +#import #import #ifdef RCT_NEW_ARCH_ENABLED #import #import #import -@interface Kb : RCTEventEmitter +@interface Kb : NativeKbSpecBase @end #else #endif // RCT_NEW_ARCH_ENABLED diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 2b12f73490bb..6db15cbe7a7c 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -33,7 +33,6 @@ + (id)sharedFsPathsHolder { @end -static NSString *const metaEventName = @"kb-meta-engine-event"; static NSString *const metaEventEngineReset = @"kb-engine-reset"; static __weak Kb *kbSharedInstance = nil; @@ -41,6 +40,86 @@ + (id)sharedFsPathsHolder { static NSString *kbStoredDeviceToken = nil; static NSDictionary *kbInitialNotification = nil; +// from react-native-localize +static bool kbUses24HourClockForLocale(NSLocale *_Nonnull locale) { + NSDateFormatter *formatter = [NSDateFormatter new]; + + [formatter setLocale:locale]; + [formatter setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]]; + [formatter setDateStyle:NSDateFormatterNoStyle]; + [formatter setTimeStyle:NSDateFormatterShortStyle]; + + NSDate *date = [NSDate dateWithTimeIntervalSince1970:72000]; + return [[formatter stringFromDate:date] containsString:@"20"]; +} + +static NSString *kbSetupServerConfig(void) { + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); + NSString *cachePath = [paths objectAtIndex:0]; + NSString *filePath = [cachePath stringByAppendingPathComponent:@"/Keybase/keybase.app.serverConfig"]; + NSError *err; + NSString *val = [NSString stringWithContentsOfFile:filePath + encoding:NSUTF8StringEncoding + error:&err]; + if (err != nil || val == nil) { + return @""; + } + return val; +} + +static NSString *kbSetupGuiConfig(void) { + NSString *filePath = [[[FsPathsHolder sharedFsPathsHolder] fsPaths][@"sharedHome"] + stringByAppendingPathComponent: @"/Library/Application Support/Keybase/gui_config.json"]; + NSError *err; + NSString *val = [NSString stringWithContentsOfFile:filePath encoding:NSUTF8StringEncoding error:&err]; + if (err != nil || val == nil) { + return @""; + } + return val; +} + +// Built once; safe because fsPaths and KeybaseInit are set up in the app +// delegate before React Native creates this module. +static NSDictionary *kbConstants(void) { + static NSDictionary *constants = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSString *serverConfig = kbSetupServerConfig(); + NSString *guiConfig = kbSetupGuiConfig(); + + NSString *appVersionString = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; + if (appVersionString == nil) { + appVersionString = @""; + } + NSString *appBuildString = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]; + if (appBuildString == nil) { + appBuildString = @""; + } + NSLocale *currentLocale = [NSLocale currentLocale]; + NSString *cacheDir = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject]; + NSString *downloadDir = [NSSearchPathForDirectoriesInDomains(NSDownloadsDirectory, NSUserDomainMask, YES) firstObject]; + + NSString *kbVersion = KeybaseVersion(); + if (kbVersion == nil) { + kbVersion = @""; + } + constants = @{ + @"androidIsDeviceSecure" : @NO, + @"androidIsTestDevice" : @NO, + @"appVersionCode" : appBuildString, + @"appVersionName" : appVersionString, + @"darkModeSupported" : @YES, + @"fsCacheDir" : cacheDir, + @"fsDownloadDir" : downloadDir, + @"guiConfig" : guiConfig, + @"serverConfig" : serverConfig, + @"uses24HourClock" : @(kbUses24HourClockForLocale(currentLocale)), + @"version" : kbVersion + }; + }); + return constants; +} + @interface Kb () @property dispatch_queue_t readQueue; @end @@ -56,6 +135,12 @@ + (BOOL)requiresMainQueueSetup { return YES; } +// _eventEmitterCallback is only set once JS creates the TurboModule; emitting +// through the generated helpers before then would call a null std::function. +- (BOOL)canEmit { + return _eventEmitterCallback != nullptr; +} + - (instancetype)init { self = [super init]; kbSharedInstance = self; @@ -65,6 +150,11 @@ - (instancetype)init { name:@"hardwareKeyPressed" object:nil]; [Kb swizzleUITextViewPaste]; + // getTypedConstants is a blocking synchronous JS call that does file I/O; + // warm the cache off the main/JS threads so startup doesn't stall on disk. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + (void)kbConstants(); + }); return self; } @@ -91,7 +181,7 @@ + (void)handlePastedImages:(NSArray *)images { if (!kbSharedInstance || images.count == 0) return; // Encoding and writing pasted images can be slow for large images; keep it - // off the main thread. sendEventWithName is safe to call from any thread. + // off the main thread. The emit helpers are safe to call from any thread. dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ NSMutableArray *uris = [NSMutableArray array]; for (UIImage *rawImage in images) { @@ -118,7 +208,10 @@ + (void)handlePastedImages:(NSArray *)images { } if (uris.count > 0) { - [kbSharedInstance sendEventWithName:@"onPasteImage" body:@{@"uris": uris}]; + Kb *instance = kbSharedInstance; + if (instance && [instance canEmit]) { + [instance emitOnPasteImage:uris]; + } } }); } @@ -131,16 +224,11 @@ - (void)invalidate { kbBridge_->teardown(); kbBridge_.reset(); } - [super invalidate]; self.readQueue = nil; NSError *error = nil; KeybaseReset(&error); } -- (NSArray *)supportedEvents { -return @[ metaEventName, @"hardwareKeyPressed", @"onPasteImage", @"onPushNotification", @"onPushToken" ]; -} - RCT_EXPORT_METHOD(setEnablePasteImage:(BOOL)enabled) { kbPasteImageEnabled = enabled; } @@ -176,81 +264,8 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime [[NSDate date] timeIntervalSince1970] * 1000]); } -// from react-native-localize -- (bool)uses24HourClockForLocale:(NSLocale *_Nonnull)locale { - NSDateFormatter *formatter = [NSDateFormatter new]; - - [formatter setLocale:locale]; - [formatter setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]]; - [formatter setDateStyle:NSDateFormatterNoStyle]; - [formatter setTimeStyle:NSDateFormatterShortStyle]; - - NSDate *date = [NSDate dateWithTimeIntervalSince1970:72000]; - return [[formatter stringFromDate:date] containsString:@"20"]; -} - -- (NSString *)setupServerConfig { - NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); - NSString *cachePath = [paths objectAtIndex:0]; - NSString *filePath = [cachePath stringByAppendingPathComponent:@"/Keybase/keybase.app.serverConfig"]; - NSError *err; - NSString *val = [NSString stringWithContentsOfFile:filePath - encoding:NSUTF8StringEncoding - error:&err]; - if (err != nil || val == nil) { - return @""; - } - return val; -} - -- (NSString *)setupGuiConfig { - NSString *filePath = [[[FsPathsHolder sharedFsPathsHolder] fsPaths][@"sharedHome"] - stringByAppendingPathComponent: @"/Library/Application Support/Keybase/gui_config.json"]; - NSError *err; - NSString *val = [NSString stringWithContentsOfFile:filePath encoding:NSUTF8StringEncoding error:&err]; - if (err != nil || val == nil) { - return @""; - } - return val; -} - - - (NSDictionary *)getConstants { - return @{}; - } - RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getTypedConstants) { - NSString *serverConfig = [self setupServerConfig]; - NSString *guiConfig = [self setupGuiConfig]; - - NSString *appVersionString = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; - if (appVersionString == nil) { - appVersionString = @""; - } - NSString *appBuildString = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]; - if (appBuildString == nil) { - appBuildString = @""; - } - NSLocale *currentLocale = [NSLocale currentLocale]; - NSString *cacheDir = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject]; - NSString *downloadDir = [NSSearchPathForDirectoriesInDomains(NSDownloadsDirectory, NSUserDomainMask, YES) firstObject]; - - NSString *kbVersion = KeybaseVersion(); - if (kbVersion == nil) { - kbVersion = @""; - } - return @{ - @"androidIsDeviceSecure" : @NO, - @"androidIsTestDevice" : @NO, - @"appVersionCode" : appBuildString, - @"appVersionName" : appVersionString, - @"darkModeSupported" : @YES, - @"fsCacheDir" : cacheDir, - @"fsDownloadDir" : downloadDir, - @"guiConfig" : guiConfig, - @"serverConfig" : serverConfig, - @"uses24HourClock" : @([self uses24HourClockForLocale:currentLocale]), - @"version" : kbVersion - }; + return kbConstants(); } RCT_EXPORT_METHOD(shareListenersRegistered) { @@ -259,7 +274,9 @@ - (NSDictionary *)getConstants { RCT_EXPORT_METHOD(engineReset) { NSError *error = nil; KeybaseReset(&error); - [self sendEventWithName:metaEventName body:metaEventEngineReset]; + if ([self canEmit]) { + [self emitOnMetaEvent:metaEventEngineReset]; + } if (error) { NSLog(@"Error in reset: %@", error); } @@ -465,8 +482,9 @@ - (NSDictionary *)getConstants { + (void)setDeviceToken:(NSString *)token { kbStoredDeviceToken = token; dispatch_async(dispatch_get_main_queue(), ^{ - if (kbSharedInstance && token) { - [kbSharedInstance sendEventWithName:@"onPushToken" body:@{@"token": token}]; + Kb *instance = kbSharedInstance; + if (instance && token && [instance canEmit]) { + [instance emitOnPushToken:token]; } }); } @@ -476,38 +494,25 @@ + (void)setInitialNotification:(NSDictionary *)notification { } + (void)emitPushNotification:(NSDictionary *)notification { - if (kbSharedInstance) { - [kbSharedInstance sendEventWithName:@"onPushNotification" body:notification]; + Kb *instance = kbSharedInstance; + if (instance && [instance canEmit]) { + [instance emitOnPushNotification:notification]; NSLog(@"Kb.emitPushNotification: sent event 'onPushNotification' to JS"); } else { - NSLog(@"Kb.emitPushNotification: WARNING - kbSharedInstance is nil, event not sent"); + NSLog(@"Kb.emitPushNotification: WARNING - module not ready, event not sent"); } } - (void)handleHardwareKeyPressed:(NSNotification *)notification { NSString *keyName = notification.userInfo[@"pressedKey"]; - if (keyName) { - NSDictionary *event = @{@"pressedKey": keyName}; - [self sendEventWithName:@"hardwareKeyPressed" body:event]; + if (keyName && [self canEmit]) { + [self emitOnHardwareKeyPressed:keyName]; } } -RCT_EXPORT_METHOD(keyPressed:(NSString *)keyName) { - NSDictionary *event = @{@"pressedKey": keyName}; - [self sendEventWithName:@"hardwareKeyPressed" body:event]; -} - -- (NSNumber *)androidCheckPushPermissions {return @-1;} -- (NSNumber *)androidRequestPushPermissions {return @-1;} -- (NSNumber *)androidShare:(NSString *)text mimeType:(NSString *)mimeType {return @-1;} -- (NSNumber *)androidShareText:(NSString *)text mimeType:(NSString *)mimeType {return @-1;} -- (NSString *)androidGetRegistrationToken {return @"";} +// Android-only spec methods; stubs satisfy the NativeKbSpec protocol - (void)androidAddCompleteDownload:(JS::NativeKb::SpecAndroidAddCompleteDownloadO &)o resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {} - (void)androidAppColorSchemeChanged:(NSString *)mode {} -- (void)androidCheckPushPermissions:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {} -- (void)androidGetRegistrationToken:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {} -- (void)androidRequestPushPermissions:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject{} -- (void)androidSetApplicationIconBadgeNumber:(double)n {} - (void)androidShare:(NSString *)text mimeType:(NSString *)mimeType resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {} - (void)androidShareText:(NSString *)text mimeType:(NSString *)mimeType resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {} @end diff --git a/rnmodules/react-native-kb/src/NativeKb.ts b/rnmodules/react-native-kb/src/NativeKb.ts index c560898004b9..47dd98ad02c5 100644 --- a/rnmodules/react-native-kb/src/NativeKb.ts +++ b/rnmodules/react-native-kb/src/NativeKb.ts @@ -1,8 +1,13 @@ import {TurboModuleRegistry, type TurboModule} from 'react-native' +import type {EventEmitter, UnsafeObject} from 'react-native/Libraries/Types/CodegenTypes' export interface Spec extends TurboModule { - addListener: (eventType: string) => void - removeListeners: (count: number) => void + readonly onMetaEvent: EventEmitter + readonly onHardwareKeyPressed: EventEmitter + readonly onPasteImage: EventEmitter> + readonly onPushNotification: EventEmitter + readonly onPushToken: EventEmitter + readonly onShareData: EventEmitter<{text?: string; localPaths?: Array}> getTypedConstants(): { androidIsDeviceSecure: boolean androidIsTestDevice: boolean diff --git a/rnmodules/react-native-kb/src/index.tsx b/rnmodules/react-native-kb/src/index.tsx index d460a717a7f0..93dd558cd7ba 100644 --- a/rnmodules/react-native-kb/src/index.tsx +++ b/rnmodules/react-native-kb/src/index.tsx @@ -1,4 +1,5 @@ -import {Platform, NativeEventEmitter, type EmitterSubscription} from 'react-native' +import {Platform} from 'react-native' +import type {EventSubscription} from 'react-native' import KbNative from './NativeKb' const Kb = KbNative @@ -83,11 +84,10 @@ export const addNotificationRequest = (config: {body: string; id: string}): Prom } // Hardware keyboard events -const hwKeyPressedListeners: Array = [] +const hwKeyPressedListeners: Array = [] export const onHWKeyPressed = (callback: (event: {pressedKey: string}) => void): void => { - const emitter = getNativeEmitter() - const listener = emitter.addListener('hardwareKeyPressed', callback) + const listener = Kb.onHardwareKeyPressed(pressedKey => callback({pressedKey})) hwKeyPressedListeners.push(listener) } @@ -101,10 +101,7 @@ let pasteImageListenerCount = 0 export const registerPasteImage = (callback: (uris: Array) => void): (() => void) => { if (Platform.OS !== 'ios') return () => {} - const emitter = getNativeEmitter() - const listener = emitter.addListener('onPasteImage', (event: {uris: Array}) => { - callback(event.uris) - }) + const listener = Kb.onPasteImage(uris => callback(uris)) pasteImageListenerCount++ Kb.setEnablePasteImage(true) return () => { @@ -114,6 +111,27 @@ export const registerPasteImage = (callback: (uris: Array) => void): (() } } +// Engine meta events (e.g. 'kb-engine-reset') +export const onMetaEvent = (callback: (payload: string) => void): EventSubscription => { + return Kb.onMetaEvent(callback) +} + +// Push events +export const onPushNotification = (callback: (notification: object) => void): EventSubscription => { + return Kb.onPushNotification(n => callback(n)) +} + +export const onPushToken = (callback: (token: string) => void): EventSubscription => { + return Kb.onPushToken(callback) +} + +// Android share-into-app intents +export const onShareData = ( + callback: (evt: {text?: string; localPaths?: Array}) => void +): EventSubscription => { + return Kb.onShareData(callback) +} + export const engineReset = (): void => { return Kb.engineReset() } @@ -128,12 +146,6 @@ export const clearLocalLogs = (): Promise => { return Kb.clearLocalLogs() } -let nativeEmitter: NativeEventEmitter | undefined -export const getNativeEmitter = () => { - nativeEmitter ??= new NativeEventEmitter(Kb as any) - return nativeEmitter -} - const KBC = Kb.getTypedConstants() export const androidIsDeviceSecure: boolean = KBC.androidIsDeviceSecure export const androidIsTestDevice: boolean = KBC.androidIsTestDevice diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt index 840bbe3a74d5..93f0c0045992 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt @@ -288,9 +288,7 @@ class MainActivity : ReactActivity() { handledIntentHash = intentHash NativeLogger.info("MainActivity.handleIntent processing notification: $intentHash") - // If there are any other bundle sources we care about, emit them here - rc.emitDeviceEvent("initialIntentFromNotification", Arguments.fromBundle(bundleFromNotification)) - rc.emitDeviceEvent("onPushNotification", Arguments.fromBundle(bundleFromNotification)) + KbModule.emitPushNotification(bundleFromNotification) } intent.removeExtra("notification") @@ -314,10 +312,10 @@ class MainActivity : ReactActivity() { if (isTextMime && textPayload.isNotEmpty()) { // Text-type intent (e.g. URL from Chrome): prefer text over any preview images - emitShareText(rc, text ?: textPayload) + emitShareText(text ?: textPayload) } else if (uris.isEmpty()) { if (textPayload.isNotEmpty()) { - emitShareText(rc, textPayload) + emitShareText(textPayload) } } else { // Copying out of the content providers can be slow for big files; don't @@ -331,12 +329,12 @@ class MainActivity : ReactActivity() { } } if (filePaths.isNotEmpty()) { - emitShareFiles(rc, filePaths) + emitShareFiles(filePaths) } else if (textPayload.isNotEmpty()) { // Fallback: non-text MIME but no files resolved, send text - emitShareText(rc, textPayload) + emitShareText(textPayload) } else { - emitShareFiles(rc, emptyList()) + emitShareFiles(emptyList()) } }.start() } @@ -346,20 +344,20 @@ class MainActivity : ReactActivity() { return true } - private fun emitShareText(rc: ReactContext, text: String) { + private fun emitShareText(text: String) { val args = Arguments.createMap() args.putString("text", text) - rc.emitDeviceEvent("onShareData", args) + KbModule.emitShareData(args) } - private fun emitShareFiles(rc: ReactContext, paths: List) { + private fun emitShareFiles(paths: List) { val args = Arguments.createMap() val lPaths = Arguments.createArray() for (path in paths) { lPaths.pushString(path) } args.putArray("localPaths", lPaths) - rc.emitDeviceEvent("onShareData", args) + KbModule.emitShareData(args) } override fun getMainComponentName(): String = "Keybase" diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index ae59e4e0d555..71bf95c6b461 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -6,14 +6,13 @@ import * as React from 'react' import Main from './main' import {KeyboardProvider} from 'react-native-keyboard-controller' import Animated, {ReducedMotionConfig, ReduceMotion} from 'react-native-reanimated' -import {AppRegistry, AppState, Appearance, Keyboard, Platform} from 'react-native' +import {AppRegistry, AppState, Appearance, Platform} from 'react-native' import {PortalProvider} from '@/common-adapters/portal.native' import {SafeAreaProvider, initialWindowMetrics} from 'react-native-safe-area-context' import {makeEngine} from '../engine' import {GestureHandlerRootView} from 'react-native-gesture-handler' import {enableFreeze} from 'react-native-screens' import {Image as ExpoImage} from 'expo-image' -import {setKeyboardUp} from '@/styles/keyboard-state' import {setServiceDecoration} from '@/common-adapters/markdown/react' import ServiceDecoration from '@/common-adapters/markdown/service-decoration' import {useUnmountAll} from '@/util/debug-react' @@ -88,33 +87,9 @@ const useDarkHookup = () => { }, [setSystemDarkMode, setMobileAppState]) } -const useKeyboardHookup = () => { - React.useEffect(() => { - const kbSubWS = Keyboard.addListener('keyboardWillShow', () => { - setKeyboardUp(true) - }) - const kbSubDS = Keyboard.addListener('keyboardDidShow', () => { - setKeyboardUp(true) - }) - const kbSubWH = Keyboard.addListener('keyboardWillHide', () => { - setKeyboardUp(false) - }) - const kbSubDH = Keyboard.addListener('keyboardDidHide', () => { - setKeyboardUp(false) - }) - return () => { - kbSubWS.remove() - kbSubDS.remove() - kbSubWH.remove() - kbSubDH.remove() - } - }, []) -} - const StoreHelper = (p: {children: React.ReactNode}): React.ReactNode => { const {children} = p useDarkHookup() - useKeyboardHookup() return children } diff --git a/shared/chat/blocking/block-buttons-state.tsx b/shared/chat/blocking/block-buttons-state.tsx index a8487a41a59c..fab3b76bd0e6 100644 --- a/shared/chat/blocking/block-buttons-state.tsx +++ b/shared/chat/blocking/block-buttons-state.tsx @@ -28,11 +28,13 @@ const gregorItemsToBlockButtons = ( type Store = T.Immutable<{ blockButtonsMap: ReadonlyMap + loadGeneration: number loaded: boolean }> const makeInitialStore = (): Store => ({ blockButtonsMap: new Map(), + loadGeneration: 0, loaded: false, }) @@ -46,8 +48,11 @@ type State = Store & { } } -let loadPromise: Promise | undefined -let loadGeneration = 0 +// Promises can't live in immer-managed state, so this stays module-level. It's +// paired with the store's loadGeneration: resetState clears this AND bumps the +// generation so a load in flight at reset time can't win the race and write +// into the fresh state after it resolves. +let activeLoadPromise: Promise | undefined export const useBlockButtonsState = Z.createZustand('block-buttons', (set, get) => { const setFromGregorItems: State['dispatch']['updateFromGregorItems'] = items => { @@ -59,39 +64,41 @@ export const useBlockButtonsState = Z.createZustand('block-buttons', (set const dispatch: State['dispatch'] = { load: () => { - if (get().loaded || loadPromise) { + if (get().loaded || activeLoadPromise) { return } - const generation = loadGeneration + const generation = get().loadGeneration const request = (async () => { try { const state = await T.RPCGen.gregorGetStateRpcPromise() - if (generation === loadGeneration) { + if (generation === get().loadGeneration) { setFromGregorItems(state.items) } } catch (error) { logger.warn('Failed to load block button state', error) } })() - loadPromise = request + activeLoadPromise = request ignorePromise( request.finally(() => { - if (loadPromise === request) { - loadPromise = undefined + if (activeLoadPromise === request) { + activeLoadPromise = undefined } }) ) }, resetState: () => { - loadGeneration++ - loadPromise = undefined + activeLoadPromise = undefined set(s => ({ ...makeInitialStore(), dispatch: s.dispatch, + loadGeneration: s.loadGeneration + 1, })) }, updateFromGregorItems: items => { - loadGeneration++ + set(s => { + s.loadGeneration++ + }) setFromGregorItems(items) }, } diff --git a/shared/chat/blocking/invitation-to-block.tsx b/shared/chat/blocking/invitation-to-block.tsx index 92ba6df5a805..d7c228238557 100644 --- a/shared/chat/blocking/invitation-to-block.tsx +++ b/shared/chat/blocking/invitation-to-block.tsx @@ -11,7 +11,9 @@ import {useBlockButtonsInfo} from './block-buttons-state' import { useConversationThreadID, useConversationThreadSelector, + useThreadMeta, } from '../conversation/thread-context' +import {useConversationParticipants} from '../conversation/data-hooks' const dismissBlockButtons = (teamID: T.RPCGen.TeamID) => { const f = async () => { @@ -29,17 +31,16 @@ const dismissBlockButtons = (teamID: T.RPCGen.TeamID) => { const BlockButtons = () => { const navigateAppend = C.Router2.navigateAppend const conversationIDKey = useConversationThreadID() - const {messageMap, messageOrdinals, participantInfo, team, teamID, tlfname} = - useConversationThreadSelector( - C.useShallow(s => ({ - messageMap: s.messageMap, - messageOrdinals: s.messageOrdinals, - participantInfo: s.participants, - team: s.meta.teamname, - teamID: s.meta.teamID, - tlfname: s.meta.tlfname, - })) - ) + const {messageMap, messageOrdinals} = useConversationThreadSelector( + C.useShallow(s => ({ + messageMap: s.messageMap, + messageOrdinals: s.messageOrdinals, + })) + ) + const {team, teamID, tlfname} = useThreadMeta( + C.useShallow(m => ({team: m.teamname, teamID: m.teamID, tlfname: m.tlfname})) + ) + const participantInfo = useConversationParticipants(conversationIDKey) const blockButtonInfo = useBlockButtonsInfo(teamID) const currentUser = useCurrentUserState(s => s.username) const hasOwnMessage = diff --git a/shared/chat/conversation/attachment-fullscreen/index.tsx b/shared/chat/conversation/attachment-fullscreen/index.tsx index 6b233ff19d18..4fe36fa1d25c 100644 --- a/shared/chat/conversation/attachment-fullscreen/index.tsx +++ b/shared/chat/conversation/attachment-fullscreen/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import * as Kb from '@/common-adapters' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {useMessagePopup} from '../messages/message-popup' import logger from '@/logger' @@ -100,7 +101,15 @@ const DesktopFullscreen = (p: Props) => { } as StyleOverride return ( - + // collapsable={false}: Fabric view-flattening would drop this Box2 (and its + // testID) from the native tree, breaking e2e lookup on iOS + {title} @@ -354,12 +363,16 @@ const NativeFullscreen = (p: Props) => { } return ( + // collapsable={false}: Fabric view-flattening would drop this Box2 (and its + // testID) from the native tree, breaking e2e lookup on iOS {spinner} diff --git a/shared/chat/conversation/bot/install.tsx b/shared/chat/conversation/bot/install.tsx index c1ebc0a9eb96..3c8136f05c56 100644 --- a/shared/chat/conversation/bot/install.tsx +++ b/shared/chat/conversation/bot/install.tsx @@ -11,10 +11,11 @@ import {openURL} from '@/util/misc' import * as T from '@/constants/types' import {useAllChannelMetas} from '@/teams/common/channel-hooks' import {useFeaturedBot} from '@/util/featured-bots' +import {useRPCLoad} from '@/util/use-rpc-load' import {RPCError} from '@/util/errors' import logger from '@/logger' import {useBotSettings} from './settings' -import {getInboxConversationMeta, metasReceived, participantInfoReceived} from '@/chat/inbox/metadata' +import {metasReceived, participantInfoReceived} from '@/chat/inbox/metadata' import {useConversationMeta} from '../data-hooks' const RestrictedItem = '---RESTRICTED---' @@ -42,8 +43,7 @@ export const useRefreshBotMembershipOnSuccess = ( preview => { participantInfoReceived( conversationIDKey, - ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []), - getInboxConversationMeta(conversationIDKey) + ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []) ) onSuccess() }, @@ -66,103 +66,48 @@ export const useRefreshBotMembershipOnSuccess = ( export const useBotConversationIDKey = (inConvIDKey?: T.Chat.ConversationIDKey, teamID?: T.Teams.TeamID) => { const cleanInConvIDKey = T.Chat.isValidConversationIDKey(inConvIDKey ?? '') ? inConvIDKey : undefined - const [generalConversation, setGeneralConversation] = React.useState< - | { - conversationIDKey: T.Chat.ConversationIDKey - teamID: T.Teams.TeamID - } - | undefined - >() - const findGeneralConvIDFromTeamID = C.useRPC(T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise) - const requestIDRef = React.useRef(0) - const conversationIDKey = - cleanInConvIDKey ?? - (generalConversation && generalConversation.teamID === teamID - ? generalConversation.conversationIDKey - : undefined) - - React.useEffect(() => { - requestIDRef.current += 1 - if (cleanInConvIDKey || !teamID) { - return - } - const requestID = requestIDRef.current - findGeneralConvIDFromTeamID( - [{teamID}], - conv => { - if (requestIDRef.current !== requestID) { - return - } + const {data: generalConvIDKey} = useRPCLoad( + T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise, + [{teamID: teamID ?? T.Teams.noTeamID}], + { + enabled: !cleanInConvIDKey && !!teamID, + key: teamID ?? T.Teams.noTeamID, + map: conv => { const meta = Meta.inboxUIItemToConversationMeta(conv) if (!meta) { - return + return undefined } metasReceived([meta]) - setGeneralConversation({conversationIDKey: meta.conversationIDKey, teamID}) + return meta.conversationIDKey }, - () => {} - ) - return () => { - if (requestIDRef.current === requestID) { - requestIDRef.current += 1 - } } - }, [cleanInConvIDKey, findGeneralConvIDFromTeamID, teamID]) - return conversationIDKey + ) + return cleanInConvIDKey ?? generalConvIDKey } export const useBotTeamRole = ( conversationIDKey: T.Chat.ConversationIDKey | undefined, botUsername: string ) => { - const [loaded, setLoaded] = React.useState< - | { - botUsername: string - conversationIDKey: T.Chat.ConversationIDKey - teamRole?: T.Teams.TeamRoleType - } - | undefined - >() - const loadBotTeamRole = C.useRPC(T.RPCChat.localGetTeamRoleInConversationRpcPromise) - const requestIDRef = React.useRef(0) - - React.useEffect(() => { - requestIDRef.current += 1 - if (!conversationIDKey) { - return undefined - } - const requestID = requestIDRef.current - loadBotTeamRole( - [{convID: T.Chat.keyToConversationID(conversationIDKey), username: botUsername}], - role => { - if (requestIDRef.current !== requestID) { - return - } + const {data: teamRole} = useRPCLoad( + T.RPCChat.localGetTeamRoleInConversationRpcPromise, + [ + { + convID: conversationIDKey ? T.Chat.keyToConversationID(conversationIDKey) : new Uint8Array(), + username: botUsername, + }, + ], + { + enabled: !!conversationIDKey, + key: `${conversationIDKey ?? ''}:${botUsername}`, + map: role => { const teamRole = Teams.teamRoleByEnum[role] - setLoaded({ - botUsername, - conversationIDKey, - teamRole: teamRole === 'none' ? undefined : teamRole, - }) + return teamRole === 'none' ? undefined : teamRole }, - error => { - if (requestIDRef.current !== requestID) { - return - } - logger.info(`useBotTeamRole: failed to refresh bot team role: ${error.message}`) - setLoaded({botUsername, conversationIDKey}) - } - ) - return () => { - if (requestIDRef.current === requestID) { - requestIDRef.current += 1 - } + onError: error => logger.info(`useBotTeamRole: failed to refresh bot team role: ${error.message}`), } - }, [botUsername, conversationIDKey, loadBotTeamRole]) - - return loaded && loaded.conversationIDKey === conversationIDKey && loaded.botUsername === botUsername - ? loaded.teamRole - : undefined + ) + return conversationIDKey ? teamRole : undefined } type LoaderProps = { @@ -673,9 +618,7 @@ const InstallBotPopup = (props: Props) => { ) : enabled ? ( content ) : ( - - - + ) return ( <> diff --git a/shared/chat/conversation/bottom-banner.tsx b/shared/chat/conversation/bottom-banner.tsx index 09685440215d..1d8e16b66786 100644 --- a/shared/chat/conversation/bottom-banner.tsx +++ b/shared/chat/conversation/bottom-banner.tsx @@ -8,10 +8,8 @@ import {assertionToDisplay} from '@/common-adapters/usernames' import {useUsersState} from '@/stores/users' import {useFollowerState} from '@/stores/followers' import {showShareActionSheet} from '@/util/platform-specific' -import { - useConversationThreadID, - useConversationThreadSelector, -} from './thread-context' +import {useConversationThreadID, useThreadMeta} from './thread-context' +import {useConversationParticipants} from './data-hooks' type Store = T.Immutable<{ inviteBannerDismissed: Set @@ -48,7 +46,8 @@ const installMessage = `I sent you encrypted messages on Keybase. You can instal const Invite = (props: {onDismiss: () => void}) => { const linkUrlProps = Kb.useClickURL('https://keybase.io/app') - const participantInfo = useConversationThreadSelector(s => s.participants) + const conversationIDKey = useConversationThreadID() + const participantInfo = useConversationParticipants(conversationIDKey) const participantInfoAll = participantInfo.all const users = participantInfoAll.filter(p => p.includes('@')) @@ -145,9 +144,8 @@ const BannerContainerInner = function BannerContainerInner(props: { dismissed: s.inviteBannerDismissed.has(conversationIDKey), })) ) - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({meta: s.meta, participantInfo: s.participants})) - ) + const meta = useThreadMeta(C.useShallow(m => ({isEmpty: m.isEmpty, teamType: m.teamType}))) + const participantInfo = useConversationParticipants(conversationIDKey) if (meta.teamType !== 'adhoc') { return null } diff --git a/shared/chat/conversation/center-context.tsx b/shared/chat/conversation/center-context.tsx index aed1cb4c0d29..dace4565f4bc 100644 --- a/shared/chat/conversation/center-context.tsx +++ b/shared/chat/conversation/center-context.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import * as T from '@/constants/types' import {clearThreadHighlightMessageID} from '@/constants/router' +import {produce} from 'immer' import {useChatThreadRouteParams} from './thread-search-route' import {useThreadLoadStatusOptionsGetter} from './thread-load-status-context' import { @@ -21,10 +22,7 @@ type CenterStateContextType = { } type CenterActionsContextType = { - centerOnMessage: ( - messageID: T.Chat.MessageID, - highlightMode: T.Chat.CenterOrdinalHighlightMode - ) => void + centerOnMessage: (messageID: T.Chat.MessageID, highlightMode: T.Chat.CenterOrdinalHighlightMode) => void clearCenter: () => void jumpToRecent: () => void } @@ -53,19 +51,18 @@ CenterActionsContext.displayName = 'ConversationCenterActionsContext' export const useConversationCenter = () => React.useContext(CenterStateContext) export const useConversationCenterActions = () => React.useContext(CenterActionsContext) -const stateForThreadSearchVisible = (state: CenterState, threadSearchVisible: boolean): CenterState => { - if (state.threadSearchVisible === threadSearchVisible) { - return state - } - return { - center: threadSearchVisible - ? state.center - ? {...state.center, highlightMode: 'none'} - : undefined - : undefined, - threadSearchVisible, - } -} +const stateForThreadSearchVisible = (state: CenterState, threadSearchVisible: boolean): CenterState => + produce(state, draft => { + if (draft.threadSearchVisible === threadSearchVisible) { + return + } + draft.threadSearchVisible = threadSearchVisible + if (threadSearchVisible && draft.center) { + draft.center.highlightMode = 'none' + } else { + draft.center = undefined + } + }) export const ConversationCenterProvider = function ConversationCenterProvider(p: { children: React.ReactNode @@ -91,23 +88,22 @@ export const ConversationCenterProvider = function ConversationCenterProvider(p: highlightMode: T.Chat.CenterOrdinalHighlightMode ) => { const ordinal = T.Chat.numberToOrdinal(T.Chat.messageIDToNumber(messageID)) - setCenterState(state => ({ - ...stateForThreadSearchVisible(state, threadSearchVisible), - center: {highlightMode, ordinal}, - })) + setCenterState(state => + produce(stateForThreadSearchVisible(state, threadSearchVisible), draft => { + draft.center = {highlightMode, ordinal} + }) + ) } const clearCenter = () => { - setCenterState(state => { - const current = stateForThreadSearchVisible(state, threadSearchVisible) - return current.center ? {...current, center: undefined} : current - }) + setCenterState(state => + produce(stateForThreadSearchVisible(state, threadSearchVisible), draft => { + draft.center = undefined + }) + ) } - const centerOnMessage = ( - messageID: T.Chat.MessageID, - highlightMode: T.Chat.CenterOrdinalHighlightMode - ) => { + const centerOnMessage = (messageID: T.Chat.MessageID, highlightMode: T.Chat.CenterOrdinalHighlightMode) => { setCenterForMessage(messageID, highlightMode) loadMessagesCentered(messageID, highlightMode, { ...getThreadLoadStatusOptions(), diff --git a/shared/chat/conversation/container.tsx b/shared/chat/conversation/container.tsx index 6fa4e3d949ce..72525b379156 100644 --- a/shared/chat/conversation/container.tsx +++ b/shared/chat/conversation/container.tsx @@ -8,7 +8,7 @@ import Rekey from './rekey/container' import type {ThreadSearchRouteProps} from './thread-search-route' import type * as T from '@/constants/types' import {BadgeHeaderUpdater} from './header-area' -import {LiveConversationThreadProvider, useConversationThreadID, useConversationThreadSelector} from './thread-context' +import {LiveConversationThreadProvider, useConversationThreadID, useThreadMeta} from './thread-context' type Props = ThreadSearchRouteProps & { conversationIDKey?: T.Chat.ConversationIDKey @@ -26,7 +26,13 @@ const Conversation = function Conversation(props: Props) { const ConversationInner = function ConversationInner() { const conversationIDKey = useConversationThreadID() - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta( + C.useShallow(m => ({ + membershipType: m.membershipType, + rekeyers: m.rekeyers, + trustedState: m.trustedState, + })) + ) const type = (() => { switch (conversationIDKey) { case Chat.noConversationIDKey: diff --git a/shared/chat/conversation/data-hooks.tsx b/shared/chat/conversation/data-hooks.tsx index 1de090cc4848..6f99f34061cc 100644 --- a/shared/chat/conversation/data-hooks.tsx +++ b/shared/chat/conversation/data-hooks.tsx @@ -1,5 +1,4 @@ import * as C from '@/constants' -import * as Common from '@/constants/chat/common' import * as Message from '@/constants/chat/message' import * as Meta from '@/constants/chat/meta' import * as React from 'react' @@ -9,13 +8,13 @@ import {useEngineActionListener} from '@/engine/action-listener' import {ignorePromise} from '@/constants/utils' import {useCurrentUserState} from '@/stores/current-user' import {useConfigState} from '@/stores/config' -import {uint8ArrayToString} from '@/util/uint8array' import logger from '@/logger' import {loadThreadNonblock, markConversationRead} from './thread-rpc' import {setConversationOrangeLine} from './orange-line-context' +import {getExplodingModeFromGregorItems} from './thread-load' const emptyConversationMeta = Meta.makeConversationMeta() -const emptyParticipantInfo: T.Chat.ParticipantInfo = { +export const emptyParticipantInfo: T.Chat.ParticipantInfo = { all: [], contactName: new Map(), name: [], @@ -129,28 +128,6 @@ export const useConversationMeta = (conversationIDKey: T.Chat.ConversationIDKey) export const useConversationParticipants = (conversationIDKey: T.Chat.ConversationIDKey) => useConversationMetadata(conversationIDKey).participants -const getExplodingModeFromGregorItems = ( - conversationIDKey: T.Chat.ConversationIDKey, - items: ReadonlyArray<{item: T.RPCGen.Gregor1.Item}> -) => { - const explodingItems = items.filter(i => i.item.category.startsWith(Common.explodingModeGregorKeyPrefix)) - if (!explodingItems.length) { - return 0 - } - const category = `${Common.explodingModeGregorKeyPrefix}${conversationIDKey}` - const item = explodingItems.find(i => i.item.category === category) - if (!item) { - return undefined - } - const secondsString = uint8ArrayToString(item.item.body) - const seconds = parseInt(secondsString, 10) - if (isNaN(seconds)) { - logger.warn(`Got dirty exploding mode ${secondsString} for category ${category}`) - return undefined - } - return seconds -} - export const useConversationExplodingMode = (conversationIDKey: T.Chat.ConversationIDKey) => useConfigState(state => getExplodingModeFromGregorItems(conversationIDKey, state.gregorPushState) ?? 0) @@ -161,31 +138,22 @@ const parseThreadMessages = (conversationIDKey: T.Chat.ConversationIDKey, thread if (!thread) { return emptyMessages } - try { - const {username, deviceName} = useCurrentUserState.getState() - let lastOrdinal = T.Chat.numberToOrdinal(0) - const getLastOrdinal = () => lastOrdinal - const uiMessages = JSON.parse(thread) as T.RPCChat.UIMessages - return (uiMessages.messages ?? []).reduce>((arr, uiMessage) => { - const message = Message.uiMessageToMessage( - conversationIDKey, - uiMessage, - username, - getLastOrdinal, - deviceName - ) - if (message) { - arr.push(message) - if (T.Chat.ordinalToNumber(message.ordinal) > T.Chat.ordinalToNumber(lastOrdinal)) { - lastOrdinal = message.ordinal - } + const {username, deviceName} = useCurrentUserState.getState() + let lastOrdinal = T.Chat.numberToOrdinal(0) + const getLastOrdinal = () => lastOrdinal + const {messages} = Message.parseUIMessagesJSON( + conversationIDKey, + thread, + username, + deviceName, + getLastOrdinal, + message => { + if (T.Chat.ordinalToNumber(message.ordinal) > T.Chat.ordinalToNumber(lastOrdinal)) { + lastOrdinal = message.ordinal } - return arr - }, []) - } catch (error) { - logger.warn(`parseThreadMessages: failed for ${conversationIDKey}: ${String(error)}`) - return emptyMessages - } + } + ) + return messages } const loadConversationMessagesAroundMessageID = async ( diff --git a/shared/chat/conversation/error.tsx b/shared/chat/conversation/error.tsx index 867be46841ff..d6e66d409e55 100644 --- a/shared/chat/conversation/error.tsx +++ b/shared/chat/conversation/error.tsx @@ -1,8 +1,8 @@ import * as Kb from '@/common-adapters' -import {useConversationThreadSelector} from './thread-context' +import {useThreadMeta} from './thread-context' const ConversationError = () => { - const text = useConversationThreadSelector(s => s.meta.snippet) ?? '' + const text = useThreadMeta(m => m.snippet) ?? '' return ( There was an error loading this conversation. diff --git a/shared/chat/conversation/giphy/index.tsx b/shared/chat/conversation/giphy/index.tsx index 6762cd2305c0..1a470ba60d17 100644 --- a/shared/chat/conversation/giphy/index.tsx +++ b/shared/chat/conversation/giphy/index.tsx @@ -123,9 +123,7 @@ const NativeGiphySearch = () => { mediaPlaybackRequiresUserAction={false} /> ) : ( - - - + )} ) diff --git a/shared/chat/conversation/header-area/index.tsx b/shared/chat/conversation/header-area/index.tsx index cc89274be533..f8aa45319248 100644 --- a/shared/chat/conversation/header-area/index.tsx +++ b/shared/chat/conversation/header-area/index.tsx @@ -15,6 +15,7 @@ import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' import {useConfigState} from '@/stores/config' import {navToProfile} from '@/constants/router' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {showConversationInfoPanel, toggleConversationThreadSearch} from '../thread-context' import {useConversationMetadata} from '../data-hooks' import {muteConversation} from '../status-actions' @@ -48,7 +49,7 @@ const HeaderAreaRight = (props: HeaderConversationProps) => { style={Kb.Styles.collapseStyles([styles.headerRight, {opacity: pendingWaiting ? 0 : 1}])} > - + ) } diff --git a/shared/chat/conversation/info-panel/add-to-channel.tsx b/shared/chat/conversation/info-panel/add-to-channel.tsx index 018a1b12265a..fe8f3e50a8bb 100644 --- a/shared/chat/conversation/info-panel/add-to-channel.tsx +++ b/shared/chat/conversation/info-panel/add-to-channel.tsx @@ -150,16 +150,14 @@ const AddToChannelInner = (props: Props & {conversationIDKey: T.Chat.Conversatio {isMobile ? null : ( - - - - + )} diff --git a/shared/chat/conversation/info-panel/bot.tsx b/shared/chat/conversation/info-panel/bot.tsx index 856d5215f800..bb282ab11d9c 100644 --- a/shared/chat/conversation/info-panel/bot.tsx +++ b/shared/chat/conversation/info-panel/bot.tsx @@ -4,12 +4,13 @@ import * as Teams from '@/constants/teams' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {getFeaturedSorted, useFeaturedBotPage} from '@/util/featured-bots' import {useUsersState} from '@/stores/users' import {useChatTeam, useChatTeamMembers} from '../team-hooks' import logger from '@/logger' import {useBotSettings} from '../bot/settings' -import {getInboxConversationMeta, participantInfoReceived} from '@/chat/inbox/metadata' +import {participantInfoReceived} from '@/chat/inbox/metadata' import {useConversationMetadata} from '../data-hooks' type AddToChannelProps = { @@ -74,8 +75,7 @@ const AddToChannel = (props: AddToChannelProps) => { preview => { participantInfoReceived( conversationIDKey, - ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []), - getInboxConversationMeta(conversationIDKey) + ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []) ) }, () => {} @@ -157,6 +157,7 @@ export const Bot = (props: BotProps) => { containerStyleOverride={styles.listItemContainer} onClick={() => onClick(botUsername)} type="Large" + testID={TestIDs.CHAT_BOT_ROW} firstItem={!!firstItem} icon={} hideHover={!!props.hideHover} @@ -237,8 +238,7 @@ const BotTab = (props: Props) => { preview => { participantInfoReceived( conversationIDKey, - ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []), - getInboxConversationMeta(conversationIDKey) + ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []) ) }, () => {} @@ -262,8 +262,7 @@ const BotTab = (props: Props) => { preview => { participantInfoReceived( conversationIDKey, - ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []), - getInboxConversationMeta(conversationIDKey) + ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []) ) }, () => {} diff --git a/shared/chat/conversation/info-panel/index.tsx b/shared/chat/conversation/info-panel/index.tsx index 0c61e0f8fc36..bda05022c2b2 100644 --- a/shared/chat/conversation/info-panel/index.tsx +++ b/shared/chat/conversation/info-panel/index.tsx @@ -1,5 +1,6 @@ import * as Chat from '@/constants/chat' import * as Kb from '@/common-adapters' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import * as Teams from '@/constants/teams' import * as React from 'react' import type * as T from '@/constants/types' @@ -63,7 +64,8 @@ const InfoPanelConnector = ({conversationIDKey: _conversationIDKey, tab}: Props) {title: 'members' as const}, {title: 'attachments' as const}, {title: 'bots' as const}, - ...(showSettings ? [{title: 'settings' as const}] : []), + // e2e: text taps on the tab label no-op on iOS, so the tab needs a testID + ...(showSettings ? [{testID: TestIDs.CHAT_INFO_PANEL_SETTINGS_TAB, title: 'settings' as const}] : []), ] } @@ -157,7 +159,13 @@ const InfoPanelConnector = ({conversationIDKey: _conversationIDKey, tab}: Props) ) } else { return ( - + {sectionList} ) diff --git a/shared/chat/conversation/input-area/container.tsx b/shared/chat/conversation/input-area/container.tsx index d6323fc991e4..5770f0af733f 100644 --- a/shared/chat/conversation/input-area/container.tsx +++ b/shared/chat/conversation/input-area/container.tsx @@ -5,16 +5,16 @@ import Normal from './normal' import Preview from './preview' import ThreadSearch from '../search' import {useThreadSearchRoute} from '../thread-search-route' -import {useConversationThreadID, useConversationThreadSelector} from '../thread-context' +import {useConversationThreadID, useThreadMeta} from '../thread-context' const InputAreaContainer = () => { const conversationIDKey = useConversationThreadID() const showThreadSearch = !!useThreadSearchRoute() - const {membershipType, resetParticipants, wasFinalizedBy} = useConversationThreadSelector( - C.useShallow(s => ({ - membershipType: s.meta.membershipType, - resetParticipants: s.meta.resetParticipants, - wasFinalizedBy: s.meta.wasFinalizedBy, + const {membershipType, resetParticipants, wasFinalizedBy} = useThreadMeta( + C.useShallow(m => ({ + membershipType: m.membershipType, + resetParticipants: m.resetParticipants, + wasFinalizedBy: m.wasFinalizedBy, })) ) diff --git a/shared/chat/conversation/input-area/input-state.test.tsx b/shared/chat/conversation/input-area/input-state.test.tsx index 80f6e4792f42..ba6abdef4184 100644 --- a/shared/chat/conversation/input-area/input-state.test.tsx +++ b/shared/chat/conversation/input-area/input-state.test.tsx @@ -17,20 +17,6 @@ jest.mock('@react-navigation/native', () => ({ useRoute: () => ({name: 'chatConversation', params: mockRouteParams}), })) -jest.mock('@/chat/inbox/rows-state', () => ({ - flushInboxRowUpdates: jest.fn(), - getInboxRowTrustedState: jest.fn(() => undefined), - queueInboxRowUpdate: jest.fn(), - setInboxRowTrustedState: jest.fn(), - syncInboxRowBadgeState: jest.fn(), - syncInboxRowsFromLayout: jest.fn(), - syncInboxRowsFromMetaAndParticipants: jest.fn(), - syncInboxRowsFromMetas: jest.fn(), - syncInboxRowsFromParticipantMap: jest.fn(), - syncInboxRowsFromParticipants: jest.fn(), - updateInboxRowTyping: jest.fn(), -})) - const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) const otherConvID = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8])) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 3f4a6ee80e10..1345f95228d1 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -12,7 +12,7 @@ import * as T from '@/constants/types' import {indefiniteArticle} from '@/util/string' import {infoPanelWidthTablet} from '../../info-panel/common' import {assertionToDisplay} from '@/common-adapters/usernames' -import {FocusContext, ScrollContext} from '@/chat/conversation/normal/context' +import {ThreadRefsContext} from '@/chat/conversation/normal/context' import type {RefType as InputRef} from './input.shared' import {useConversationCenter, useConversationCenterActions} from '../../center-context' import { @@ -21,7 +21,9 @@ import { useConversationThreadSelector, useConversationThreadSetExplodingMode, useConversationThreadToggleSearch, + useThreadMeta, } from '../../thread-context' +import {useConversationParticipants} from '../../data-hooks' import {useCurrentUserState} from '@/stores/current-user' import {useRoute} from '@react-navigation/native' import {metasReceived, unboxRows} from '@/chat/inbox/metadata' @@ -34,14 +36,15 @@ const useHintText = (p: { }) => { const {minWriterRole, isExploding, isEditing, cannotWrite} = p const username = useCurrentUserState(s => s.username) - const {channelname, participantInfoName, teamType, teamname} = useConversationThreadSelector( - C.useShallow(s => ({ - channelname: s.meta.channelname, - participantInfoName: s.participants.name, - teamType: s.meta.teamType, - teamname: s.meta.teamname, + const conversationIDKey = useConversationThreadID() + const {channelname, teamType, teamname} = useThreadMeta( + C.useShallow(m => ({ + channelname: m.channelname, + teamType: m.teamType, + teamname: m.teamname, })) ) + const participantInfoName = useConversationParticipants(conversationIDKey).name if (isMobile && isExploding) { return C.isLargeScreen ? `Write an exploding message` : 'Exploding message' } @@ -139,9 +142,8 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { ) const replyToMessage = useConversationThreadMessage(uiData.replyTo) const conversationIDKey = useConversationThreadID() - const {explodingMode, meta} = useConversationThreadSelector( - C.useShallow(s => ({explodingMode: s.explodingMode, meta: s.meta})) - ) + const explodingMode = useConversationThreadSelector(s => s.explodingMode) + const meta = useThreadMeta(m => m) const setExplodingModeRaw = useConversationThreadSetExplodingMode() const {cannotWrite, minWriterRole, tlfname} = meta const convoID = T.Chat.isValidConversationIDKey(conversationIDKey) @@ -181,7 +183,7 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { doInjectText(inputRef, text, focus) } - const {scrollToBottom} = React.useContext(ScrollContext) + const {scrollToBottom} = React.useContext(ThreadRefsContext) const onSubmit = (text: string) => { if (!text) return injectText('', true) @@ -205,7 +207,8 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { const updateDraftRaw = (text: string) => { // Immediately update local meta.draft so switching back to this thread // before the async unbox completes won't re-inject the old stale draft. - metasReceived([{...meta, draft: text}]) + // Merges from the current meta (same inbox version), so force past gating. + metasReceived([{...meta, draft: text}], undefined, {force: true}) const f = async () => { await T.RPCChat.localUpdateUnsentTextRpcPromise({ conversationID: convoID, @@ -274,7 +277,7 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { } }, [focusInputCounter, updateUnsentText, unsentText]) - const {setInputRef} = React.useContext(FocusContext) + const {setInputRef} = React.useContext(ThreadRefsContext) React.useEffect(() => { setInputRef(inputRef.current) }, [setInputRef]) diff --git a/shared/chat/conversation/input-area/normal/input.tsx b/shared/chat/conversation/input-area/normal/input.tsx index 66e4b724d2dc..bc468715d706 100644 --- a/shared/chat/conversation/input-area/normal/input.tsx +++ b/shared/chat/conversation/input-area/normal/input.tsx @@ -12,7 +12,7 @@ import type {PlatformInputProps as Props} from './input.shared' export type {Selection, RefType, TextInfo, PlatformInputProps} from './input.shared' import {formatDurationShort} from '@/util/timestamp' import {useSuggestors} from '../suggestors' -import {ScrollContext} from '@/chat/conversation/normal/context' +import {ThreadRefsContext} from '@/chat/conversation/normal/context' import {getTextStyle} from '@/common-adapters/text.styles' import {useColorScheme} from 'react-native' import {useConversationThreadID} from '../../thread-context' @@ -646,7 +646,7 @@ const useKeyboard = (p: UseKeyboardProps) => { const {onChangeText, onEditLastMessage, showReplyPreview} = p const lastText = React.useRef('') const setReplyTo = InputState.useConversationInputDispatch(s => s.setReplyTo) - const {scrollDown, scrollUp} = React.useContext(ScrollContext) + const {scrollDown, scrollUp} = React.useContext(ThreadRefsContext) const onCancelReply = () => { setReplyTo(ChatTypes.numberToOrdinal(0)) } @@ -1016,7 +1016,7 @@ const NativeButtons = function NativeButtons(p: NativeButtonsProps) { /> )} {explodingIcon} - + {!hasText && ( diff --git a/shared/chat/conversation/input-area/normal/set-explode-popup/hooks.tsx b/shared/chat/conversation/input-area/normal/set-explode-popup/hooks.tsx index c8ea1b1e788a..745b82c93035 100644 --- a/shared/chat/conversation/input-area/normal/set-explode-popup/hooks.tsx +++ b/shared/chat/conversation/input-area/normal/set-explode-popup/hooks.tsx @@ -1,7 +1,7 @@ import * as Chat from '@/constants/chat' import type * as T from '@/constants/types' import type {Props} from './index.shared' -import {useConversationThreadSelector} from '../../../thread-context' +import {useConversationThreadSelector, useThreadMeta} from '../../../thread-context' export type MessageExplodeDescription = { text: string @@ -32,7 +32,7 @@ const makeItems = (meta: T.Chat.ConversationMeta) => { export default (p: Props) => { const {setExplodingMode, onHidden, visible, attachTo, onAfterSelect} = p - const _meta = useConversationThreadSelector(s => s.meta) + const _meta = useThreadMeta(m => m) const selected = useConversationThreadSelector(s => s.explodingMode) const onSelect = (seconds: number) => { setTimeout(() => { diff --git a/shared/chat/conversation/input-area/preview.tsx b/shared/chat/conversation/input-area/preview.tsx index 1a98428c0341..95839f8fb7ee 100644 --- a/shared/chat/conversation/input-area/preview.tsx +++ b/shared/chat/conversation/input-area/preview.tsx @@ -2,11 +2,11 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' import {joinConversation} from '../status-actions' -import {useConversationThreadID, useConversationThreadSelector} from '../thread-context' +import {useConversationThreadID, useThreadMeta} from '../thread-context' const Preview = () => { const conversationIDKey = useConversationThreadID() - const channelname = useConversationThreadSelector(s => s.meta.channelname) + const channelname = useThreadMeta(m => m.channelname) const [clicked, setClicked] = React.useState(undefined) const _onClick = (join: boolean) => { diff --git a/shared/chat/conversation/input-area/suggestors/channels.test.tsx b/shared/chat/conversation/input-area/suggestors/channels.test.tsx index 0f0348e6bb5d..6e4b2247e3a9 100644 --- a/shared/chat/conversation/input-area/suggestors/channels.test.tsx +++ b/shared/chat/conversation/input-area/suggestors/channels.test.tsx @@ -21,20 +21,6 @@ jest.mock('./common', () => ({ }, })) -jest.mock('@/chat/inbox/rows-state', () => ({ - flushInboxRowUpdates: jest.fn(), - getInboxRowTrustedState: jest.fn(() => undefined), - queueInboxRowUpdate: jest.fn(), - setInboxRowTrustedState: jest.fn(), - syncInboxRowBadgeState: jest.fn(), - syncInboxRowsFromLayout: jest.fn(), - syncInboxRowsFromMetaAndParticipants: jest.fn(), - syncInboxRowsFromMetas: jest.fn(), - syncInboxRowsFromParticipantMap: jest.fn(), - syncInboxRowsFromParticipants: jest.fn(), - updateInboxRowTyping: jest.fn(), -})) - const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) const flushPromises = async () => { @@ -83,15 +69,11 @@ beforeEach(() => { teamType: 'adhoc', } metasReceived([meta]) - participantInfoReceived( - convID, - { - all: ['alice', 'bob', 'carol'], - contactName: new Map(), - name: ['alice', 'bob', 'carol'], - }, - meta - ) + participantInfoReceived(convID, { + all: ['alice', 'bob', 'carol'], + contactName: new Map(), + name: ['alice', 'bob', 'carol'], + }) useInboxLayoutState.getState().dispatch.updateLayout( JSON.stringify({ bigTeams: [ diff --git a/shared/chat/conversation/input-area/suggestors/suggestion-list.tsx b/shared/chat/conversation/input-area/suggestors/suggestion-list.tsx index b1f0dafb6cbf..35da9c0cca43 100644 --- a/shared/chat/conversation/input-area/suggestors/suggestion-list.tsx +++ b/shared/chat/conversation/input-area/suggestors/suggestion-list.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import noop from 'lodash/noop' import {BotCommandUpdateStatus} from './shared' @@ -50,6 +51,7 @@ const SuggestionList = (props: Props) => { direction="vertical" fullWidth={true} style={Kb.Styles.collapseStyles([desktopStyles.listContainer, {height: listHeight}, props.style])} + testID={TestIDs.CHAT_SUGGESTION_LIST} > (props: Props) => { direction="vertical" fullWidth={true} style={Kb.Styles.collapseStyles([nativeStyles.listContainer, props.style])} + testID={TestIDs.CHAT_SUGGESTION_LIST} > { void listRef.current?.scrollToEnd({animated: false}) }, []) @@ -219,7 +218,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { }) }, []) - const {setScrollRef} = React.useContext(ScrollContext) + const {setScrollRef} = React.useContext(ThreadRefsContext) React.useEffect(() => { setScrollRef({scrollDown, scrollToBottom, scrollUp}) }, [scrollDown, scrollToBottom, scrollUp, setScrollRef]) @@ -343,7 +342,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { const jumpToRecent = useJumpToRecent(scrollToBottom, messageOrdinals.length) - const {focusInput} = React.useContext(FocusContext) + const {focusInput} = React.useContext(ThreadRefsContext) const handleListClick = (ev: React.MouseEvent) => { const target = ev.target as { closest?: (s: string) => unknown @@ -510,7 +509,7 @@ const useNativeScrolling = (p: { listRef.current?.scrollToOffset({animated: false, offset}) }, [insetsBottom, keyboardAnimHeight, listRef]) - const {setScrollRef} = React.useContext(ScrollContext) + const {setScrollRef} = React.useContext(ThreadRefsContext) React.useEffect(() => { setScrollRef({scrollDown: noop, scrollToBottom, scrollUp: noop}) }, [setScrollRef, scrollToBottom]) diff --git a/shared/chat/conversation/messages/attachment/image/index.tsx b/shared/chat/conversation/messages/attachment/image/index.tsx index c4b8ddb0529b..fcadb0b053d5 100644 --- a/shared/chat/conversation/messages/attachment/image/index.tsx +++ b/shared/chat/conversation/messages/attachment/image/index.tsx @@ -1,6 +1,7 @@ import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {showAttachmentPreview} from '../../../attachment-actions' import {useConversationThreadID} from '../../../thread-context' import ImageImpl from './imageimpl' @@ -70,6 +71,7 @@ function Image(p: Props) { onClick={openFullscreen} onLongPress={hasMessageID ? showPopup : undefined} ref={toastTargetRef} + testID={TestIDs.CHAT_ATTACHMENT_IMAGE} > diff --git a/shared/chat/conversation/messages/cards/team-journey/container.tsx b/shared/chat/conversation/messages/cards/team-journey/container.tsx index 6ecc363b58a9..61ba822efc81 100644 --- a/shared/chat/conversation/messages/cards/team-journey/container.tsx +++ b/shared/chat/conversation/messages/cards/team-journey/container.tsx @@ -13,7 +13,7 @@ import { useConversationThreadDismissJourneycard, useConversationThreadID, useConversationThreadMessage, - useConversationThreadSelector, + useThreadMeta, } from '../../../thread-context' type Action = {label: string; onClick: () => void} | 'wave' @@ -25,7 +25,15 @@ const TeamJourneyConnected = (ownProps: OwnProps) => { const {ordinal} = ownProps const m = useConversationThreadMessage(ordinal) const message = m?.type === 'journeycard' ? m : emptyJourney - const conv = useConversationThreadSelector(s => s.meta) + const conv = useThreadMeta( + C.useShallow(m => ({ + cannotWrite: m.cannotWrite, + channelname: m.channelname, + teamID: m.teamID, + teamname: m.teamname, + tlfname: m.tlfname, + })) + ) const {cannotWrite, channelname, teamname, teamID} = conv const welcomeMessage = {display: '', raw: '', set: false} const teamMetaByID = useTeamsListMap() diff --git a/shared/chat/conversation/messages/message-popup/attachment.tsx b/shared/chat/conversation/messages/message-popup/attachment.tsx index 8291181c3f0d..696f48ea0a94 100644 --- a/shared/chat/conversation/messages/message-popup/attachment.tsx +++ b/shared/chat/conversation/messages/message-popup/attachment.tsx @@ -12,10 +12,11 @@ import { import {openLocalPathInSystemFileManagerDesktop} from '@/util/fs-storeless-actions' import { showConversationInfoPanel, + useConversationThreadID, useConversationThreadMessage, - useConversationThreadSelector, + useThreadMeta, } from '../../thread-context' -import {useConversationMetadata} from '../../data-hooks' +import {useConversationMetadata, useConversationParticipants} from '../../data-hooks' import {useRoute} from '@react-navigation/native' import type {MessagePopupItems} from './hooks' import {useHeader, useHeaderForMessage, useItems, useModeration, useStorelessItems} from './hooks' @@ -157,6 +158,7 @@ const PopAttachLoaded = (ownProps: OwnProps & { const PopAttachThread = (ownProps: OwnProps) => { const {ordinal, onHidden} = ownProps + const conversationIDKey = useConversationThreadID() const loadedMessage = useConversationThreadMessage(ordinal) const message = loadedMessage?.type === 'attachment' ? loadedMessage : emptyMessage const {attachmentDownload, messageAttachmentNativeSave, messageAttachmentNativeShare} = @@ -167,9 +169,8 @@ const PopAttachThread = (ownProps: OwnProps) => { // infoPanel only exists on the desktop/tablet split-view chatRoot route const infoPanelShowing = route.name === 'chatRoot' && 'infoPanel' in route.params && !!route.params.infoPanel - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({meta: s.meta, participantInfo: s.participants})) - ) + const meta = useThreadMeta(m => m) + const participantInfo = useConversationParticipants(conversationIDKey) return ( void) => { const conversationIDKey = useConversationThreadID() const message = useConversationThreadMessage(ordinal) ?? emptyText - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({meta: s.meta, participantInfo: s.participants})) - ) + const meta = useThreadMeta(m => m) + const participantInfo = useConversationParticipants(conversationIDKey) const {messageDelete, toggleMessageReaction} = useConversationThreadMessageActions() const setMarkAsUnread = useConversationThreadSetMarkAsUnread() return useItemsForMessage({ diff --git a/shared/chat/conversation/messages/message-popup/text.tsx b/shared/chat/conversation/messages/message-popup/text.tsx index 7cc6264f532b..bb5daf9ebb8b 100644 --- a/shared/chat/conversation/messages/message-popup/text.tsx +++ b/shared/chat/conversation/messages/message-popup/text.tsx @@ -1,4 +1,3 @@ -import * as C from '@/constants' import * as Chat from '@/constants/chat' import * as Kb from '@/common-adapters' import type * as React from 'react' @@ -6,12 +5,13 @@ import * as T from '@/constants/types' import {copyToClipboard} from '@/util/storeless-actions' import {openURL} from '@/util/misc' import {replyPrivatelyToConversationMessage} from '../../message-actions' -import {useConversationMetadata} from '../../data-hooks' +import {useConversationMetadata, useConversationParticipants} from '../../data-hooks' import {useCurrentUserState} from '@/stores/current-user' import { + useConversationThreadID, useConversationThreadMessage, useConversationThreadMessageActions, - useConversationThreadSelector, + useThreadMeta, } from '../../thread-context' import type {MessagePopupItems} from './hooks' import {useHeader, useHeaderForMessage, useItems, useModeration, useStorelessItems} from './hooks' @@ -153,10 +153,10 @@ const PopTextLoaded = (ownProps: OwnProps & { const PopTextThread = (ownProps: OwnProps) => { const {ordinal, onHidden} = ownProps + const conversationIDKey = useConversationThreadID() const message = useConversationThreadMessage(ordinal) ?? emptyMessage - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({meta: s.meta, participantInfo: s.participants})) - ) + const meta = useThreadMeta(m => m) + const participantInfo = useConversationParticipants(conversationIDKey) const itemsData = useItems(ordinal, onHidden) const header = useHeader(ordinal, onHidden) const {messageReplyPrivately} = useConversationThreadMessageActions() diff --git a/shared/chat/conversation/messages/reset-user.tsx b/shared/chat/conversation/messages/reset-user.tsx index 82913bdcb00b..aa6c7201c220 100644 --- a/shared/chat/conversation/messages/reset-user.tsx +++ b/shared/chat/conversation/messages/reset-user.tsx @@ -2,15 +2,14 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import {navToProfile} from '@/constants/router' -import {useConversationThreadID, useConversationThreadSelector} from '../thread-context' +import {useConversationThreadID, useThreadMeta} from '../thread-context' +import {useConversationParticipants} from '../data-hooks' const ResetUser = () => { - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({meta: s.meta, participantInfo: s.participants})) - ) const conversationIDKey = useConversationThreadID() + const participantInfo = useConversationParticipants(conversationIDKey) const _participants = participantInfo.all - const _resetParticipants = meta.resetParticipants + const _resetParticipants = useThreadMeta(m => m.resetParticipants) const _viewProfile = navToProfile const username = [..._resetParticipants][0] || '' const nonResetUsers = new Set(_participants) diff --git a/shared/chat/conversation/messages/retention-notice.tsx b/shared/chat/conversation/messages/retention-notice.tsx index 80c453972738..591fa83d26cb 100644 --- a/shared/chat/conversation/messages/retention-notice.tsx +++ b/shared/chat/conversation/messages/retention-notice.tsx @@ -1,7 +1,8 @@ import type * as T from '@/constants/types' +import * as C from '@/constants' import * as Kb from '@/common-adapters' import {useChatTeam} from '../team-hooks' -import {useConversationShowInfoPanel, useConversationThreadSelector} from '../thread-context' +import {useConversationShowInfoPanel, useThreadMeta} from '../thread-context' // Parses retention policies into a string suitable for display at the top of a conversation function makeRetentionNotice( @@ -40,7 +41,15 @@ function makeRetentionNotice( } function RetentionNoticeContainer() { - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta( + C.useShallow(m => ({ + retentionPolicy: m.retentionPolicy, + teamID: m.teamID, + teamRetentionPolicy: m.teamRetentionPolicy, + teamType: m.teamType, + teamname: m.teamname, + })) + ) const {teamType, retentionPolicy: policy, teamRetentionPolicy: teamPolicy} = meta const {yourOperations} = useChatTeam(meta.teamID, meta.teamname) const canChange = meta.teamType !== 'adhoc' ? yourOperations.setRetentionPolicy : true diff --git a/shared/chat/conversation/messages/special-bottom-message.tsx b/shared/chat/conversation/messages/special-bottom-message.tsx index 3e4c27762ae4..8fd109fb1d87 100644 --- a/shared/chat/conversation/messages/special-bottom-message.tsx +++ b/shared/chat/conversation/messages/special-bottom-message.tsx @@ -1,10 +1,17 @@ +import * as C from '@/constants' import * as Chat from '@/constants/chat' -import {useConversationThreadSelector} from '../thread-context' +import {useThreadMeta} from '../thread-context' import OldProfileReset from './system-old-profile-reset-notice/container' import ResetUser from './reset-user' function BottomMessageContainer() { - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta( + C.useShallow(m => ({ + resetParticipants: m.resetParticipants, + supersededBy: m.supersededBy, + wasFinalizedBy: m.wasFinalizedBy, + })) + ) const showResetParticipants = meta.resetParticipants.size !== 0 const showSuperseded = !!meta.wasFinalizedBy || meta.supersededBy !== Chat.noConversationIDKey diff --git a/shared/chat/conversation/messages/special-top-message.tsx b/shared/chat/conversation/messages/special-top-message.tsx index 378b8c29c5f2..d049bc3354cf 100644 --- a/shared/chat/conversation/messages/special-top-message.tsx +++ b/shared/chat/conversation/messages/special-top-message.tsx @@ -10,7 +10,9 @@ import {useChatThreadRouteParams} from '../thread-search-route' import { useConversationThreadID, useConversationThreadSelector, + useThreadMeta, } from '../thread-context' +import {useConversationParticipants} from '../data-hooks' import * as FS from '@/constants/fs' import {useCurrentUserState} from '@/stores/current-user' @@ -110,15 +112,21 @@ const ErrorMessage = () => { function SpecialTopMessage() { const username = useCurrentUserState(s => s.username) const conversationIDKey = useConversationThreadID() - const {hasLoadedEver, meta, moreToLoadBack, participants} = useConversationThreadSelector( + const {hasLoadedEver, moreToLoadBack} = useConversationThreadSelector( C.useShallow(s => ({ hasLoadedEver: s.messageOrdinals !== undefined, - meta: s.meta, moreToLoadBack: s.moreToLoadBack, - participants: s.participants, })) ) - const {teamType, supersedes, retentionPolicy, teamRetentionPolicy} = meta + const {teamType, supersedes, retentionPolicy, teamRetentionPolicy} = useThreadMeta( + C.useShallow(m => ({ + retentionPolicy: m.retentionPolicy, + supersedes: m.supersedes, + teamRetentionPolicy: m.teamRetentionPolicy, + teamType: m.teamType, + })) + ) + const participants = useConversationParticipants(conversationIDKey) const loadMoreType = moreToLoadBack ? 'moreToLoad' : 'noMoreToLoad' const pendingState = conversationIDKey === T.Chat.pendingWaitingConversationIDKey diff --git a/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx b/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx index 2e2c5a5ce4d2..d0794f9ae205 100644 --- a/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx @@ -7,7 +7,7 @@ import {getAddedUsernames} from '../system-users-added-to-conv/container' import {indefiniteArticle} from '@/util/string' import {useCurrentUserState} from '@/stores/current-user' import {useChatTeamMembers} from '../../team-hooks' -import {useConversationShowInfoPanel, useConversationThreadID, useConversationThreadSelector} from '../../thread-context' +import {useConversationShowInfoPanel, useConversationThreadID, useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemAddedToTeam} @@ -16,11 +16,11 @@ function SystemAddedToTeamContainer(p: OwnProps) { const {message} = p const {addee, adder, author, bulkAdds, role: _role, timestamp} = message const conversationIDKey = useConversationThreadID() - const {teamID, teamname, teamType} = useConversationThreadSelector( - C.useShallow(s => ({ - teamID: s.meta.teamID, - teamType: s.meta.teamType, - teamname: s.meta.teamname, + const {teamID, teamname, teamType} = useThreadMeta( + C.useShallow(m => ({ + teamID: m.teamID, + teamType: m.teamType, + teamname: m.teamname, })) ) const showInfoPanel = useConversationShowInfoPanel() diff --git a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx index 6684506827d3..7c8ab5d39954 100644 --- a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx +++ b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx @@ -5,7 +5,7 @@ import * as T from '@/constants/types' import * as dateFns from 'date-fns' import {useCurrentUserState} from '@/stores/current-user' import {useChatTeam} from '../../team-hooks' -import {useConversationShowInfoPanel, useConversationThreadSelector} from '../../thread-context' +import {useConversationShowInfoPanel, useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemChangeRetention} @@ -14,11 +14,11 @@ function SystemChangeRetentionContainer(p: OwnProps) { const {message} = p const {isInherit, isTeam, membersType, policy, user} = message const you = useCurrentUserState(s => s.username) - const {teamID, teamType, teamname} = useConversationThreadSelector( - C.useShallow(s => ({ - teamID: s.meta.teamID, - teamType: s.meta.teamType, - teamname: s.meta.teamname, + const {teamID, teamType, teamname} = useThreadMeta( + C.useShallow(m => ({ + teamID: m.teamID, + teamType: m.teamType, + teamname: m.teamname, })) ) const showInfoPanel = useConversationShowInfoPanel() diff --git a/shared/chat/conversation/messages/system-create-team/wrapper.tsx b/shared/chat/conversation/messages/system-create-team/wrapper.tsx index 707dc1221da1..1e74c3678405 100644 --- a/shared/chat/conversation/messages/system-create-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-create-team/wrapper.tsx @@ -6,15 +6,15 @@ import type * as T from '@/constants/types' import {useCurrentUserState} from '@/stores/current-user' import {useChatTeam} from '../../team-hooks' import {makeAddMembersWizard} from '@/teams/add-members-wizard/state' -import {useConversationShowInfoPanel, useConversationThreadSelector} from '../../thread-context' +import {useConversationShowInfoPanel, useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemCreateTeam} function SystemCreateTeamContainer(p: OwnProps) { const {creator} = p.message - const {teamID, teamname} = useConversationThreadSelector( - C.useShallow(s => ({teamID: s.meta.teamID, teamname: s.meta.teamname})) + const {teamID, teamname} = useThreadMeta( + C.useShallow(m => ({teamID: m.teamID, teamname: m.teamname})) ) const showInfoPanel = useConversationShowInfoPanel() const {role} = useChatTeam(teamID, teamname) diff --git a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx index 2659e23e4b7e..8020a253403d 100644 --- a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx +++ b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx @@ -4,7 +4,7 @@ import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' import UserNotice from '../user-notice' import {useCurrentUserState} from '@/stores/current-user' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemInviteAccepted} @@ -12,7 +12,7 @@ type OwnProps = {message: T.Chat.MessageSystemInviteAccepted} function SystemInviteAcceptedContainer(p: OwnProps) { const {message} = p const {role} = message - const teamID = useConversationThreadSelector(s => s.meta.teamID) + const teamID = useThreadMeta(m => m.teamID) const you = useCurrentUserState(s => s.username) const navigateAppend = C.Router2.navigateAppend const onViewTeam = () => { diff --git a/shared/chat/conversation/messages/system-joined/container.tsx b/shared/chat/conversation/messages/system-joined/container.tsx index 1986d5ea8400..71864861f5ad 100644 --- a/shared/chat/conversation/messages/system-joined/container.tsx +++ b/shared/chat/conversation/messages/system-joined/container.tsx @@ -1,17 +1,19 @@ import type * as T from '@/constants/types' +import * as C from '@/constants' import * as Kb from '@/common-adapters' import UserNotice from '../user-notice' import {getAddedUsernames} from '../system-users-added-to-conv/container' import {formatTimeForChat} from '@/util/timestamp' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' type OwnProps = {message: T.Chat.MessageSystemJoined} function JoinedContainer(p: OwnProps) { const {message} = p const {joiners, author, leavers, timestamp} = message - const meta = useConversationThreadSelector(s => s.meta) - const {channelname, teamType, teamname} = meta + const {channelname, teamType, teamname} = useThreadMeta( + C.useShallow(m => ({channelname: m.channelname, teamType: m.teamType, teamname: m.teamname})) + ) const joiners2 = !joiners?.length && !leavers?.length ? [author] : joiners const isBigTeam = teamType === 'big' const multiProps = {channelname, isBigTeam, teamname, timestamp} diff --git a/shared/chat/conversation/messages/system-left/wrapper.tsx b/shared/chat/conversation/messages/system-left/wrapper.tsx index 54a0b4457c0d..4822f0149b97 100644 --- a/shared/chat/conversation/messages/system-left/wrapper.tsx +++ b/shared/chat/conversation/messages/system-left/wrapper.tsx @@ -1,11 +1,13 @@ +import * as C from '@/constants' import * as Kb from '@/common-adapters' import UserNotice from '../user-notice' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' function SystemLeft() { - const meta = useConversationThreadSelector(s => s.meta) - const {channelname, teamType, teamname} = meta + const {channelname, teamType, teamname} = useThreadMeta( + C.useShallow(m => ({channelname: m.channelname, teamType: m.teamType, teamname: m.teamname})) + ) const isBigTeam = teamType === 'big' return ( diff --git a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx index e92f47391aa1..9a176b905db0 100644 --- a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx +++ b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx @@ -2,14 +2,14 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import type * as T from '@/constants/types' import UserNotice from '../user-notice' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemNewChannel} function SystemNewChannelContainer(p: OwnProps) { const {message} = p - const teamID = useConversationThreadSelector(s => s.meta.teamID) + const teamID = useThreadMeta(m => m.teamID) const navigateAppend = C.Router2.navigateAppend const onManageChannels = () => navigateAppend({name: 'teamAddToChannels', params: {teamID}}) diff --git a/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx b/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx index 379700323871..dd8f4deedda8 100644 --- a/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx +++ b/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx @@ -1,17 +1,17 @@ -import * as C from '@/constants' import type * as T from '@/constants/types' +import * as C from '@/constants' import {navigateToThread, previewConversation} from '@/constants/router' import {Text} from '@/common-adapters' import UserNotice from '../user-notice' -import {useConversationThreadSelector} from '../../thread-context' +import {useConversationThreadID, useThreadMeta} from '../../thread-context' +import {useConversationParticipants} from '../../data-hooks' const SystemOldProfileResetNotice = () => { - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({ - meta: s.meta, - participantInfo: s.participants, - })) + const conversationIDKey = useConversationThreadID() + const meta = useThreadMeta( + C.useShallow(m => ({supersededBy: m.supersededBy, wasFinalizedBy: m.wasFinalizedBy})) ) + const participantInfo = useConversationParticipants(conversationIDKey) const _participants = participantInfo.all const nextConversationIDKey = meta.supersededBy const username = meta.wasFinalizedBy || '' diff --git a/shared/chat/conversation/messages/system-profile-reset-notice.tsx b/shared/chat/conversation/messages/system-profile-reset-notice.tsx index f7f374565578..c80d1abffe6f 100644 --- a/shared/chat/conversation/messages/system-profile-reset-notice.tsx +++ b/shared/chat/conversation/messages/system-profile-reset-notice.tsx @@ -2,10 +2,12 @@ import * as C from '@/constants' import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' import UserNotice from './user-notice' -import {useConversationThreadSelector} from '../thread-context' +import {useThreadMeta} from '../thread-context' const SystemProfileResetNotice = () => { - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta( + C.useShallow(m => ({supersedes: m.supersedes, wasFinalizedBy: m.wasFinalizedBy})) + ) const prevConversationIDKey = meta.supersedes const username = meta.wasFinalizedBy || '' const _onOpenOlderConversation = (conversationIDKey: T.Chat.ConversationIDKey) => { diff --git a/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx b/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx index 5ad69e3159c5..2b125d4b406d 100644 --- a/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx +++ b/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx @@ -3,14 +3,14 @@ import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' import UserNotice from '../user-notice' import {useCurrentUserState} from '@/stores/current-user' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemSimpleToComplex} function SystemSimpleToComplexContainer(p: OwnProps) { const {message} = p - const teamID = useConversationThreadSelector(s => s.meta.teamID) + const teamID = useThreadMeta(m => m.teamID) const you = useCurrentUserState(s => s.username) const navigateAppend = C.Router2.navigateAppend const onManageChannels = () => navigateAppend({name: 'teamAddToChannels', params: {teamID}}) diff --git a/shared/chat/conversation/messages/system-users-added-to-conv/container.tsx b/shared/chat/conversation/messages/system-users-added-to-conv/container.tsx index 370127e3b1f6..1df6290e51c2 100644 --- a/shared/chat/conversation/messages/system-users-added-to-conv/container.tsx +++ b/shared/chat/conversation/messages/system-users-added-to-conv/container.tsx @@ -3,13 +3,13 @@ import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' import UserNotice from '../user-notice' import {useCurrentUserState} from '@/stores/current-user' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' type OwnProps = {message: T.Chat.MessageSystemUsersAddedToConversation} function UsersAddedToConversationContainer(p: OwnProps) { const {usernames} = p.message - const channelname = useConversationThreadSelector(s => s.meta.channelname) + const channelname = useThreadMeta(m => m.channelname) const you = useCurrentUserState(s => s.username) let otherUsers: Array | undefined if (usernames.includes(you)) { diff --git a/shared/chat/conversation/messages/wrapper/exploding-meta.tsx b/shared/chat/conversation/messages/wrapper/exploding-meta.tsx index c3f1b9dc2298..7eaa9613c62a 100644 --- a/shared/chat/conversation/messages/wrapper/exploding-meta.tsx +++ b/shared/chat/conversation/messages/wrapper/exploding-meta.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import {useIsHighlighted} from '../ids-context' +import {produce} from 'immer' import * as Kb from '@/common-adapters' import {addTicker, removeTicker} from '@/util/second-timer' import {formatDurationShort} from '@/util/timestamp' @@ -19,11 +20,7 @@ export type OwnProps = { function ExplodingMetaContainer(p: OwnProps) { const pending = isPendingSubmitState(p.submitState) return ( - + ) } @@ -41,11 +38,7 @@ const isPendingSubmitState = (submitState?: T.Chat.Message['submitState']) => const cappedLoopInterval = (difference: number) => Math.min(getLoopInterval(difference), 60000) -const makeInitialTimerState = (p: { - exploded: boolean - explodesAt: number - pending: boolean -}): TimerState => { +const makeInitialTimerState = (p: {exploded: boolean; explodesAt: number; pending: boolean}): TimerState => { const now = Date.now() if (p.pending) { return {exploded: p.exploded, inter: 0, mode: 'none', now} @@ -65,12 +58,13 @@ function ExplodingMetaInner(p: ExplodingMetaInnerProps) { let currentTimerState = timerState if (timerState.exploded !== exploded) { - currentTimerState = { - ...timerState, - exploded, - inter: exploded && !timerState.exploded ? 0 : timerState.inter, - mode: exploded && !timerState.exploded ? 'boom' : timerState.mode, - } + currentTimerState = produce(timerState, draft => { + draft.exploded = exploded + if (exploded) { + draft.inter = 0 + draft.mode = 'boom' + } + }) setTimerState(currentTimerState) } const {inter, mode, now} = currentTimerState @@ -87,12 +81,14 @@ function ExplodingMetaInner(p: ExplodingMetaInnerProps) { const id = addTicker(() => { const n = Date.now() const difference = explodesAt - n - setTimerState(state => { - if (difference <= 0 || exploded) { - return state.mode === 'countdown' ? {...state, mode: 'boom', now: n} : {...state, now: n} - } - return {...state, now: n} - }) + setTimerState( + produce(draft => { + if ((difference <= 0 || exploded) && draft.mode === 'countdown') { + draft.mode = 'boom' + } + draft.now = n + }) + ) }) return () => { removeTicker(id) @@ -101,16 +97,32 @@ function ExplodingMetaInner(p: ExplodingMetaInnerProps) { const id = setTimeout(() => { const n = Date.now() if (pending) { - setTimerState(state => ({...state, inter: 0, now: n})) + setTimerState( + produce(draft => { + draft.inter = 0 + draft.now = n + }) + ) return } const difference = explodesAt - n if (difference <= 0 || exploded) { - setTimerState(state => ({...state, inter: 0, mode: 'boom', now: n})) + setTimerState( + produce(draft => { + draft.inter = 0 + draft.mode = 'boom' + draft.now = n + }) + ) return } // we don't need a timer longer than 60000 (android complains also) - setTimerState(state => ({...state, inter: cappedLoopInterval(difference), now: n})) + setTimerState( + produce(draft => { + draft.inter = cappedLoopInterval(difference) + draft.now = n + }) + ) }, inter) return () => { clearTimeout(id) @@ -127,7 +139,12 @@ function ExplodingMetaInner(p: ExplodingMetaInnerProps) { } sharedTimerKeyRef.current = messageKey sharedTimerIDRef.current = SharedTimer.addObserver( - () => setTimerState(state => ({...state, mode: 'hidden'})), + () => + setTimerState( + produce(draft => { + draft.mode = 'hidden' + }) + ), { key: sharedTimerKeyRef.current, ms: animationDuration, diff --git a/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx b/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx index 864ec6f24360..88f059e38130 100644 --- a/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx +++ b/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx @@ -15,7 +15,7 @@ type Props = { } import {useConversationThreadToggleSearch} from '../../../thread-context' import Swipeable, {type SwipeableMethods} from '@/common-adapters/swipeable-row' -import {FocusContext} from '@/chat/conversation/normal/context' +import {ThreadRefsContext} from '@/chat/conversation/normal/context' function ReplyIcon({progress}: {progress: Animated.Value}) { const opacity = progress.interpolate({inputRange: [-20, 0], outputRange: [1, 0], extrapolate: 'clamp'}) @@ -30,7 +30,7 @@ function LongPressable(props: Props & {ref?: React.Ref}) { const toggleThreadSearch = useConversationThreadToggleSearch() const setReplyTo = InputState.useConversationInputDispatch(s => s.setReplyTo) const ordinal = useOrdinal() - const {focusInput} = React.useContext(FocusContext) + const {focusInput} = React.useContext(ThreadRefsContext) const swipeRef = React.useRef(null) if (!isMobile) { diff --git a/shared/chat/conversation/messages/wrapper/send-indicator.tsx b/shared/chat/conversation/messages/wrapper/send-indicator.tsx index 538c45fd93f8..ddd714701c94 100644 --- a/shared/chat/conversation/messages/wrapper/send-indicator.tsx +++ b/shared/chat/conversation/messages/wrapper/send-indicator.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import * as Kb from '@/common-adapters' +import {produce} from 'immer' import {useColorScheme} from 'react-native' type AnimationStatus = @@ -92,7 +93,11 @@ function SendIndicator(p: OwnProps) { } shownEncryptingSet.add(id) const timeoutID = setTimeout(() => { - setIndicatorState(state => ({...state, encrypting: false})) + setIndicatorState( + produce(draft => { + draft.encrypting = false + }) + ) }, 600) return () => { clearTimeout(timeoutID) @@ -104,7 +109,13 @@ function SendIndicator(p: OwnProps) { return undefined } const timeoutID = setTimeout(() => { - setIndicatorState(state => (state.sent ? {...state, sentHidden: true} : state)) + setIndicatorState( + produce(draft => { + if (draft.sent) { + draft.sentHidden = true + } + }) + ) }, 400) return () => { clearTimeout(timeoutID) diff --git a/shared/chat/conversation/messages/wrapper/shared-timers.tsx b/shared/chat/conversation/messages/wrapper/shared-timers.tsx index 139e72e099c0..60fbd6aad00e 100644 --- a/shared/chat/conversation/messages/wrapper/shared-timers.tsx +++ b/shared/chat/conversation/messages/wrapper/shared-timers.tsx @@ -6,6 +6,11 @@ import logger from '@/logger' * be kept in sync. Timers are given a key that can be * subscribed to. When all observers of a timer are * removed the timeout is cancelled and the key deleted + * + * No explicit logout reset: every observer here is added from a message row's + * effect and removed in that effect's cleanup (see exploding-meta.tsx), and + * logout unmounts every message row. So _refs/_timers always drain back to + * empty on logout without this module needing to know about it. */ export type SharedTimerID = number diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index c865a1d893ba..d53766d24da6 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -24,9 +24,13 @@ import { getConversationThreadDisplayMessage, ShownUsernameCacheContext, useConversationThreadActions, + useConversationThreadID, useConversationThreadMessageActions, useConversationThreadSelector, + useThreadMeta, } from '../../thread-context' +import {emptyParticipantInfo} from '../../data-hooks' +import {useInboxMetadataState} from '@/chat/inbox/metadata' import type {ConversationInputState} from '../../input-area/input-state' import {useChatTeamMemberRole} from '../../team-hooks' @@ -202,7 +206,7 @@ function AuthorSection(p: AuthorProps) { const getAuthorData = ( message: T.Chat.Message, - meta: T.Chat.ConversationMeta, + meta: Pick, participants: T.Chat.ParticipantInfo, showUsername: string ): FlatAuthorData => { @@ -372,6 +376,18 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo const {retryMessage} = useConversationThreadActions() const messageActions = useConversationThreadMessageActions() const shownCache = React.useContext(ShownUsernameCacheContext) + const conversationIDKey = useConversationThreadID() + // Reload-free read: avoid useConversationParticipants' per-mount unboxRows + engine + // listener registration, which is too expensive to pay per message row. + const participantInfo = useInboxMetadataState(s => s.participants.get(conversationIDKey)) ?? emptyParticipantInfo + const authorMeta = useThreadMeta( + C.useShallow(m => ({ + botAliases: m.botAliases, + teamID: m.teamID, + teamType: m.teamType, + teamname: m.teamname, + })) + ) return useConversationThreadSelector( C.useShallow(s => { @@ -397,7 +413,7 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo ...commonData, ...getEditCancelRetryData(commonData.ecrType, message), ...getRowActions(messageActions, uiDispatch, retryMessage), - ...getAuthorData(message, s.meta, s.participants, showUsername), + ...getAuthorData(message, authorMeta, participantInfo, showUsername), message, } }) diff --git a/shared/chat/conversation/normal/container.test.tsx b/shared/chat/conversation/normal/container.test.tsx index ff34b4c5d23e..a65902885a01 100644 --- a/shared/chat/conversation/normal/container.test.tsx +++ b/shared/chat/conversation/normal/container.test.tsx @@ -96,6 +96,7 @@ jest.mock('../thread-context', () => ({ useConversationThreadSelector: ( selector: (state: {loaded: boolean; meta: T.Chat.ConversationMeta}) => unknown ) => selector({loaded: mockLoaded, meta: mockMeta}), + useThreadMeta: (selector: (meta: T.Chat.ConversationMeta) => unknown) => selector(mockMeta), })) jest.mock('../team-hooks', () => { diff --git a/shared/chat/conversation/normal/container.tsx b/shared/chat/conversation/normal/container.tsx index 8cb4c726c46f..e59c5e85cb0b 100644 --- a/shared/chat/conversation/normal/container.tsx +++ b/shared/chat/conversation/normal/container.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import {useEngineActionListener} from '@/engine/action-listener' import Normal from '.' import * as T from '@/constants/types' -import {FocusProvider, ScrollProvider} from './context' +import {ThreadRefsProvider} from './context' import {OrangeLineContext, SetOrangeLineContext, useExplicitOrangeLineState} from '../orange-line-context' import {ChatTeamProvider} from '../team-hooks' import {ConversationCenterProvider} from '../center-context' @@ -12,6 +12,7 @@ import {ConversationInputProvider} from '../input-area/input-state' import { useConversationThreadID, useConversationThreadSelector, + useThreadMeta, } from '../thread-context' import {ConversationThreadLoadStatusProvider} from '../thread-load-status-context' import {MaybeMentionProvider} from '@/common-adapters/markdown/maybe-mention/context' @@ -53,10 +54,12 @@ const useOrangeLine = ( React.useLayoutEffect(() => { currentOrangeLineKeyRef.current = {conversationIDKey: id, mobileAppState} }, [id, mobileAppState]) - const meta = useConversationThreadSelector(s => s.meta) + const {maxVisibleMsgID, readMsgID} = useThreadMeta( + C.useShallow(m => ({maxVisibleMsgID: m.maxVisibleMsgID, readMsgID: m.readMsgID})) + ) // Keep the read position from when this conversation mounted. Mark-as-read updates - // meta.readMsgID shortly after navigation, but the open thread should retain its orange line. - const [initialReadMsgID] = React.useState(() => meta.readMsgID) + // readMsgID shortly after navigation, but the open thread should retain its orange line. + const [initialReadMsgID] = React.useState(() => readMsgID) const loadOrangeLine = React.useEffectEvent( (conversationIDKey: T.Chat.ConversationIDKey, readMsgID: T.Chat.MessageID) => { @@ -97,15 +100,13 @@ const useOrangeLine = ( } }, [id, loaded, initialReadMsgID]) - const maxVisibleMsgID = meta.maxVisibleMsgID - // just use the rpc for orange line if we're not active // if we are active we want to keep whatever state we had so it is maintained React.useEffect(() => { if (!active) { - loadOrangeLine(id, meta.readMsgID) + loadOrangeLine(id, readMsgID) } - }, [maxVisibleMsgID, active, id, meta.readMsgID]) + }, [maxVisibleMsgID, active, id, readMsgID]) const setOrangeLine = React.useEffectEvent((ordinal: T.Chat.Ordinal) => { const currentKey = currentOrangeLineKeyRef.current @@ -118,16 +119,13 @@ const useOrangeLine = ( }) }) - const explicitOrangeLine = useExplicitOrangeLineState(s => s.update) + const explicitOrangeLine = useExplicitOrangeLineState(s => s.updates.get(id)) const explicitOrangeLineVersionRef = React.useRef(explicitOrangeLine?.version ?? 0) React.useEffect(() => { if (!explicitOrangeLine || explicitOrangeLine.version <= explicitOrangeLineVersionRef.current) { return } explicitOrangeLineVersionRef.current = explicitOrangeLine.version - if (explicitOrangeLine.conversationIDKey !== id) { - return - } setOrangeLine(explicitOrangeLine.ordinal) }, [explicitOrangeLine, id]) @@ -136,8 +134,8 @@ const useOrangeLine = ( const useShowManageChannels = () => { const navigateAppend = C.Router2.navigateAppend - const {teamID, teamname} = useConversationThreadSelector( - C.useShallow(s => ({teamID: s.meta.teamID, teamname: s.meta.teamname})) + const {teamID, teamname} = useThreadMeta( + C.useShallow(m => ({teamID: m.teamID, teamname: m.teamname})) ) useEngineActionListener('chat.1.chatUi.chatShowManageChannels', action => { if ( @@ -194,11 +192,9 @@ const NormalWrapper = function NormalWrapper() { > - - - - - + + + diff --git a/shared/chat/conversation/normal/context.tsx b/shared/chat/conversation/normal/context.tsx index ecb27fad4e99..d23a2dac163f 100644 --- a/shared/chat/conversation/normal/context.tsx +++ b/shared/chat/conversation/normal/context.tsx @@ -2,30 +2,6 @@ import * as React from 'react' type FocusRefType = null | {focus: () => void} -type FocusContextType = { - focusInput: () => void - setInputRef: (inputRef: FocusRefType) => void -} - -export const FocusContext = React.createContext({ - focusInput: () => {}, - setInputRef: () => {}, -}) -FocusContext.displayName = 'FocusContext' - -export const FocusProvider = function FocusProvider({children}: {children: React.ReactNode}) { - const inputRef = React.useRef(null) - const [value] = React.useState(() => ({ - focusInput: () => { - inputRef.current?.focus() - }, - setInputRef: r => { - inputRef.current = r - }, - })) - return {children} -} - type ScrollType = { scrollUp: () => void scrollDown: () => void @@ -33,21 +9,29 @@ type ScrollType = { } type ScrollRefType = null | ScrollType -type ScrollContextType = ScrollType & { +type ThreadRefsType = ScrollType & { + focusInput: () => void + setInputRef: (inputRef: FocusRefType) => void setScrollRef: (scrollRef: ScrollRefType) => void } -export const ScrollContext = React.createContext({ +export const ThreadRefsContext = React.createContext({ + focusInput: () => {}, scrollDown: () => {}, scrollToBottom: () => {}, scrollUp: () => {}, + setInputRef: () => {}, setScrollRef: () => {}, }) -ScrollContext.displayName = 'ScrollContext' +ThreadRefsContext.displayName = 'ThreadRefsContext' -export const ScrollProvider = function ScrollProvider({children}: {children: React.ReactNode}) { +export const ThreadRefsProvider = function ThreadRefsProvider({children}: {children: React.ReactNode}) { + const inputRef = React.useRef(null) const scrollRef = React.useRef(null) - const [value] = React.useState(() => ({ + const [value] = React.useState(() => ({ + focusInput: () => { + inputRef.current?.focus() + }, scrollDown: () => { scrollRef.current?.scrollDown() }, @@ -57,9 +41,12 @@ export const ScrollProvider = function ScrollProvider({children}: {children: Rea scrollUp: () => { scrollRef.current?.scrollUp() }, + setInputRef: r => { + inputRef.current = r + }, setScrollRef: r => { scrollRef.current = r }, })) - return {children} + return {children} } diff --git a/shared/chat/conversation/normal/index.tsx b/shared/chat/conversation/normal/index.tsx index d5bd9255acee..7155b2ed7616 100644 --- a/shared/chat/conversation/normal/index.tsx +++ b/shared/chat/conversation/normal/index.tsx @@ -11,8 +11,8 @@ import ThreadLoadStatus from '../load-status' import {useConversationCenterActions} from '../center-context' import { useConversationThreadID, - useConversationThreadSelector, useConversationThreadToggleSearch, + useThreadMeta, } from '../thread-context' import {useThreadSearchRoute} from '../thread-search-route' import {indefiniteArticle} from '@/util/string' @@ -54,9 +54,9 @@ const DesktopConversation = function DesktopConversation() { }) } const showThreadSearch = !!useThreadSearchRoute() - const meta = useConversationThreadSelector(s => s.meta) - const {cannotWrite, minWriterRole} = meta - const threadLoadedOffline = meta.offline + const {cannotWrite, minWriterRole, offline: threadLoadedOffline} = useThreadMeta( + C.useShallow(m => ({cannotWrite: m.cannotWrite, minWriterRole: m.minWriterRole, offline: m.offline})) + ) const dragAndDropRejectReason = cannotWrite ? `You must be at least ${indefiniteArticle(minWriterRole)} ${minWriterRole} to post.` : undefined @@ -133,7 +133,7 @@ const NativeConversation = function NativeConversation() { const safeStyle = {height, maxHeight: height, minHeight: height} - const threadLoadedOffline = useConversationThreadSelector(s => s.meta.offline) + const threadLoadedOffline = useThreadMeta(m => m.offline) const stickyOffset = React.useMemo(() => ({closed: -insets.bottom, opened: 0}), [insets.bottom]) diff --git a/shared/chat/conversation/orange-line-context.tsx b/shared/chat/conversation/orange-line-context.tsx index 95121569b0e5..3baa33242644 100644 --- a/shared/chat/conversation/orange-line-context.tsx +++ b/shared/chat/conversation/orange-line-context.tsx @@ -3,38 +3,37 @@ import * as T from '@/constants/types' import * as Z from '@/util/zustand' type ExplicitOrangeLine = T.Immutable<{ - conversationIDKey: T.Chat.ConversationIDKey ordinal: T.Chat.Ordinal version: number }> type ExplicitOrangeLineState = T.Immutable<{ - update?: ExplicitOrangeLine + updates: Map dispatch: { resetState: () => void setOrangeLine: (conversationIDKey: T.Chat.ConversationIDKey, ordinal: T.Chat.Ordinal) => void } }> -let explicitOrangeLineVersion = 0 - export const useExplicitOrangeLineState = Z.createZustand( 'chat-explicit-orange-line', - set => ({ - dispatch: { - resetState: Z.defaultReset, - setOrangeLine: (conversationIDKey, ordinal) => { - set(s => { - s.update = { - conversationIDKey, - ordinal, - version: ++explicitOrangeLineVersion, - } - }) + set => { + let explicitOrangeLineVersion = 0 + return { + dispatch: { + resetState: Z.defaultReset, + setOrangeLine: (conversationIDKey, ordinal) => { + set(s => { + s.updates.set(conversationIDKey, { + ordinal, + version: ++explicitOrangeLineVersion, + }) + }) + }, }, - }, - update: undefined, - }) + updates: new Map(), + } + } ) export const setConversationOrangeLine = ( diff --git a/shared/chat/conversation/pinned-message.tsx b/shared/chat/conversation/pinned-message.tsx index 2f020d523f64..56d5842321fb 100644 --- a/shared/chat/conversation/pinned-message.tsx +++ b/shared/chat/conversation/pinned-message.tsx @@ -7,17 +7,17 @@ import {useCurrentUserState} from '@/stores/current-user' import {useChatTeam} from './team-hooks' import {ZoomedImage} from './common' import {useConversationCenterActions} from './center-context' -import {useConversationThreadID, useConversationThreadSelector} from './thread-context' +import {useConversationThreadID, useThreadMeta} from './thread-context' import logger from '@/logger' import {RPCError} from '@/util/errors' const PinnedMessage = function PinnedMessage() { const conversationIDKey = useConversationThreadID() - const {pinnedMsg, teamID, teamname} = useConversationThreadSelector( - C.useShallow(s => ({ - pinnedMsg: s.meta.pinnedMsg, - teamID: s.meta.teamID, - teamname: s.meta.teamname, + const {pinnedMsg, teamID, teamname} = useThreadMeta( + C.useShallow(m => ({ + pinnedMsg: m.pinnedMsg, + teamID: m.teamID, + teamname: m.teamname, })) ) const {centerOnMessage} = useConversationCenterActions() diff --git a/shared/chat/conversation/rekey/container.tsx b/shared/chat/conversation/rekey/container.tsx index aa0fdb56a422..e420e15b9378 100644 --- a/shared/chat/conversation/rekey/container.tsx +++ b/shared/chat/conversation/rekey/container.tsx @@ -4,11 +4,11 @@ import * as T from '@/constants/types' import ParticipantRekey from './participant-rekey' import YouRekey from './you-rekey' import {navToProfile} from '@/constants/router' -import {useConversationThreadSelector} from '../thread-context' +import {useThreadMeta} from '../thread-context' const Container = () => { const _you = useCurrentUserState(s => s.username) - const rekeyers = useConversationThreadSelector(s => s.meta.rekeyers) + const rekeyers = useThreadMeta(m => m.rekeyers) const onBack = C.Router2.navigateUp const navigateAppend = C.Router2.navigateAppend const onEnterPaperkey = () => { diff --git a/shared/chat/conversation/send-actions.tsx b/shared/chat/conversation/send-actions.tsx index 6ae77b3b000b..7767dbb677c8 100644 --- a/shared/chat/conversation/send-actions.tsx +++ b/shared/chat/conversation/send-actions.tsx @@ -9,6 +9,7 @@ import { useConversationThreadActions, useConversationThreadID, useConversationThreadSelector, + useThreadMeta, } from './thread-context' type SendTextParams = { @@ -82,14 +83,14 @@ export const sendTextToConversation = ( export const useConversationSendActions = () => { const conversationIDKey = useConversationThreadID() const actions = useConversationThreadActions() - const {explodingMode, messageMap, messageOrdinals, meta} = useConversationThreadSelector( + const {explodingMode, messageMap, messageOrdinals} = useConversationThreadSelector( C.useShallow(s => ({ explodingMode: s.explodingMode, messageMap: s.messageMap, messageOrdinals: s.messageOrdinals, - meta: s.meta, })) ) + const meta = useThreadMeta(C.useShallow(m => ({tlfname: m.tlfname}))) const clientPrev = getClientPrevFromThread(messageMap, messageOrdinals) const editMessage = (ordinal: T.Chat.Ordinal, text: string) => { diff --git a/shared/chat/conversation/team-hooks.tsx b/shared/chat/conversation/team-hooks.tsx index fcb9e1bdee01..199ea70a1ab0 100644 --- a/shared/chat/conversation/team-hooks.tsx +++ b/shared/chat/conversation/team-hooks.tsx @@ -4,11 +4,12 @@ import {useEngineActionListener} from '@/engine/action-listener' import {useCurrentUserState} from '@/stores/current-user' import {useUsersState} from '@/stores/users' import * as Teams from '@/constants/teams' +import {produce} from 'immer' import logger from '@/logger' import * as React from 'react' import {useTeamsListMap, useTeamsRoleMap} from '@/teams/use-teams-list' import {updateChosenChannelsTeamnames, useChosenChannelsTeamnames} from './manage-channels-badge' -import {useConversationThreadSelector} from './thread-context' +import {useThreadMeta} from './thread-context' type ChatTeamState = { allowPromote: boolean @@ -141,7 +142,14 @@ const useChatTeamRaw = (teamID: T.Teams.TeamID, teamname?: string, enabled = tru return } const requestVersion = ++requestVersionRef.current - setState(prev => ({...prev, loading: true, teamname: prev.teamname || knownTeamname})) + setState( + produce(draft => { + draft.loading = true + if (!draft.teamname) { + draft.teamname = knownTeamname + } + }) + ) try { const [annotatedTeam] = await Promise.all([ T.RPCGen.teamsGetAnnotatedTeamRpcPromise({teamID: validTeamID}), @@ -159,7 +167,14 @@ const useChatTeamRaw = (teamID: T.Teams.TeamID, teamname?: string, enabled = tru return } logger.warn(`Failed to load chat team metadata for ${validTeamID}`, error) - setState(prev => ({...prev, loading: false, teamname: prev.teamname || knownTeamname})) + setState( + produce(draft => { + draft.loading = false + if (!draft.teamname) { + draft.teamname = knownTeamname + } + }) + ) } }, [clearState, enabled, knownTeamname, loadRoleMapIfStale, validTeamID]) @@ -250,7 +265,12 @@ const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeam return } logger.warn(`Failed to reload chat team members for ${validTeamID}`, error) - setState(prev => ({...prev, loadedTeamID: validTeamID, loading: false})) + setState( + produce(draft => { + draft.loadedTeamID = validTeamID + draft.loading = false + }) + ) } }, [enabled, loadMemberInfos, validTeamID]) @@ -259,7 +279,12 @@ const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeam clearState() return } - setState(prev => ({...prev, loadedTeamID: validTeamID, loading: true})) + setState( + produce(draft => { + draft.loadedTeamID = validTeamID + draft.loading = true + }) + ) await loadMembers() }, [clearState, enabled, loadMembers, validTeamID]) @@ -294,7 +319,12 @@ const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeam return } logger.warn(`Failed to reload chat team members for ${validTeamID}`, error) - setState(prev => ({...prev, loadedTeamID: validTeamID, loading: false})) + setState( + produce(draft => { + draft.loadedTeamID = validTeamID + draft.loading = false + }) + ) } } C.ignorePromise(f()) @@ -390,11 +420,15 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t return } logger.warn(`Failed to load chat team names for ${teamIDsKey}`, error) - setState(prev => ({ - loadedTeamIDsKey: teamIDsKey, - loading: false, - teamnames: prev.loadedTeamIDsKey === teamIDsKey ? new Map(prev.teamnames) : new Map(), - })) + setState( + produce(draft => { + if (draft.loadedTeamIDsKey !== teamIDsKey) { + draft.teamnames = new Map() + } + draft.loadedTeamIDsKey = teamIDsKey + draft.loading = false + }) + ) } }, [enabled, loadTeamNamesForIDs, teamIDsKey, username, validTeamIDs]) @@ -403,11 +437,15 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t clearState() return } - setState(prev => ({ - loadedTeamIDsKey: teamIDsKey, - loading: true, - teamnames: prev.loadedTeamIDsKey === teamIDsKey ? new Map(prev.teamnames) : new Map(), - })) + setState( + produce(draft => { + if (draft.loadedTeamIDsKey !== teamIDsKey) { + draft.teamnames = new Map() + } + draft.loadedTeamIDsKey = teamIDsKey + draft.loading = true + }) + ) await loadTeamNames() }, [clearState, enabled, loadTeamNames, teamIDsKey, username]) @@ -472,21 +510,23 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t useEngineActionListener('keybase.1.NotifyTeam.teamDeleted', action => { if (enabled && validTeamIDs.includes(action.payload.params.teamID)) { requestVersionRef.current++ - setState(prev => { - const teamnames = new Map(prev.teamnames) - teamnames.delete(action.payload.params.teamID) - return {loadedTeamIDsKey: prev.loadedTeamIDsKey, loading: false, teamnames} - }) + setState( + produce(draft => { + draft.teamnames.delete(action.payload.params.teamID) + draft.loading = false + }) + ) } }) useEngineActionListener('keybase.1.NotifyTeam.teamExit', action => { if (enabled && validTeamIDs.includes(action.payload.params.teamID)) { requestVersionRef.current++ - setState(prev => { - const teamnames = new Map(prev.teamnames) - teamnames.delete(action.payload.params.teamID) - return {loadedTeamIDsKey: prev.loadedTeamIDsKey, loading: false, teamnames} - }) + setState( + produce(draft => { + draft.teamnames.delete(action.payload.params.teamID) + draft.loading = false + }) + ) } }) @@ -504,11 +544,11 @@ ChatTeamContext.displayName = 'ChatTeamContext' export const ChatTeamProvider = (props: React.PropsWithChildren) => { const {children} = props - const {teamID, teamType, teamname} = useConversationThreadSelector( - C.useShallow(s => ({ - teamID: s.meta.teamID, - teamType: s.meta.teamType, - teamname: s.meta.teamname, + const {teamID, teamType, teamname} = useThreadMeta( + C.useShallow(m => ({ + teamID: m.teamID, + teamType: m.teamType, + teamname: m.teamname, })) ) const outer = React.useContext(ChatTeamContext) diff --git a/shared/chat/conversation/thread-context.test.tsx b/shared/chat/conversation/thread-context.test.tsx index 0d495c677121..eecd52dcc497 100644 --- a/shared/chat/conversation/thread-context.test.tsx +++ b/shared/chat/conversation/thread-context.test.tsx @@ -1,7 +1,6 @@ /** @jest-environment jsdom */ /// import * as Common from '@/constants/chat/common' -import * as Meta from '@/constants/chat/meta' import * as Message from '@/constants/chat/message' import * as T from '@/constants/types' import HiddenString from '@/util/hidden-string' @@ -27,20 +26,7 @@ import { useConversationThreadSelector, useConversationThreadStore, } from './thread-context' - -jest.mock('@/chat/inbox/rows-state', () => ({ - flushInboxRowUpdates: jest.fn(), - getInboxRowTrustedState: jest.fn(() => undefined), - queueInboxRowUpdate: jest.fn(), - setInboxRowTrustedState: jest.fn(), - syncInboxRowBadgeState: jest.fn(), - syncInboxRowsFromLayout: jest.fn(), - syncInboxRowsFromMetaAndParticipants: jest.fn(), - syncInboxRowsFromMetas: jest.fn(), - syncInboxRowsFromParticipantMap: jest.fn(), - syncInboxRowsFromParticipants: jest.fn(), - updateInboxRowTyping: jest.fn(), -})) +import {useConversationParticipants} from './data-hooks' const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) const emptyStringSet = new Set() @@ -169,43 +155,6 @@ const makeIncomingOutboxReaction = ( pagination: null, }) -const makeUnverifiedInboxUIItem = (): T.RPCChat.UnverifiedInboxUIItem => ({ - commands: {typ: T.RPCChat.ConversationCommandGroupsTyp.none}, - convID: T.Chat.conversationIDKeyToString(convID), - convRetention: null, - draft: null, - finalizeInfo: null, - isDefaultConv: false, - isPublic: false, - localMetadata: { - channelName: '', - headline: '', - headlineDecorated: '', - resetParticipants: null, - snippet: '', - snippetDecoration: T.RPCChat.SnippetDecoration.none, - writerNames: null, - }, - localVersion: 1, - maxMsgID: T.Chat.messageIDToNumber(T.Chat.numberToMessageID(301)), - maxVisibleMsgID: T.Chat.messageIDToNumber(T.Chat.numberToMessageID(301)), - memberStatus: T.RPCChat.ConversationMemberStatus.active, - membersType: T.RPCChat.ConversationMembersType.impteamnative, - name: 'alice,bob,charlie', - notifications: null, - readMsgID: 0, - status: T.RPCChat.ConversationStatus.unfiled, - supersededBy: null, - supersedes: null, - teamRetention: null, - teamType: T.RPCChat.TeamType.simple, - time: 1, - tlfID: 'tlf-id', - topicType: T.RPCChat.TopicType.chat, - version: 1, - visibility: T.RPCGen.TLFVisibility.private, -}) - const makeFailedOutboxRecord = ( conversationIDKey: T.Chat.ConversationIDKey, outboxID: T.Chat.OutboxID @@ -331,7 +280,7 @@ test('separate providers do not share thread state', () => { }) test('mounted thread syncs participant updates received outside its provider', () => { - const {result} = renderHook(() => useConversationThreadSelector(s => s.participants), {wrapper}) + const {result} = renderHook(() => useConversationParticipants(convID), {wrapper}) const participantInfo = { all: ['alice', 'helperbot'], contactName: new Map(), @@ -339,10 +288,7 @@ test('mounted thread syncs participant updates received outside its provider', ( } act(() => { - participantInfoReceived(convID, participantInfo, { - ...Meta.makeConversationMeta(), - conversationIDKey: convID, - }) + participantInfoReceived(convID, participantInfo) }) expect(result.current.all).toEqual(['alice', 'helperbot']) @@ -1136,45 +1082,6 @@ test('toggleMessageReaction overlays locally without mutating server reactions', }) }) -test('mounted thread listener applies inbox failure metadata for the active conversation', () => { - const {result} = renderHook( - () => ({ - meta: useConversationThreadSelector(s => s.meta), - participants: useConversationThreadSelector(s => s.participants), - }), - {wrapper} - ) - - act(() => { - notifyEngineActionListeners({ - payload: { - params: { - convID: T.Chat.keyToConversationID(convID), - error: { - message: 'rekey needed', - rekeyInfo: { - readerNames: ['charlie'], - rekeyers: ['bob'], - tlfName: 'alice,bob,charlie', - tlfPublic: false, - writerNames: ['alice', 'bob'], - }, - remoteConv: makeUnverifiedInboxUIItem(), - typ: T.RPCChat.ConversationErrorType.otherrekeyneeded, - unverifiedTLFName: 'alice,bob,charlie', - }, - }, - }, - type: 'chat.1.chatUi.chatInboxFailed', - } as never) - }) - - expect(result.current.meta.trustedState).toBe('error') - expect(result.current.meta.snippet).toBe('rekey needed') - expect([...result.current.meta.rekeyers]).toEqual(['bob']) - expect(result.current.participants.name).toEqual(['alice', 'bob', 'charlie']) -}) - test('mounted thread listener applies request and payment decorators for the active conversation', () => { const {result} = renderHook( () => ({ diff --git a/shared/chat/conversation/thread-context.tsx b/shared/chat/conversation/thread-context.tsx index 631e3f95bdd2..ab58d3d94909 100644 --- a/shared/chat/conversation/thread-context.tsx +++ b/shared/chat/conversation/thread-context.tsx @@ -1,18 +1,9 @@ import * as Common from '@/constants/chat/common' -import * as Message from '@/constants/chat/message' import * as Meta from '@/constants/chat/meta' -import * as TeamsUtil from '@/constants/teams' import * as React from 'react' import * as Strings from '@/constants/strings' import * as T from '@/constants/types' -import { - getVisibleScreen, - navigateAppend, - navigateToInbox, - navigateToThread, - navigateUp, - setChatRootParams, -} from '@/constants/router' +import {getVisibleScreen, navigateAppend, navigateToThread, navigateUp, setChatRootParams} from '@/constants/router' import {isPhone} from '@/constants/platform' import logger from '@/logger' import throttle from 'lodash/throttle' @@ -20,9 +11,6 @@ import {clearChatTimeCache} from '@/util/timestamp' import {findLast} from '@/util/arrays' import {ignorePromise} from '@/constants/utils' import {RPCError} from '@/util/errors' -import {persistRoute} from '@/util/storeless-actions' -import {uint8ArrayToString} from '@/util/uint8array' -import {useEngineActionListener} from '@/engine/action-listener' import {useCurrentUserState} from '@/stores/current-user' import {useUsersState} from '@/stores/users' import {useConfigState} from '@/stores/config' @@ -41,7 +29,6 @@ import { explodeMessagesInThreadState, failAttachmentDownloadInThreadState, finishAttachmentDownloadInThreadState, - getOrdinalForMessageID, type OptimisticReaction, retryMessageInThreadState, setMessageSubmitStateInThreadState, @@ -56,16 +43,10 @@ import { getInboxConversationMeta, getInboxConversationParticipants, metasReceived, - participantInfoReceived, unboxRows, useInboxMetadataState, } from '@/chat/inbox/metadata' -import { - loadThreadMessageIDAtIndex, - loadThreadNonblock, - markConversationRead, - threadLoadReasonToRPCReason, -} from './thread-rpc' +import {loadThreadMessageIDAtIndex, markConversationRead} from './thread-rpc' import { cancelConversationPost, createAdhocConversation, @@ -74,9 +55,17 @@ import { postConversationReaction, } from './message-rpc' import {cancelActiveThreadSearchRPC} from '../search-rpc' - -const numMessagesOnInitialLoad = isMobile ? 20 : 100 -const numMessagesOnScrollback = 100 +import { + emptyConversationMeta, + getClientPrevFromSnapshot, + getExplodingModeFromConfig, + getMeta, + loadConversationThreadMessages, + numMessagesOnInitialLoad, + numMessagesOnScrollback, + persistExplodingMode, +} from './thread-load' +import {useThreadEngineListeners} from './thread-engine' const sameStringSet = (a: ReadonlySet, b: ReadonlySet) => { if (a.size !== b.size) { @@ -90,84 +79,10 @@ const sameStringSet = (a: ReadonlySet, b: ReadonlySet) => { return true } -const ignoreErrors = [ - T.RPCGen.StatusCode.scgenericapierror, - T.RPCGen.StatusCode.scapinetworkerror, - T.RPCGen.StatusCode.sctimeout, -] - -const makeEmptyParticipantInfo = (): T.Chat.ParticipantInfo => ({ +const emptyParticipantInfo: T.Chat.ParticipantInfo = { all: [], contactName: new Map(), name: [], -}) - -const getExplodingModeFromGregorItems = ( - conversationIDKey: T.Chat.ConversationIDKey, - items: ReadonlyArray<{item: T.RPCGen.Gregor1.Item}> -) => { - const explodingItems = items.filter(i => i.item.category.startsWith(Common.explodingModeGregorKeyPrefix)) - if (!explodingItems.length) { - return 0 - } - const category = `${Common.explodingModeGregorKeyPrefix}${conversationIDKey}` - const item = explodingItems.find(i => i.item.category === category) - if (!item) { - // Other conversations have exploding modes but this one's category is absent, - // meaning it was dismissed: the mode is off. - return 0 - } - const secondsString = uint8ArrayToString(item.item.body) - const seconds = parseInt(secondsString, 10) - if (isNaN(seconds)) { - logger.warn(`Got dirty exploding mode ${secondsString} for category ${category}`) - return undefined - } - return seconds -} - -const getExplodingModeFromConfig = (conversationIDKey: T.Chat.ConversationIDKey) => - getExplodingModeFromGregorItems(conversationIDKey, useConfigState.getState().gregorPushState) ?? 0 - -const persistExplodingMode = ( - conversationIDKey: T.Chat.ConversationIDKey, - meta: T.Chat.ConversationMeta, - seconds: number -) => { - const f = async () => { - logger.info(`Setting exploding mode for conversation ${conversationIDKey} to ${seconds}`) - const category = `${Common.explodingModeGregorKeyPrefix}${conversationIDKey}` - const convRetention = Meta.getEffectiveRetentionPolicy(meta) - try { - if (seconds === 0 || seconds === convRetention.seconds) { - await T.RPCGen.gregorDismissCategoryRpcPromise({category}) - } else { - await T.RPCGen.gregorUpdateCategoryRpcPromise({ - body: seconds.toString(), - category, - dtime: {offset: 0, time: 0}, - }) - logger.info(`Successfully set exploding mode for conversation ${conversationIDKey} to ${seconds}`) - } - } catch (error) { - if (error instanceof RPCError) { - if (seconds !== 0) { - logger.error( - `Failed to set exploding mode for conversation ${conversationIDKey} to ${seconds}. Service responded with: ${error.message}` - ) - } else { - logger.error( - `Failed to unset exploding mode for conversation ${conversationIDKey}. Service responded with: ${error.message}` - ) - } - if (ignoreErrors.includes(error.code)) { - return - } - } - throw error - } - } - ignorePromise(f()) } const formatTextForQuoting = (text: string) => @@ -176,15 +91,6 @@ const formatTextForQuoting = (text: string) => .map(line => `> ${line}\n`) .join('') -const getClientPrevFromSnapshot = (snapshot: ConversationThreadState): T.Chat.MessageID => { - const ordinal = findLast(snapshot.messageOrdinals ?? [], o => { - const m = snapshot.messageMap.get(o) - return !!m?.id - }) - const message = ordinal ? snapshot.messageMap.get(ordinal) : undefined - return message?.id || T.Chat.numberToMessageID(0) -} - const ConversationThreadIDContext = React.createContext(undefined) ConversationThreadIDContext.displayName = 'ConversationThreadIDContext' @@ -194,7 +100,6 @@ export type ConversationThreadState = { flipStatusMap: Map loaded: boolean liveUpdateVersion: number - meta: T.Chat.ConversationMeta messageIDToOrdinal: Map messageMap: Map messageOrdinals?: ReadonlyArray @@ -203,7 +108,6 @@ export type ConversationThreadState = { moreToLoadForward: boolean optimisticReactionMap: Map paymentStatusMap: Map - participants: T.Chat.ParticipantInfo pendingOutboxToOrdinal: Map typing: Set unfurlPrompt: Map> @@ -234,11 +138,9 @@ const makeEmptyThreadState = (): ConversationThreadState => messageMap: new Map(), messageOrdinals: undefined as ReadonlyArray | undefined, messageTypeMap: new Map(), - meta: Meta.makeConversationMeta(), moreToLoadBack: false, moreToLoadForward: false, optimisticReactionMap: new Map(), - participants: makeEmptyParticipantInfo(), paymentStatusMap: new Map(), pendingOutboxToOrdinal: new Map(), typing: new Set(), @@ -249,15 +151,7 @@ const makeEmptyThreadState = (): ConversationThreadState => ) const makeInitialThreadState = (id: T.Chat.ConversationIDKey) => { - const meta = getInboxConversationMeta(id) - const participants = getInboxConversationParticipants(id) return produce(makeEmptyThreadState(), s => { - if (meta) { - s.meta = T.castDraft(meta) - } - if (participants) { - s.participants = T.castDraft(participants) - } s.explodingMode = getExplodingModeFromConfig(id) }) } @@ -275,8 +169,8 @@ type SelectedConversationOptions = ThreadLoadStatusOptions & { skipThreadLoad?: boolean } -type ScrollDirection = 'none' | 'back' | 'forward' -type LoadMoreMessagesParams = ThreadLoadStatusOptions & { +export type ScrollDirection = 'none' | 'back' | 'forward' +export type LoadMoreMessagesParams = ThreadLoadStatusOptions & { allowMarkAsRead?: boolean centeredMessageID?: { conversationIDKey: T.Chat.ConversationIDKey @@ -307,7 +201,7 @@ type LoadNewerMessagesDueToScroll = ( type JumpToRecent = (options?: ThreadLoadStatusOptions) => void type MessagesClear = () => void type SelectedConversation = (options?: SelectedConversationOptions) => void -type ConversationThreadActions = { +export type ConversationThreadActions = { addMessages: ( messages: ReadonlyArray, opt?: { @@ -351,11 +245,9 @@ type ConversationThreadActions = { receiveRequestInfo: (messageID: T.Chat.MessageID, requestInfo: T.Chat.ChatRequestInfo) => void retryMessage: (outboxID: T.Chat.OutboxID) => void setExplodingMode: (seconds: number, incoming?: boolean) => void - setMeta: (meta?: T.Chat.ConversationMeta) => void setMessageErrored: (outboxID: T.Chat.OutboxID, reason: string, errorTyp?: number) => void setMessageSubmitState: (ordinal: T.Chat.Ordinal, submitState: T.Chat.Message['submitState']) => void setMarkAsUnread: (readMsgID?: T.Chat.MessageID | false) => void - setParticipants: (participants: T.Chat.ParticipantInfo) => void setTyping: (typing: ReadonlySet) => void showUnfurlPrompt: (messageID: T.Chat.MessageID, domain: string) => void addOptimisticReaction: (outboxID: T.Chat.OutboxID, reaction: OptimisticReaction) => void @@ -379,7 +271,6 @@ type ConversationThreadActions = { bytesComplete?: number, bytesTotal?: number ) => void - updateMeta: (meta: Partial) => void } const ConversationThreadActionsContext = React.createContext( @@ -428,463 +319,6 @@ const useScrollLoadGate = () => { } } -const scrollDirectionToPagination = ( - scrollDirection: ScrollDirection, - numberOfMessagesToLoad: number -) => { - const pagination = { - last: false, - next: '', - num: numberOfMessagesToLoad, - previous: '', - } - switch (scrollDirection) { - case 'none': - break - case 'back': - pagination.next = 'deadbeef' - break - case 'forward': - pagination.previous = 'deadbeef' - } - return pagination -} - -const getCurrentUser = () => { - const s = useCurrentUserState.getState() - return {devicename: s.deviceName, username: s.username} -} - -const getLastOrdinalFromSnapshot = (snapshot: ConversationThreadState) => - snapshot.messageOrdinals?.at(-1) ?? T.Chat.numberToOrdinal(0) - -const getOrdinalForMessageIDInSnapshot = ( - snapshot: ConversationThreadState, - messageID: T.Chat.MessageID -) => - getOrdinalForMessageID( - snapshot.messageMap, - snapshot.pendingOutboxToOrdinal, - messageID, - snapshot.messageIDToOrdinal - ) - -const applyMessagesUpdatedToThread = ( - conversationIDKey: T.Chat.ConversationIDKey, - messagesUpdated: T.RPCChat.MessagesUpdated, - actions: ConversationThreadActions -) => { - if (!messagesUpdated.updates) return - const snapshot = actions.getSnapshot() - const activelyLookingAtThread = Common.isUserActivelyLookingAtThisThread(conversationIDKey) - if (!snapshot.loaded && !activelyLookingAtThread) { - return - } - - const {username, devicename} = getCurrentUser() - const messages = messagesUpdated.updates.flatMap(uimsg => { - if (!Message.getMessageID(uimsg)) return [] - const message = Message.uiMessageToMessage( - conversationIDKey, - uimsg, - username, - () => getLastOrdinalFromSnapshot(actions.getSnapshot()), - devicename - ) - return message ? [message] : [] - }) - if (messages.length === 0) { - return - } - actions.addMessages(messages, {liveUpdate: true, markAsRead: activelyLookingAtThread}) -} - -const applyIncomingMutationToThread = ( - conversationIDKey: T.Chat.ConversationIDKey, - valid: T.RPCChat.UIMessageValid, - modifiedMessage: T.RPCChat.UIMessage | null | undefined, - actions: ConversationThreadActions -) => { - const body = valid.messageBody - logger.info(`Got chat incoming message of messageType: ${body.messageType}`) - const mutationOrdinal = T.Chat.numberToOrdinal(valid.messageID) - if (actions.getSnapshot().messageMap.has(mutationOrdinal)) { - actions.deleteMessages({liveUpdate: true, ordinals: [mutationOrdinal]}) - } - - switch (body.messageType) { - case T.RPCChat.MessageType.edit: - if (modifiedMessage) { - const {username, devicename} = getCurrentUser() - const modMessage = Message.uiMessageToMessage( - conversationIDKey, - modifiedMessage, - username, - () => getLastOrdinalFromSnapshot(actions.getSnapshot()), - devicename - ) - if (modMessage) { - actions.addMessages([modMessage], {liveUpdate: true}) - } - } - return true - case T.RPCChat.MessageType.delete: { - const {delete: d} = body - if (d.messageIDs) { - const messageIDs = T.Chat.numbersToMessageIDs(d.messageIDs) - const snapshot = actions.getSnapshot() - const isExplodeNow = messageIDs.some(id => { - const ordinal = getOrdinalForMessageIDInSnapshot(snapshot, id) - const message = ordinal ? snapshot.messageMap.get(ordinal) : undefined - return !!((message?.type === 'text' || message?.type === 'attachment') && message.exploding) - }) - - if (isExplodeNow) { - actions.explodeMessages(messageIDs, valid.senderUsername, true) - } else { - actions.deleteMessages({liveUpdate: true, messageIDs}) - } - } - return true - } - default: - return false - } -} - -const applyIncomingMessageToThread = ( - conversationIDKey: T.Chat.ConversationIDKey, - incomingMessage: T.RPCChat.IncomingMessage, - actions: ConversationThreadActions -) => { - const snapshot = actions.getSnapshot() - const activelyLookingAtThread = Common.isUserActivelyLookingAtThisThread(conversationIDKey) - if (!snapshot.loaded && !activelyLookingAtThread) { - return - } - const {message: cMsg, modifiedMessage} = incomingMessage - const {username, devicename} = getCurrentUser() - - if ( - cMsg.state === T.RPCChat.MessageUnboxedState.outbox && - cMsg.outbox.messageType === T.RPCChat.MessageType.reaction - ) { - actions.updateOptimisticReactionDecorated( - T.Chat.stringToOutboxID(cMsg.outbox.outboxID), - cMsg.outbox.decoratedTextBody ?? cMsg.outbox.body - ) - return - } - - if (cMsg.state === T.RPCChat.MessageUnboxedState.valid) { - const {valid} = cMsg - const {messageType} = valid.messageBody - if ( - (messageType === T.RPCChat.MessageType.edit || messageType === T.RPCChat.MessageType.delete) && - applyIncomingMutationToThread(conversationIDKey, valid, modifiedMessage, actions) - ) { - return - } - } - - const message = Message.uiMessageToMessage( - conversationIDKey, - cMsg, - username, - () => getLastOrdinalFromSnapshot(actions.getSnapshot()), - devicename - ) - if (!message) return - - if ( - cMsg.state === T.RPCChat.MessageUnboxedState.valid && - cMsg.valid.messageBody.messageType === T.RPCChat.MessageType.attachmentuploaded && - message.type === 'attachment' - ) { - const placeholderID = cMsg.valid.messageBody.attachmentuploaded.messageID - const snapshot = actions.getSnapshot() - const ordinal = getOrdinalForMessageIDInSnapshot(snapshot, T.Chat.numberToMessageID(placeholderID)) - const existing = ordinal ? snapshot.messageMap.get(ordinal) : undefined - if (ordinal && existing) { - actions.addMessages([Message.upgradeMessage(existing, {...message, ordinal})], { - liveUpdate: true, - markAsRead: activelyLookingAtThread, - }) - } else { - if (snapshot.moreToLoadForward) { - return - } - actions.addMessages([message], {liveUpdate: true, markAsRead: activelyLookingAtThread}) - } - } else { - if (actions.getSnapshot().moreToLoadForward) { - return - } - actions.addMessages([message], {liveUpdate: true, markAsRead: activelyLookingAtThread}) - } -} - -const applyFailedMessageToThread = ( - conversationIDKey: T.Chat.ConversationIDKey, - failedMessage: T.RPCChat.FailedMessageInfo, - actions: ConversationThreadActions -) => { - const {outboxRecords} = failedMessage - if (!outboxRecords) return - for (const outboxRecord of outboxRecords) { - if (T.Chat.conversationIDToKey(outboxRecord.convID) !== conversationIDKey) { - continue - } - const s = outboxRecord.state - if (s.state !== T.RPCChat.OutboxStateType.error) { - continue - } - const {error} = s - const outboxID = T.Chat.rpcOutboxIDToOutboxID(outboxRecord.outboxID) - actions.setMessageErrored(outboxID, Message.rpcErrorToString(error), error.typ) - } -} - -const applyReactionUpdateToThread = ( - reactionUpdate: T.RPCChat.ReactionUpdateNotif, - actions: ConversationThreadActions -) => { - if (!reactionUpdate.reactionUpdates || reactionUpdate.reactionUpdates.length === 0) { - return - } - const updates = reactionUpdate.reactionUpdates.map(ru => ({ - reactions: Message.reactionMapToReactions(ru.reactions), - targetMsgID: T.Chat.numberToMessageID(ru.targetMsgID), - })) - actions.updateReactions(updates) -} - -const applyExpungeToThread = (expunge: T.RPCChat.ExpungeInfo, actions: ConversationThreadActions) => { - const deletableMessageTypes = - useConfigState.getState().chatDeletableByDeleteHistory || Common.allMessageTypes - actions.deleteMessages({ - deletableMessageTypes, - liveUpdate: true, - upToMessageID: T.Chat.numberToMessageID(expunge.expunge.upto), - }) -} - -const applyEphemeralPurgeToThread = ( - ephemeralPurge: T.RPCChat.EphemeralPurgeNotifInfo, - actions: ConversationThreadActions -) => { - const messageIDs = ephemeralPurge.msgs?.reduce>((arr, msg) => { - const msgID = Message.getMessageID(msg) - if (msgID) { - arr.push(msgID) - } - return arr - }, []) - if (messageIDs) { - actions.explodeMessages(messageIDs, undefined, true) - } -} - -const applyConversationMetaToThread = ( - meta: T.Chat.ConversationMeta | undefined, - actions: ConversationThreadActions -) => { - if (!meta) { - return - } - const oldMeta = actions.getSnapshot().meta - if (oldMeta.conversationIDKey === meta.conversationIDKey) { - actions.setMeta(Meta.updateMeta(oldMeta, meta)) - } else { - actions.setMeta(meta) - } -} - -const applyInboxUIItemToThread = ( - conv: T.RPCChat.InboxUIItem | null | undefined, - actions: ConversationThreadActions -) => { - if (conv) { - applyConversationMetaToThread(Meta.inboxUIItemToConversationMeta(conv), actions) - } -} - -const loadConversationThreadMessages = ( - conversationIDKey: T.Chat.ConversationIDKey, - p: LoadMoreMessagesParams, - actions: ConversationThreadActions -) => { - if (!T.Chat.isValidConversationIDKey(conversationIDKey)) { - return - } - const {scrollDirection = 'none', numberOfMessagesToLoad = numMessagesOnInitialLoad} = p - const { - allowMarkAsRead = true, - reason, - forceContainsLatestCalc, - messageIDControl, - knownRemotes, - centeredMessageID, - isThreadLoadCurrent, - onThreadLoadStatus, - } = p - const isCurrentThreadLoad = () => isThreadLoadCurrent?.() ?? true - - const f = async () => { - if (!isCurrentThreadLoad()) { - logger.info('loadMoreMessages: bail: stale mounted thread load') - return - } - - if (!conversationIDKey || !T.Chat.isValidConversationIDKey(conversationIDKey)) { - logger.info('loadMoreMessages: bail: no conversationIDKey') - return - } - - const loadStartedSnapshot = actions.getSnapshot() - const currentMeta = loadStartedSnapshot.meta - if (currentMeta.membershipType === 'youAreReset' || currentMeta.rekeyers.size > 0) { - logger.info('loadMoreMessages: bail: we are reset') - return - } - const loadStartedLiveUpdateVersion = loadStartedSnapshot.liveUpdateVersion - const protectLoadedFocusRefresh = - loadStartedSnapshot.loaded && - scrollDirection === 'none' && - !centeredMessageID && - !messageIDControl && - (reason === 'focused' || reason === 'tab selected') - logger.info( - `loadMoreMessages: calling rpc convo: ${conversationIDKey} num: ${numberOfMessagesToLoad} reason: ${reason}` - ) - - const loadingKey = Strings.waitingKeyChatThreadLoad(conversationIDKey) - let reconciled = false - const onGotThread = (thread: string, why: string) => { - if (!thread) { - return - } - if (!isCurrentThreadLoad()) { - logger.info(`loadMoreMessages: stale response ignored: ${why}`) - return - } - if ( - protectLoadedFocusRefresh && - actions.getSnapshot().liveUpdateVersion !== loadStartedLiveUpdateVersion - ) { - logger.info( - `loadMoreMessages: stale response ignored after live update: ${why} reason=${reason} convID=${conversationIDKey}` - ) - return - } - - const {username, devicename} = getCurrentUser() - const uiMessages = JSON.parse(thread) as T.RPCChat.UIMessages - - const messages = (uiMessages.messages ?? []).reduce>((arr, m) => { - const message = Message.uiMessageToMessage( - conversationIDKey, - m, - username, - () => getLastOrdinalFromSnapshot(actions.getSnapshot()), - devicename - ) - if (message) { - arr.push(message) - } - return arr - }, []) - - const moreToLoad = uiMessages.pagination ? !uiMessages.pagination.last : true - const canMarkReadForThreadWindow = - allowMarkAsRead && - !centeredMessageID && - !messageIDControl && - scrollDirection !== 'back' && - reason !== 'findNewestConversation' && - reason !== 'findNewestConversationFromLayout' - let validatedRange: {from: T.Chat.Ordinal; to: T.Chat.Ordinal} | undefined - if (messages.length) { - if (scrollDirection === 'none' && !reconciled) { - const ords = messages - .filter(m => m.conversationMessage !== false && m.type !== 'deleted') - .map(m => m.ordinal) - if (ords.length > 0) { - validatedRange = { - from: Math.min(...ords) as T.Chat.Ordinal, - to: Math.max(...ords) as T.Chat.Ordinal, - } - } - reconciled = true - } - } - actions.applyThreadLoad({ - centered: !!centeredMessageID, - disableActiveMarkRead: !allowMarkAsRead || !!centeredMessageID || !!messageIDControl, - enableActiveMarkRead: canMarkReadForThreadWindow, - forceContainsLatestCalc, - messages, - moreToLoad, - scrollDirection, - validatedRange, - }) - - if (canMarkReadForThreadWindow) { - actions.markThreadAsRead() - } - } - - const pagination = messageIDControl - ? null - : scrollDirectionToPagination(scrollDirection, numberOfMessagesToLoad) - try { - const results = await loadThreadNonblock({ - conversationIDKey, - knownRemotes, - messageIDControl, - onCachedThread: thread => onGotThread(thread, 'cached'), - onFullThread: thread => onGotThread(thread, 'full'), - onThreadStatus: status => { - logger.info( - `loadMoreMessages: thread status received: convID: ${conversationIDKey} typ: ${status.typ}` - ) - if (isCurrentThreadLoad()) { - onThreadLoadStatus?.(conversationIDKey, status.typ) - } - }, - pagination, - reason: threadLoadReasonToRPCReason(reason), - waitingKey: loadingKey, - }) - if (!isCurrentThreadLoad()) { - return - } - if (actions.getSnapshot().meta.conversationIDKey === conversationIDKey) { - actions.updateMeta({offline: results.offline}) - } - } catch (error) { - if (!isCurrentThreadLoad()) { - return - } - if (error instanceof RPCError) { - logger.warn(`loadMoreMessages: error: ${error.desc}`) - if (error.code === T.RPCGen.StatusCode.scchatnotinteam) { - // We're no longer in this conv's team. Clear the persisted last-route - // (ui.routeState2) so app startup doesn't keep restoring and reloading - // this conv, which would re-trigger this error on every launch. - persistRoute(true, true, () => useConfigState.getState().startup.loaded) - navigateToInbox(true, 'maybeKickedFromTeam') - } - if (error.code !== T.RPCGen.StatusCode.scteamreaderror) { - throw error - } - } - } - } - - ignorePromise(f()) -} - export const useConversationThreadSelector = ( selector: (snapshot: ConversationThreadState) => TValue ) => { @@ -903,6 +337,16 @@ export const useConversationThreadStore = () => { return store } +// Reads the meta for the current thread from its single owner (the inbox metadata +// store). Pass a narrow selector (wrap object results in C.useShallow) so render-hot +// callers don't re-render on unrelated meta churn (e.g. draft updates). +export const useThreadMeta = ( + selector: (meta: T.Immutable) => TValue +): TValue => { + const id = useConversationThreadID() + return useInboxMetadataState(s => selector(s.metas.get(id) ?? emptyConversationMeta)) +} + type ConversationThreadProviderProps = React.PropsWithChildren<{ id: T.Chat.ConversationIDKey }> @@ -993,7 +437,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => logger.info(`marking read messages ${id} failed due to no id`) return } - if (snapshot.meta.conversationIDKey === id && readMsgID === snapshot.meta.readMsgID) { + if (readMsgID === getInboxConversationMeta(id)?.readMsgID) { logger.info(`marking read messages is noop bail: ${id} ${readMsgID}`) return } @@ -1066,7 +510,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => // moreToLoadForward true would drop live incoming messages and block mark-read. let containsLatest = false if (p.centered && p.forceContainsLatestCalc) { - const {maxVisibleMsgID} = s.meta + const {maxVisibleMsgID} = getMeta(id) const ordinal = findLast(s.messageOrdinals ?? [], o => !!s.messageMap.get(o)?.id) const message = ordinal ? s.messageMap.get(ordinal) : undefined containsLatest = !!message?.id && maxVisibleMsgID > 0 && message.id >= maxVisibleMsgID @@ -1141,32 +585,9 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => s.explodingMode = seconds }) if (!incoming) { - persistExplodingMode(id, getSnapshot().meta, seconds) + persistExplodingMode(id, getMeta(id), seconds) } }) - const setMeta = React.useEffectEvent((meta?: T.Chat.ConversationMeta) => { - updateThreadState(s => { - s.meta = T.castDraft(meta ?? Meta.makeConversationMeta()) - }) - if (meta) { - metasReceived([getSnapshot().meta]) - } - }) - const updateMeta = React.useEffectEvent((meta: Partial) => { - updateThreadState(s => { - Object.assign(s.meta, meta) - }) - const nextMeta = getSnapshot().meta - if (nextMeta.conversationIDKey === id) { - metasReceived([nextMeta]) - } - }) - const setParticipants = React.useEffectEvent((participants: T.Chat.ParticipantInfo) => { - updateThreadState(s => { - s.participants = T.castDraft(participants) - }) - participantInfoReceived(id, participants, getSnapshot().meta) - }) const setMarkAsUnread = React.useEffectEvent((readMsgID?: T.Chat.MessageID | false) => { if (readMsgID === false) { return @@ -1177,7 +598,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => return } const snapshot = getSnapshot() - const unreadLineID = readMsgID ? readMsgID : snapshot.meta.maxVisibleMsgID + const unreadLineID = readMsgID ? readMsgID : getMeta(id).maxVisibleMsgID let msgID = unreadLineID if (snapshot.messageMap.size) { @@ -1234,7 +655,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => revertDeleting() return } - if (snapshot.meta.conversationIDKey !== id) { + if (!getInboxConversationMeta(id)) { logger.warn('Deleting message w/ no meta') revertDeleting() return @@ -1253,7 +674,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => await postConversationDelete({ conversationIDKey: id, messageID: message.id, - tlfName: snapshot.meta.tlfname, + tlfName: getMeta(id).tlfname, }) } catch (error) { revertDeleting() @@ -1390,7 +811,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => conversationIDKey: id, messageID, outboxID, - tlfName: snapshot.meta.tlfname, + tlfName: getMeta(id).tlfname, }) } catch (error) { removeOptimisticReaction(localOutboxID) @@ -1403,15 +824,14 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => }) const unfurlRemove = React.useEffectEvent((messageID: T.Chat.MessageID) => { const f = async () => { - const snapshot = getSnapshot() - if (snapshot.meta.conversationIDKey !== id) { + if (!getInboxConversationMeta(id)) { logger.debug('unfurl remove no meta found, aborting!') return } await postConversationDelete({ conversationIDKey: id, messageID, - tlfName: snapshot.meta.tlfname, + tlfName: getMeta(id).tlfname, }) } ignorePromise(f()) @@ -1550,29 +970,23 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => } ) const [threadActions] = React.useState(() => { - const threadActionsHolder: {current?: ConversationThreadActions} = {} - const loadMoreMessagesImpl = (p: LoadMoreMessagesParams) => { - const actions = threadActionsHolder.current - if (actions) { - loadConversationThreadMessages(id, p, actions) - } - } - const throttledLoadMoreMessages = throttle(loadMoreMessagesImpl, 500) + const impl = (p: LoadMoreMessagesParams) => loadConversationThreadMessages(id, p, threadActions) + const throttled = throttle(impl, 500) // The throttle keeps only the last trailing call, so a centered or jump-to-recent // load issued between two other loads would be silently dropped — after // loadMessagesCentered already cleared the thread. Run those immediately instead. const loadMoreMessages: LoadMoreMessages = Object.assign( (p: LoadMoreMessagesParams) => { if (p.centeredMessageID || p.messageIDControl || p.reason === 'jump to recent') { - throttledLoadMoreMessages.cancel() - loadMoreMessagesImpl(p) + throttled.cancel() + impl(p) } else { - throttledLoadMoreMessages(p) + throttled(p) } }, { cancel: () => { - throttledLoadMoreMessages.cancel() + throttled.cancel() }, } ) @@ -1603,8 +1017,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => setMarkReadBlocked, setMessageErrored, setMessageSubmitState, - setMeta, - setParticipants, setTyping, showUnfurlPrompt, startAttachmentDownload, @@ -1614,11 +1026,9 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => updateAttachmentDownloadProgress, updateAttachmentUploadProgress, updateCoinFlipStatuses, - updateMeta, updateOptimisticReactionDecorated, updateReactions, } - threadActionsHolder.current = threadActions return threadActions }) React.useEffect(() => { @@ -1626,270 +1036,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => threadActions.loadMoreMessages.cancel() } }, [threadActions]) - const inboxParticipants = useInboxMetadataState(s => s.participants.get(id)) - React.useEffect(() => { - if (!inboxParticipants) { - return - } - updateThreadState(s => { - s.participants = T.castDraft(inboxParticipants) - }) - }, [inboxParticipants]) - useEngineActionListener('chat.1.NotifyChat.NewChatActivity', action => { - const {activity} = action.payload.params - switch (activity.activityType) { - case T.RPCChat.ChatActivityType.incomingMessage: { - const {incomingMessage} = activity - const conversationIDKey = T.Chat.conversationIDToKey(incomingMessage.convID) - if (conversationIDKey === id) { - applyInboxUIItemToThread(incomingMessage.conv, threadActions) - applyIncomingMessageToThread(conversationIDKey, incomingMessage, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.setStatus: { - const {setStatus} = activity - const conversationIDKey = setStatus.conv - ? T.Chat.stringToConversationIDKey(setStatus.conv.convID) - : T.Chat.noConversationIDKey - if (conversationIDKey === id) { - applyInboxUIItemToThread(setStatus.conv, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.readMessage: { - const {readMessage} = activity - const conversationIDKey = readMessage.conv - ? T.Chat.stringToConversationIDKey(readMessage.conv.convID) - : T.Chat.noConversationIDKey - if (conversationIDKey === id) { - applyInboxUIItemToThread(readMessage.conv, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.newConversation: { - const {newConversation} = activity - const conversationIDKey = newConversation.conv - ? T.Chat.stringToConversationIDKey(newConversation.conv.convID) - : T.Chat.noConversationIDKey - if (conversationIDKey === id) { - applyInboxUIItemToThread(newConversation.conv, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.setAppNotificationSettings: { - const {setAppNotificationSettings} = activity - if (T.Chat.conversationIDToKey(setAppNotificationSettings.convID) === id) { - threadActions.updateMeta(Meta.parseNotificationSettings(setAppNotificationSettings.settings)) - } - break - } - case T.RPCChat.ChatActivityType.messagesUpdated: { - const {messagesUpdated} = activity - const conversationIDKey = T.Chat.conversationIDToKey(messagesUpdated.convID) - if (conversationIDKey === id) { - applyMessagesUpdatedToThread(conversationIDKey, messagesUpdated, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.failedMessage: { - const {failedMessage} = activity - applyInboxUIItemToThread(failedMessage.conv, threadActions) - applyFailedMessageToThread(id, failedMessage, threadActions) - break - } - case T.RPCChat.ChatActivityType.reactionUpdate: { - const {reactionUpdate} = activity - const conversationIDKey = T.Chat.conversationIDToKey(reactionUpdate.convID) - if (conversationIDKey === id) { - applyReactionUpdateToThread(reactionUpdate, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.expunge: { - const {expunge} = activity - const conversationIDKey = T.Chat.conversationIDToKey(expunge.convID) - if (conversationIDKey === id) { - applyExpungeToThread(expunge, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.ephemeralPurge: { - const {ephemeralPurge} = activity - const conversationIDKey = T.Chat.conversationIDToKey(ephemeralPurge.convID) - if (conversationIDKey === id) { - applyEphemeralPurgeToThread(ephemeralPurge, threadActions) - } - break - } - default: - } - }) - useEngineActionListener('keybase.1.gregorUI.pushState', action => { - const items = (action.payload.params.state.items ?? []).reduce< - Array<{md: T.RPCGen.Gregor1.Metadata; item: T.RPCGen.Gregor1.Item}> - >((arr, {md, item}) => { - if (md && item) { - arr.push({item, md}) - } - return arr - }, []) - const seconds = getExplodingModeFromGregorItems(id, items) - if (seconds !== undefined) { - threadActions.setExplodingMode(seconds, true) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatConvUpdate', action => { - const {conv} = action.payload.params - const conversationIDKey = conv ? T.Chat.stringToConversationIDKey(conv.convID) : T.Chat.noConversationIDKey - if (conversationIDKey === id) { - applyInboxUIItemToThread(conv, threadActions) - } - }) - useEngineActionListener('chat.1.chatUi.chatInboxFailed', action => { - const {convID, error} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - const {meta, participants} = Meta.inboxUIItemErrorToConversationMetaAndParticipants( - error, - useCurrentUserState.getState().username, - threadActions.getSnapshot().meta - ) - if (meta) { - threadActions.setMeta(meta) - } - if (participants) { - threadActions.setParticipants(participants) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatSetConvSettings', action => { - const {conv, convID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - const newRole = conv?.convSettings?.minWriterRoleInfo?.role - const role = newRole && TeamsUtil.teamRoleByEnum[newRole] - const cannotWrite = conv?.convSettings?.minWriterRoleInfo?.cannotWrite || false - if (role) { - threadActions.updateMeta({cannotWrite, minWriterRole: role}) - } else { - logger.warn( - `got NotifyChat.ChatSetConvSettings with no valid minWriterRole for convID ${id}. The local version may be out of date.` - ) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatSetConvRetention', action => { - const {conv, convID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - if (!conv) { - logger.warn('onChatSetConvRetention: no conv given') - return - } - const meta = Meta.inboxUIItemToConversationMeta(conv) - if (!meta) { - logger.warn(`onChatSetConvRetention: no meta found for ${convID.toString()}`) - return - } - applyConversationMetaToThread(meta, threadActions) - }) - useEngineActionListener('chat.1.NotifyChat.ChatSetTeamRetention', action => { - const meta = (action.payload.params.convs ?? []).reduce( - (found, conv) => { - if (found) { - return found - } - const meta = Meta.inboxUIItemToConversationMeta(conv) - return meta?.conversationIDKey === id ? meta : undefined - }, - undefined - ) - if (meta) { - applyConversationMetaToThread(meta, threadActions) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatParticipantsInfo', action => { - const participants = action.payload.params.participants?.[id] - if (participants) { - threadActions.setParticipants(Common.uiParticipantsToParticipantInfo(participants)) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatRequestInfo', action => { - const {convID, info, msgID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - const requestInfo = Message.uiRequestInfoToChatRequestInfo(info) - if (!requestInfo) { - logger.error( - `got 'NotifyChat.ChatRequestInfo' with no valid requestInfo for convID ${id} messageID: ${msgID}. The local version may be absent or out of date.` - ) - return - } - threadActions.receiveRequestInfo(T.Chat.numberToMessageID(msgID), requestInfo) - }) - useEngineActionListener('chat.1.NotifyChat.ChatPaymentInfo', action => { - const {convID, info, msgID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - const paymentInfo = Message.uiPaymentInfoToChatPaymentInfo([info]) - if (!paymentInfo) { - logger.error( - `got 'NotifyChat.ChatPaymentInfo' with no valid paymentInfo for convID ${id} messageID: ${msgID}. The local version may be absent or out of date.` - ) - return - } - threadActions.receivePaymentInfo(T.Chat.numberToMessageID(msgID), paymentInfo) - }) - useEngineActionListener('chat.1.NotifyChat.ChatPromptUnfurl', action => { - const {convID, domain, msgID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - threadActions.showUnfurlPrompt(T.Chat.numberToMessageID(msgID), domain) - }) - useEngineActionListener('chat.1.chatUi.chatCoinFlipStatus', action => { - const statuses = action.payload.params.statuses?.filter(status => { - return T.Chat.stringToConversationIDKey(status.convID) === id - }) - if (statuses?.length) { - threadActions.updateCoinFlipStatuses(statuses) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatTypingUpdate', action => { - action.payload.params.typingUpdates?.forEach(update => { - if (T.Chat.conversationIDToKey(update.convID) === id) { - threadActions.setTyping(new Set(update.typers?.map(typer => typer.username))) - } - }) - }) - useEngineActionListener('chat.1.NotifyChat.ChatAttachmentDownloadProgress', action => { - const {bytesComplete, bytesTotal, convID, msgID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) === id) { - threadActions.updateAttachmentDownloadProgress(msgID, bytesComplete, bytesTotal) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatAttachmentDownloadComplete', action => { - const {convID, msgID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) === id) { - threadActions.completeAttachmentDownload(msgID) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatAttachmentUploadStart', action => { - const {convID, outboxID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) === id) { - threadActions.updateAttachmentUploadProgress(outboxID) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatAttachmentUploadProgress', action => { - const {bytesComplete, bytesTotal, convID, outboxID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) === id) { - threadActions.updateAttachmentUploadProgress(outboxID, bytesComplete, bytesTotal) - } - }) + useThreadEngineListeners(id, threadActions) return ( { export const useConversationThreadSelectedConversation = () => { const conversationIDKey = useConversationThreadID() const loadMoreMessages = useConversationThreadLoadMoreMessages() - const participantInfo = useConversationThreadSelector(s => s.participants) const selectedConversation: SelectedConversation = (options?: SelectedConversationOptions) => { const {skipThreadLoad, ...loadStatusOptions} = options ?? {} @@ -2066,6 +1212,7 @@ export const useConversationThreadSelectedConversation = () => { unboxRows([conversationIDKey]) const username = useCurrentUserState.getState().username + const participantInfo = getInboxConversationParticipants(conversationIDKey) ?? emptyParticipantInfo const otherParticipants = Meta.getRowParticipants(participantInfo, username || '') if (otherParticipants.length === 1) { const otherUsername = otherParticipants[0] || '' diff --git a/shared/chat/conversation/thread-engine.tsx b/shared/chat/conversation/thread-engine.tsx new file mode 100644 index 000000000000..d8fed1eaf88f --- /dev/null +++ b/shared/chat/conversation/thread-engine.tsx @@ -0,0 +1,374 @@ +import * as Common from '@/constants/chat/common' +import * as Message from '@/constants/chat/message' +import * as T from '@/constants/types' +import logger from '@/logger' +import {useConfigState} from '@/stores/config' +import {useEngineActionListener} from '@/engine/action-listener' +import { + getCurrentUser, + getExplodingModeFromGregorItems, + getLastOrdinalFromSnapshot, + getOrdinalForMessageIDInSnapshot, +} from './thread-load' +import type {ConversationThreadActions} from './thread-context' + +export const applyMessagesUpdatedToThread = ( + conversationIDKey: T.Chat.ConversationIDKey, + messagesUpdated: T.RPCChat.MessagesUpdated, + actions: ConversationThreadActions +) => { + if (!messagesUpdated.updates) return + const snapshot = actions.getSnapshot() + const activelyLookingAtThread = Common.isUserActivelyLookingAtThisThread(conversationIDKey) + if (!snapshot.loaded && !activelyLookingAtThread) { + return + } + + const {username, devicename} = getCurrentUser() + const messages = messagesUpdated.updates.flatMap(uimsg => { + if (!Message.getMessageID(uimsg)) return [] + const message = Message.uiMessageToMessage( + conversationIDKey, + uimsg, + username, + () => getLastOrdinalFromSnapshot(actions.getSnapshot()), + devicename + ) + return message ? [message] : [] + }) + if (messages.length === 0) { + return + } + actions.addMessages(messages, {liveUpdate: true, markAsRead: activelyLookingAtThread}) +} + +export const applyIncomingMutationToThread = ( + conversationIDKey: T.Chat.ConversationIDKey, + valid: T.RPCChat.UIMessageValid, + modifiedMessage: T.RPCChat.UIMessage | null | undefined, + actions: ConversationThreadActions +) => { + const body = valid.messageBody + logger.info(`Got chat incoming message of messageType: ${body.messageType}`) + const mutationOrdinal = T.Chat.numberToOrdinal(valid.messageID) + if (actions.getSnapshot().messageMap.has(mutationOrdinal)) { + actions.deleteMessages({liveUpdate: true, ordinals: [mutationOrdinal]}) + } + + switch (body.messageType) { + case T.RPCChat.MessageType.edit: + if (modifiedMessage) { + const {username, devicename} = getCurrentUser() + const modMessage = Message.uiMessageToMessage( + conversationIDKey, + modifiedMessage, + username, + () => getLastOrdinalFromSnapshot(actions.getSnapshot()), + devicename + ) + if (modMessage) { + actions.addMessages([modMessage], {liveUpdate: true}) + } + } + return true + case T.RPCChat.MessageType.delete: { + const {delete: d} = body + if (d.messageIDs) { + const messageIDs = T.Chat.numbersToMessageIDs(d.messageIDs) + const snapshot = actions.getSnapshot() + const isExplodeNow = messageIDs.some(id => { + const ordinal = getOrdinalForMessageIDInSnapshot(snapshot, id) + const message = ordinal ? snapshot.messageMap.get(ordinal) : undefined + return !!((message?.type === 'text' || message?.type === 'attachment') && message.exploding) + }) + + if (isExplodeNow) { + actions.explodeMessages(messageIDs, valid.senderUsername, true) + } else { + actions.deleteMessages({liveUpdate: true, messageIDs}) + } + } + return true + } + default: + return false + } +} + +export const applyIncomingMessageToThread = ( + conversationIDKey: T.Chat.ConversationIDKey, + incomingMessage: T.RPCChat.IncomingMessage, + actions: ConversationThreadActions +) => { + const snapshot = actions.getSnapshot() + const activelyLookingAtThread = Common.isUserActivelyLookingAtThisThread(conversationIDKey) + if (!snapshot.loaded && !activelyLookingAtThread) { + return + } + const {message: cMsg, modifiedMessage} = incomingMessage + const {username, devicename} = getCurrentUser() + + if ( + cMsg.state === T.RPCChat.MessageUnboxedState.outbox && + cMsg.outbox.messageType === T.RPCChat.MessageType.reaction + ) { + actions.updateOptimisticReactionDecorated( + T.Chat.stringToOutboxID(cMsg.outbox.outboxID), + cMsg.outbox.decoratedTextBody ?? cMsg.outbox.body + ) + return + } + + if (cMsg.state === T.RPCChat.MessageUnboxedState.valid) { + const {valid} = cMsg + const {messageType} = valid.messageBody + if ( + (messageType === T.RPCChat.MessageType.edit || messageType === T.RPCChat.MessageType.delete) && + applyIncomingMutationToThread(conversationIDKey, valid, modifiedMessage, actions) + ) { + return + } + } + + const message = Message.uiMessageToMessage( + conversationIDKey, + cMsg, + username, + () => getLastOrdinalFromSnapshot(actions.getSnapshot()), + devicename + ) + if (!message) return + + if ( + cMsg.state === T.RPCChat.MessageUnboxedState.valid && + cMsg.valid.messageBody.messageType === T.RPCChat.MessageType.attachmentuploaded && + message.type === 'attachment' + ) { + const placeholderID = cMsg.valid.messageBody.attachmentuploaded.messageID + const snapshot = actions.getSnapshot() + const ordinal = getOrdinalForMessageIDInSnapshot(snapshot, T.Chat.numberToMessageID(placeholderID)) + const existing = ordinal ? snapshot.messageMap.get(ordinal) : undefined + if (ordinal && existing) { + actions.addMessages([Message.upgradeMessage(existing, {...message, ordinal})], { + liveUpdate: true, + markAsRead: activelyLookingAtThread, + }) + } else { + if (snapshot.moreToLoadForward) { + return + } + actions.addMessages([message], {liveUpdate: true, markAsRead: activelyLookingAtThread}) + } + } else { + if (actions.getSnapshot().moreToLoadForward) { + return + } + actions.addMessages([message], {liveUpdate: true, markAsRead: activelyLookingAtThread}) + } +} + +export const applyFailedMessageToThread = ( + conversationIDKey: T.Chat.ConversationIDKey, + failedMessage: T.RPCChat.FailedMessageInfo, + actions: ConversationThreadActions +) => { + const {outboxRecords} = failedMessage + if (!outboxRecords) return + for (const outboxRecord of outboxRecords) { + if (T.Chat.conversationIDToKey(outboxRecord.convID) !== conversationIDKey) { + continue + } + const s = outboxRecord.state + if (s.state !== T.RPCChat.OutboxStateType.error) { + continue + } + const {error} = s + const outboxID = T.Chat.rpcOutboxIDToOutboxID(outboxRecord.outboxID) + actions.setMessageErrored(outboxID, Message.rpcErrorToString(error), error.typ) + } +} + +export const applyReactionUpdateToThread = ( + reactionUpdate: T.RPCChat.ReactionUpdateNotif, + actions: ConversationThreadActions +) => { + if (!reactionUpdate.reactionUpdates || reactionUpdate.reactionUpdates.length === 0) { + return + } + const updates = reactionUpdate.reactionUpdates.map(ru => ({ + reactions: Message.reactionMapToReactions(ru.reactions), + targetMsgID: T.Chat.numberToMessageID(ru.targetMsgID), + })) + actions.updateReactions(updates) +} + +export const applyExpungeToThread = (expunge: T.RPCChat.ExpungeInfo, actions: ConversationThreadActions) => { + const deletableMessageTypes = + useConfigState.getState().chatDeletableByDeleteHistory || Common.allMessageTypes + actions.deleteMessages({ + deletableMessageTypes, + liveUpdate: true, + upToMessageID: T.Chat.numberToMessageID(expunge.expunge.upto), + }) +} + +export const applyEphemeralPurgeToThread = ( + ephemeralPurge: T.RPCChat.EphemeralPurgeNotifInfo, + actions: ConversationThreadActions +) => { + const messageIDs = ephemeralPurge.msgs?.reduce>((arr, msg) => { + const msgID = Message.getMessageID(msg) + if (msgID) { + arr.push(msgID) + } + return arr + }, []) + if (messageIDs) { + actions.explodeMessages(messageIDs, undefined, true) + } +} + +export const useThreadEngineListeners = ( + id: T.Chat.ConversationIDKey, + threadActions: ConversationThreadActions +): void => { + useEngineActionListener('chat.1.NotifyChat.NewChatActivity', action => { + const {activity} = action.payload.params + switch (activity.activityType) { + case T.RPCChat.ChatActivityType.incomingMessage: { + const {incomingMessage} = activity + const conversationIDKey = T.Chat.conversationIDToKey(incomingMessage.convID) + if (conversationIDKey === id) { + applyIncomingMessageToThread(conversationIDKey, incomingMessage, threadActions) + } + break + } + case T.RPCChat.ChatActivityType.messagesUpdated: { + const {messagesUpdated} = activity + const conversationIDKey = T.Chat.conversationIDToKey(messagesUpdated.convID) + if (conversationIDKey === id) { + applyMessagesUpdatedToThread(conversationIDKey, messagesUpdated, threadActions) + } + break + } + case T.RPCChat.ChatActivityType.failedMessage: { + const {failedMessage} = activity + applyFailedMessageToThread(id, failedMessage, threadActions) + break + } + case T.RPCChat.ChatActivityType.reactionUpdate: { + const {reactionUpdate} = activity + const conversationIDKey = T.Chat.conversationIDToKey(reactionUpdate.convID) + if (conversationIDKey === id) { + applyReactionUpdateToThread(reactionUpdate, threadActions) + } + break + } + case T.RPCChat.ChatActivityType.expunge: { + const {expunge} = activity + const conversationIDKey = T.Chat.conversationIDToKey(expunge.convID) + if (conversationIDKey === id) { + applyExpungeToThread(expunge, threadActions) + } + break + } + case T.RPCChat.ChatActivityType.ephemeralPurge: { + const {ephemeralPurge} = activity + const conversationIDKey = T.Chat.conversationIDToKey(ephemeralPurge.convID) + if (conversationIDKey === id) { + applyEphemeralPurgeToThread(ephemeralPurge, threadActions) + } + break + } + default: + } + }) + useEngineActionListener('keybase.1.gregorUI.pushState', action => { + const items = (action.payload.params.state.items ?? []).reduce< + Array<{md: T.RPCGen.Gregor1.Metadata; item: T.RPCGen.Gregor1.Item}> + >((arr, {md, item}) => { + if (md && item) { + arr.push({item, md}) + } + return arr + }, []) + const seconds = getExplodingModeFromGregorItems(id, items) + if (seconds !== undefined) { + threadActions.setExplodingMode(seconds, true) + } + }) + useEngineActionListener('chat.1.NotifyChat.ChatRequestInfo', action => { + const {convID, info, msgID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) !== id) { + return + } + const requestInfo = Message.uiRequestInfoToChatRequestInfo(info) + if (!requestInfo) { + logger.error( + `got 'NotifyChat.ChatRequestInfo' with no valid requestInfo for convID ${id} messageID: ${msgID}. The local version may be absent or out of date.` + ) + return + } + threadActions.receiveRequestInfo(T.Chat.numberToMessageID(msgID), requestInfo) + }) + useEngineActionListener('chat.1.NotifyChat.ChatPaymentInfo', action => { + const {convID, info, msgID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) !== id) { + return + } + const paymentInfo = Message.uiPaymentInfoToChatPaymentInfo([info]) + if (!paymentInfo) { + logger.error( + `got 'NotifyChat.ChatPaymentInfo' with no valid paymentInfo for convID ${id} messageID: ${msgID}. The local version may be absent or out of date.` + ) + return + } + threadActions.receivePaymentInfo(T.Chat.numberToMessageID(msgID), paymentInfo) + }) + useEngineActionListener('chat.1.NotifyChat.ChatPromptUnfurl', action => { + const {convID, domain, msgID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) !== id) { + return + } + threadActions.showUnfurlPrompt(T.Chat.numberToMessageID(msgID), domain) + }) + useEngineActionListener('chat.1.chatUi.chatCoinFlipStatus', action => { + const statuses = action.payload.params.statuses?.filter(status => { + return T.Chat.stringToConversationIDKey(status.convID) === id + }) + if (statuses?.length) { + threadActions.updateCoinFlipStatuses(statuses) + } + }) + useEngineActionListener('chat.1.NotifyChat.ChatTypingUpdate', action => { + action.payload.params.typingUpdates?.forEach(update => { + if (T.Chat.conversationIDToKey(update.convID) === id) { + threadActions.setTyping(new Set(update.typers?.map(typer => typer.username))) + } + }) + }) + useEngineActionListener('chat.1.NotifyChat.ChatAttachmentDownloadProgress', action => { + const {bytesComplete, bytesTotal, convID, msgID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) === id) { + threadActions.updateAttachmentDownloadProgress(msgID, bytesComplete, bytesTotal) + } + }) + useEngineActionListener('chat.1.NotifyChat.ChatAttachmentDownloadComplete', action => { + const {convID, msgID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) === id) { + threadActions.completeAttachmentDownload(msgID) + } + }) + useEngineActionListener('chat.1.NotifyChat.ChatAttachmentUploadStart', action => { + const {convID, outboxID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) === id) { + threadActions.updateAttachmentUploadProgress(outboxID) + } + }) + useEngineActionListener('chat.1.NotifyChat.ChatAttachmentUploadProgress', action => { + const {bytesComplete, bytesTotal, convID, outboxID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) === id) { + threadActions.updateAttachmentUploadProgress(outboxID, bytesComplete, bytesTotal) + } + }) +} diff --git a/shared/chat/conversation/thread-load-status-context.test.tsx b/shared/chat/conversation/thread-load-status-context.test.tsx index ebcd485d3602..797ac75ffebc 100644 --- a/shared/chat/conversation/thread-load-status-context.test.tsx +++ b/shared/chat/conversation/thread-load-status-context.test.tsx @@ -14,20 +14,6 @@ import { } from './thread-load-status-context' import {ConversationThreadProvider} from './thread-context' -jest.mock('@/chat/inbox/rows-state', () => ({ - flushInboxRowUpdates: jest.fn(), - getInboxRowTrustedState: jest.fn(() => undefined), - queueInboxRowUpdate: jest.fn(), - setInboxRowTrustedState: jest.fn(), - syncInboxRowBadgeState: jest.fn(), - syncInboxRowsFromLayout: jest.fn(), - syncInboxRowsFromMetaAndParticipants: jest.fn(), - syncInboxRowsFromMetas: jest.fn(), - syncInboxRowsFromParticipantMap: jest.fn(), - syncInboxRowsFromParticipants: jest.fn(), - updateInboxRowTyping: jest.fn(), -})) - const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) const otherConvID = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8])) diff --git a/shared/chat/conversation/thread-load.tsx b/shared/chat/conversation/thread-load.tsx new file mode 100644 index 000000000000..de4121253cad --- /dev/null +++ b/shared/chat/conversation/thread-load.tsx @@ -0,0 +1,320 @@ +import * as Common from '@/constants/chat/common' +import * as Message from '@/constants/chat/message' +import * as Meta from '@/constants/chat/meta' +import * as Strings from '@/constants/strings' +import * as T from '@/constants/types' +import {navigateToInbox} from '@/constants/router' +import logger from '@/logger' +import {findLast} from '@/util/arrays' +import {ignorePromise} from '@/constants/utils' +import {RPCError} from '@/util/errors' +import {persistRoute} from '@/util/storeless-actions' +import {uint8ArrayToString} from '@/util/uint8array' +import {useCurrentUserState} from '@/stores/current-user' +import {useConfigState} from '@/stores/config' +import {getOrdinalForMessageID} from './thread-message-state' +import {getInboxConversationMeta, updateInboxConversationMeta} from '@/chat/inbox/metadata' +import {loadThreadNonblock, threadLoadReasonToRPCReason} from './thread-rpc' +import type { + ConversationThreadActions, + ConversationThreadState, + LoadMoreMessagesParams, + ScrollDirection, +} from './thread-context' + +export const numMessagesOnInitialLoad = isMobile ? 20 : 100 +export const numMessagesOnScrollback = 100 + +const ignoreErrors = [ + T.RPCGen.StatusCode.scgenericapierror, + T.RPCGen.StatusCode.scapinetworkerror, + T.RPCGen.StatusCode.sctimeout, +] + +// The inbox metadata store is the single owner of conversation meta; fall back to +// an empty meta for reads that predate an unbox. +export const emptyConversationMeta = Meta.makeConversationMeta() +export const getMeta = (id: T.Chat.ConversationIDKey) => getInboxConversationMeta(id) ?? emptyConversationMeta + +export const getCurrentUser = () => { + const s = useCurrentUserState.getState() + return {devicename: s.deviceName, username: s.username} +} + +export const getExplodingModeFromGregorItems = ( + conversationIDKey: T.Chat.ConversationIDKey, + items: ReadonlyArray<{item: T.RPCGen.Gregor1.Item}> +) => { + const explodingItems = items.filter(i => i.item.category.startsWith(Common.explodingModeGregorKeyPrefix)) + if (!explodingItems.length) { + return 0 + } + const category = `${Common.explodingModeGregorKeyPrefix}${conversationIDKey}` + const item = explodingItems.find(i => i.item.category === category) + if (!item) { + // Other conversations have exploding modes but this one's category is absent, + // meaning it was dismissed: the mode is off. + return 0 + } + const secondsString = uint8ArrayToString(item.item.body) + const seconds = parseInt(secondsString, 10) + if (isNaN(seconds)) { + logger.warn(`Got dirty exploding mode ${secondsString} for category ${category}`) + return undefined + } + return seconds +} + +export const getExplodingModeFromConfig = (conversationIDKey: T.Chat.ConversationIDKey) => + getExplodingModeFromGregorItems(conversationIDKey, useConfigState.getState().gregorPushState) ?? 0 + +export const persistExplodingMode = ( + conversationIDKey: T.Chat.ConversationIDKey, + meta: T.Chat.ConversationMeta, + seconds: number +) => { + const f = async () => { + logger.info(`Setting exploding mode for conversation ${conversationIDKey} to ${seconds}`) + const category = `${Common.explodingModeGregorKeyPrefix}${conversationIDKey}` + const convRetention = Meta.getEffectiveRetentionPolicy(meta) + try { + if (seconds === 0 || seconds === convRetention.seconds) { + await T.RPCGen.gregorDismissCategoryRpcPromise({category}) + } else { + await T.RPCGen.gregorUpdateCategoryRpcPromise({ + body: seconds.toString(), + category, + dtime: {offset: 0, time: 0}, + }) + logger.info(`Successfully set exploding mode for conversation ${conversationIDKey} to ${seconds}`) + } + } catch (error) { + if (error instanceof RPCError) { + if (seconds !== 0) { + logger.error( + `Failed to set exploding mode for conversation ${conversationIDKey} to ${seconds}. Service responded with: ${error.message}` + ) + } else { + logger.error( + `Failed to unset exploding mode for conversation ${conversationIDKey}. Service responded with: ${error.message}` + ) + } + if (ignoreErrors.includes(error.code)) { + return + } + } + throw error + } + } + ignorePromise(f()) +} + +export const getClientPrevFromSnapshot = (snapshot: ConversationThreadState): T.Chat.MessageID => { + const ordinal = findLast(snapshot.messageOrdinals ?? [], o => { + const m = snapshot.messageMap.get(o) + return !!m?.id + }) + const message = ordinal ? snapshot.messageMap.get(ordinal) : undefined + return message?.id || T.Chat.numberToMessageID(0) +} + +export const getLastOrdinalFromSnapshot = (snapshot: ConversationThreadState) => + snapshot.messageOrdinals?.at(-1) ?? T.Chat.numberToOrdinal(0) + +export const getOrdinalForMessageIDInSnapshot = ( + snapshot: ConversationThreadState, + messageID: T.Chat.MessageID +) => + getOrdinalForMessageID( + snapshot.messageMap, + snapshot.pendingOutboxToOrdinal, + messageID, + snapshot.messageIDToOrdinal + ) + +export const scrollDirectionToPagination = ( + scrollDirection: ScrollDirection, + numberOfMessagesToLoad: number +) => { + const pagination = { + last: false, + next: '', + num: numberOfMessagesToLoad, + previous: '', + } + switch (scrollDirection) { + case 'none': + break + case 'back': + pagination.next = 'deadbeef' + break + case 'forward': + pagination.previous = 'deadbeef' + } + return pagination +} + +export const loadConversationThreadMessages = ( + conversationIDKey: T.Chat.ConversationIDKey, + p: LoadMoreMessagesParams, + actions: ConversationThreadActions +) => { + if (!T.Chat.isValidConversationIDKey(conversationIDKey)) { + return + } + const {scrollDirection = 'none', numberOfMessagesToLoad = numMessagesOnInitialLoad} = p + const { + allowMarkAsRead = true, + reason, + forceContainsLatestCalc, + messageIDControl, + knownRemotes, + centeredMessageID, + isThreadLoadCurrent, + onThreadLoadStatus, + } = p + const isCurrentThreadLoad = () => isThreadLoadCurrent?.() ?? true + + const f = async () => { + if (!isCurrentThreadLoad()) { + logger.info('loadMoreMessages: bail: stale mounted thread load') + return + } + + if (!conversationIDKey || !T.Chat.isValidConversationIDKey(conversationIDKey)) { + logger.info('loadMoreMessages: bail: no conversationIDKey') + return + } + + const loadStartedSnapshot = actions.getSnapshot() + const currentMeta = getMeta(conversationIDKey) + if (currentMeta.membershipType === 'youAreReset' || currentMeta.rekeyers.size > 0) { + logger.info('loadMoreMessages: bail: we are reset') + return + } + const loadStartedLiveUpdateVersion = loadStartedSnapshot.liveUpdateVersion + const protectLoadedFocusRefresh = + loadStartedSnapshot.loaded && + scrollDirection === 'none' && + !centeredMessageID && + !messageIDControl && + (reason === 'focused' || reason === 'tab selected') + logger.info( + `loadMoreMessages: calling rpc convo: ${conversationIDKey} num: ${numberOfMessagesToLoad} reason: ${reason}` + ) + + const loadingKey = Strings.waitingKeyChatThreadLoad(conversationIDKey) + let reconciled = false + const onGotThread = (thread: string, why: string) => { + if (!thread) { + return + } + if (!isCurrentThreadLoad()) { + logger.info(`loadMoreMessages: stale response ignored: ${why}`) + return + } + if ( + protectLoadedFocusRefresh && + actions.getSnapshot().liveUpdateVersion !== loadStartedLiveUpdateVersion + ) { + logger.info( + `loadMoreMessages: stale response ignored after live update: ${why} reason=${reason} convID=${conversationIDKey}` + ) + return + } + + const {username, devicename} = getCurrentUser() + const {messages, pagination} = Message.parseUIMessagesJSON( + conversationIDKey, + thread, + username, + devicename, + () => getLastOrdinalFromSnapshot(actions.getSnapshot()) + ) + const moreToLoad = pagination ? !pagination.last : true + const canMarkReadForThreadWindow = + allowMarkAsRead && + !centeredMessageID && + !messageIDControl && + scrollDirection !== 'back' && + reason !== 'findNewestConversation' && + reason !== 'findNewestConversationFromLayout' + let validatedRange: {from: T.Chat.Ordinal; to: T.Chat.Ordinal} | undefined + if (messages.length) { + if (scrollDirection === 'none' && !reconciled) { + const ords = messages + .filter(m => m.conversationMessage !== false && m.type !== 'deleted') + .map(m => m.ordinal) + if (ords.length > 0) { + validatedRange = { + from: Math.min(...ords) as T.Chat.Ordinal, + to: Math.max(...ords) as T.Chat.Ordinal, + } + } + reconciled = true + } + } + actions.applyThreadLoad({ + centered: !!centeredMessageID, + disableActiveMarkRead: !allowMarkAsRead || !!centeredMessageID || !!messageIDControl, + enableActiveMarkRead: canMarkReadForThreadWindow, + forceContainsLatestCalc, + messages, + moreToLoad, + scrollDirection, + validatedRange, + }) + + if (canMarkReadForThreadWindow) { + actions.markThreadAsRead() + } + } + + const pagination = messageIDControl + ? null + : scrollDirectionToPagination(scrollDirection, numberOfMessagesToLoad) + try { + const results = await loadThreadNonblock({ + conversationIDKey, + knownRemotes, + messageIDControl, + onCachedThread: thread => onGotThread(thread, 'cached'), + onFullThread: thread => onGotThread(thread, 'full'), + onThreadStatus: status => { + logger.info( + `loadMoreMessages: thread status received: convID: ${conversationIDKey} typ: ${status.typ}` + ) + if (isCurrentThreadLoad()) { + onThreadLoadStatus?.(conversationIDKey, status.typ) + } + }, + pagination, + reason: threadLoadReasonToRPCReason(reason), + waitingKey: loadingKey, + }) + if (!isCurrentThreadLoad()) { + return + } + updateInboxConversationMeta(conversationIDKey, {offline: results.offline}) + } catch (error) { + if (!isCurrentThreadLoad()) { + return + } + if (error instanceof RPCError) { + logger.warn(`loadMoreMessages: error: ${error.desc}`) + if (error.code === T.RPCGen.StatusCode.scchatnotinteam) { + // We're no longer in this conv's team. Clear the persisted last-route + // (ui.routeState2) so app startup doesn't keep restoring and reloading + // this conv, which would re-trigger this error on every launch. + persistRoute(true, true, () => useConfigState.getState().startup.loaded) + navigateToInbox(true, 'maybeKickedFromTeam') + } + if (error.code !== T.RPCGen.StatusCode.scteamreaderror) { + throw error + } + } + } + } + + ignorePromise(f()) +} diff --git a/shared/chat/create-channel/index.tsx b/shared/chat/create-channel/index.tsx index 987b1e3e8ae7..19cb4bea7dbd 100644 --- a/shared/chat/create-channel/index.tsx +++ b/shared/chat/create-channel/index.tsx @@ -55,14 +55,12 @@ const CreateChannel = (p: Props) => { onChangeText={props.onDescriptionChange} /> - - - - + ) diff --git a/shared/chat/emoji-picker/container.tsx b/shared/chat/emoji-picker/container.tsx index 152508416480..b871cb093915 100644 --- a/shared/chat/emoji-picker/container.tsx +++ b/shared/chat/emoji-picker/container.tsx @@ -1,6 +1,7 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import * as T from '@/constants/types' import type {LayoutEvent} from '@/common-adapters/box' import {useChatTeam} from '@/chat/conversation/team-hooks' @@ -113,12 +114,16 @@ const WrapperMobile = (props: Props) => { const canManageEmoji = useCanManageEmoji(conversationIDKey) return ( + // collapsable={false}: Fabric view-flattening would drop this Box2 (and its + // testID) from the native tree, breaking e2e lookup on iOS @@ -197,6 +202,7 @@ const EmojiPickerDesktopInner = (props: Props) => { props.small && styles.containerDesktopSmall, ])} gap="tiny" + testID={TestIDs.CHAT_EMOJI_PICKER} > { participantInfo: s.participants.get(conversationIDKey) ?? emptyParticipantInfo, })) ) - const inboxRow = useInboxRowsState( - C.useShallow(s => { - const big = s.rowsBig.get(conversationIDKey) - const small = s.rowsSmall.get(conversationIDKey) - return { - rowChannelname: big?.channelname ?? '', - rowParticipants: small?.participants ?? emptyParticipants, - rowTeamname: big?.teamname || small?.teamDisplayName || '', - } - }) - ) + const bigRow = useInboxRowBig(conversationIDKey) + const smallRow = useInboxRowSmall(conversationIDKey) + const inboxRow = { + rowChannelname: bigRow.channelname, + rowParticipants: smallRow.participants.length ? smallRow.participants : emptyParticipants, + rowTeamname: bigRow.teamname || smallRow.teamDisplayName, + } const { channelname: metaChannelname, descriptionDecorated, diff --git a/shared/chat/inbox/badge-state.test.ts b/shared/chat/inbox/badge-state.test.ts new file mode 100644 index 000000000000..46de21b179e2 --- /dev/null +++ b/shared/chat/inbox/badge-state.test.ts @@ -0,0 +1,46 @@ +/// +import * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' +import {getInboxBadge, syncInboxBadgeState, useInboxBadgeState} from './badge-state' + +const convA = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) +const convB = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8])) + +afterEach(() => { + resetAllStores() +}) + +test('syncInboxBadgeState applies badge and unread counts', () => { + syncInboxBadgeState({ + conversations: [ + {badgeCount: 2, convID: T.Chat.keyToConversationID(convA), unreadMessages: 5}, + {badgeCount: 0, convID: T.Chat.keyToConversationID(convB), unreadMessages: 3}, + ], + } as unknown as T.RPCGen.BadgeState) + + expect(getInboxBadge(convA)).toEqual({badgeCount: 2, unreadCount: 5}) + expect(getInboxBadge(convB)).toEqual({badgeCount: 0, unreadCount: 3}) + expect(useInboxBadgeState.getState().counts.get(convA)?.badgeCount).toBe(2) +}) + +test('conversations absent from a later sync are zeroed (full-replace)', () => { + syncInboxBadgeState({ + conversations: [ + {badgeCount: 2, convID: T.Chat.keyToConversationID(convA), unreadMessages: 5}, + {badgeCount: 1, convID: T.Chat.keyToConversationID(convB), unreadMessages: 1}, + ], + } as unknown as T.RPCGen.BadgeState) + + syncInboxBadgeState({ + conversations: [{badgeCount: 4, convID: T.Chat.keyToConversationID(convA), unreadMessages: 9}], + } as unknown as T.RPCGen.BadgeState) + + expect(getInboxBadge(convA)).toEqual({badgeCount: 4, unreadCount: 9}) + // convB dropped from payload, so it has no entry and reads as the {0,0} default + expect(useInboxBadgeState.getState().counts.has(convB)).toBe(false) + expect(getInboxBadge(convB)).toEqual({badgeCount: 0, unreadCount: 0}) +}) + +test('getInboxBadge defaults to zeroes for an unknown conversation', () => { + expect(getInboxBadge(convA)).toEqual({badgeCount: 0, unreadCount: 0}) +}) diff --git a/shared/chat/inbox/badge-state.tsx b/shared/chat/inbox/badge-state.tsx new file mode 100644 index 000000000000..dcf716817cbc --- /dev/null +++ b/shared/chat/inbox/badge-state.tsx @@ -0,0 +1,37 @@ +import * as T from '@/constants/types' +import * as Z from '@/util/zustand' + +export type BadgeCounts = {badgeCount: number; unreadCount: number} + +type State = T.Immutable<{ + counts: Map + dispatch: { + resetState: () => void + } +}> + +export const useInboxBadgeState = Z.createZustand('inboxBadge', () => ({ + counts: new Map(), + dispatch: {resetState: Z.defaultReset}, +})) + +const emptyCounts: BadgeCounts = {badgeCount: 0, unreadCount: 0} + +// Full-replace semantics: the map is rebuilt from the payload each sync, so a +// conversation absent from the payload gets no entry (reads default to {0,0}). +export const syncInboxBadgeState = (badgeState?: T.RPCGen.BadgeState) => { + if (!badgeState) { + return + } + const next = new Map() + badgeState.conversations?.forEach(conversation => { + const id = T.Chat.conversationIDToKey(conversation.convID) + next.set(id, {badgeCount: conversation.badgeCount, unreadCount: conversation.unreadMessages}) + }) + useInboxBadgeState.setState(s => { + s.counts = next + }) +} + +export const getInboxBadge = (id: T.Chat.ConversationIDKey): BadgeCounts => + useInboxBadgeState.getState().counts.get(id) ?? emptyCounts diff --git a/shared/chat/inbox/engine.test.tsx b/shared/chat/inbox/engine.test.tsx index bbf2c80f3896..60ed618248fc 100644 --- a/shared/chat/inbox/engine.test.tsx +++ b/shared/chat/inbox/engine.test.tsx @@ -2,24 +2,16 @@ import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {handleConvoEngineIncoming} from './engine' -import {getInboxConversationMeta, getInboxConversationParticipants, syncBadgeState} from './metadata' +import {getInboxConversationMeta, getInboxConversationParticipants} from './metadata' import {useConfigState} from '@/stores/config' -import { - syncInboxRowBadgeState, - syncInboxRowsFromParticipantMap, - updateInboxRowTyping, -} from '@/chat/inbox/rows-state' +import {updateInboxTyping} from '@/chat/inbox/typing-state' -jest.mock('@/chat/inbox/rows-state', () => ({ - getInboxRowTrustedState: jest.fn(() => undefined), - setInboxRowTrustedState: jest.fn(), - syncInboxRowBadgeState: jest.fn(), - syncInboxRowsFromLayout: jest.fn(), - syncInboxRowsFromMetaAndParticipants: jest.fn(), - syncInboxRowsFromMetas: jest.fn(), - syncInboxRowsFromParticipantMap: jest.fn(), - syncInboxRowsFromParticipants: jest.fn(), - updateInboxRowTyping: jest.fn(), +jest.mock('@/chat/inbox/badge-state', () => ({ + syncInboxBadgeState: jest.fn(), +})) + +jest.mock('@/chat/inbox/typing-state', () => ({ + updateInboxTyping: jest.fn(), })) afterEach(() => { @@ -371,7 +363,7 @@ test('global typing and participant updates route to inbox rows', () => { type: 'chat.1.NotifyChat.ChatTypingUpdate', } as never) - expect(updateInboxRowTyping).toHaveBeenCalledWith(typingUpdates) + expect(updateInboxTyping).toHaveBeenCalledWith(typingUpdates) const participantMap = { [T.Chat.conversationIDKeyToString(convID)]: [ @@ -385,7 +377,7 @@ test('global typing and participant updates route to inbox rows', () => { type: 'chat.1.NotifyChat.ChatParticipantsInfo', } as never) - expect(syncInboxRowsFromParticipantMap).toHaveBeenCalledWith(participantMap) + expect(getInboxConversationParticipants(convID)?.name).toEqual(['alice', 'bob']) }) test('global inbox failure routing stores error metadata and rekey participants', () => { @@ -417,35 +409,3 @@ test('global inbox failure routing stores error metadata and rekey participants' expect([...(meta?.rekeyers ?? [])]).toEqual(['bob']) expect(getInboxConversationParticipants(convID)?.name).toEqual(['alice', 'bob', 'charlie']) }) - -test('syncBadgeState delegates badge ownership to inbox rows', () => { - const badgeState = { - bigTeamBadgeCount: 0, - conversations: [ - { - badgeCount: 1, - convID: T.Chat.keyToConversationID(convID), - unreadMessages: 6, - }, - ], - homeTodoItems: 0, - inboxVers: 0, - newDevices: null, - newFollowers: 0, - newGitRepoGlobalUniqueIDs: [], - newTeamAccessRequestCount: 0, - newTeams: [], - newTlfs: 0, - rekeysNeeded: 0, - resetState: {active: false, endTime: 0}, - revokedDevices: null, - smallTeamBadgeCount: 1, - teamsWithResetUsers: null, - unverifiedEmails: 0, - unverifiedPhones: 0, - } as T.RPCGen.BadgeState - - syncBadgeState(badgeState) - - expect(syncInboxRowBadgeState).toHaveBeenCalledWith(badgeState) -}) diff --git a/shared/chat/inbox/engine.tsx b/shared/chat/inbox/engine.tsx index 8abfa90d0fb8..75c6591bf652 100644 --- a/shared/chat/inbox/engine.tsx +++ b/shared/chat/inbox/engine.tsx @@ -9,7 +9,7 @@ import {NotifyPopup} from '@/util/misc' import {showMain} from '@/util/storeless-actions' import {useShellState} from '@/stores/shell' import {useUsersState} from '@/stores/users' -import {updateInboxRowTyping} from '@/chat/inbox/rows-state' +import {updateInboxTyping} from '@/chat/inbox/typing-state' import { forceUnboxRowsForService, getInboxConversationMeta, @@ -243,7 +243,7 @@ export const handleConvoEngineIncoming = (action: EngineGen.Actions): ConvoEngin case 'chat.1.NotifyChat.NewChatActivity': return onNewChatActivity(action.payload.params.activity) case 'chat.1.NotifyChat.ChatTypingUpdate': { - updateInboxRowTyping(action.payload.params.typingUpdates) + updateInboxTyping(action.payload.params.typingUpdates) return handledConvoEngineIncoming() } case 'chat.1.NotifyChat.ChatSetConvRetention': { diff --git a/shared/chat/inbox/header-portal-state.tsx b/shared/chat/inbox/header-portal-state.tsx index 0d9a2e2e5e5b..b7fcb10c146d 100644 --- a/shared/chat/inbox/header-portal-state.tsx +++ b/shared/chat/inbox/header-portal-state.tsx @@ -1,5 +1,10 @@ import * as React from 'react' +// These never get an explicit logout reset: the ref callback that sets +// portalNode fires with null on unmount, and the effect that sets +// portalContent clears it on unmount too. Both owning components live only +// inside the logged-in chat tree, so logout unmounts them and clears this +// module state as a side effect - no extra reset wiring needed. let portalNode: HTMLElement | null = null let portalContent: React.ReactElement | null = null const listeners = new Set<() => void>() diff --git a/shared/chat/inbox/index.tsx b/shared/chat/inbox/index.tsx index d6c6b0956c21..8f0d557b957a 100644 --- a/shared/chat/inbox/index.tsx +++ b/shared/chat/inbox/index.tsx @@ -30,6 +30,7 @@ import * as TestIDs from '@/tests/e2e/shared/test-ids' import {createPortal} from 'react-dom' import SearchRow from './search-row' import {useOpenedRowState} from './row/opened-row-state' +import {useCurrentUserState} from '@/stores/current-user' import {Alert} from 'react-native' import {SafeAreaView as ScreensSafeAreaView} from 'react-native-screens/experimental' @@ -298,7 +299,15 @@ function InboxWithSearch(props: { refreshInbox?: T.Chat.ChatRootInboxRefresh }) { const search = useInboxSearch() - return + const username = useCurrentUserState(s => s.username) + return ( + + ) } // Desktop InboxBody @@ -595,8 +604,14 @@ function InboxBody(props: ControlledInboxProps) { } function Inbox(props: InboxProps) { + const username = useCurrentUserState(s => s.username) return props.search ? ( - + ) : ( ) diff --git a/shared/chat/inbox/layout-state.tsx b/shared/chat/inbox/layout-state.tsx index fc4ca5064692..ababe6afd22a 100644 --- a/shared/chat/inbox/layout-state.tsx +++ b/shared/chat/inbox/layout-state.tsx @@ -98,6 +98,51 @@ export const useInboxLayoutState = Z.createZustand('chat-inbox-layout', ( } }) +// Per-conversation index over the current layout so row hooks can do a cheap +// map lookup instead of scanning smallTeams/bigTeams. Built once per layout +// object and memoized on it (a new layout object replaces the prior on change), +// so selector bodies stay allocation-free after the first read. +export type SmallLayoutRow = T.Immutable +export type BigLayoutChannelRow = T.Immutable +type LayoutIndex = { + bigChannels: Map + small: Map +} +const layoutIndexCache = new WeakMap() + +const getLayoutIndex = (layout?: T.Immutable): LayoutIndex | undefined => { + if (!layout) { + return undefined + } + const existing = layoutIndexCache.get(layout) + if (existing) { + return existing + } + const small = new Map() + layout.smallTeams?.forEach(row => { + small.set(T.Chat.stringToConversationIDKey(row.convID), row) + }) + const bigChannels = new Map() + layout.bigTeams?.forEach(row => { + if (row.state === T.RPCChat.UIInboxBigTeamRowTyp.channel) { + bigChannels.set(T.Chat.stringToConversationIDKey(row.channel.convID), row.channel) + } + }) + const index: LayoutIndex = {bigChannels, small} + layoutIndexCache.set(layout, index) + return index +} + +export const getSmallLayoutRow = ( + s: {layout?: T.Immutable}, + id: T.Chat.ConversationIDKey +) => getLayoutIndex(s.layout)?.small.get(id) + +export const getBigLayoutChannelRow = ( + s: {layout?: T.Immutable}, + id: T.Chat.ConversationIDKey +) => getLayoutIndex(s.layout)?.bigChannels.get(id) + export const useInboxLayout = () => useInboxLayoutState( Z.useShallow(s => ({ diff --git a/shared/chat/inbox/metadata.test.tsx b/shared/chat/inbox/metadata.test.tsx index cbde2fbfde72..5d3b8203f826 100644 --- a/shared/chat/inbox/metadata.test.tsx +++ b/shared/chat/inbox/metadata.test.tsx @@ -1,8 +1,9 @@ /// +import * as Meta from '@/constants/chat/meta' import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '@/stores/config' -import {forceUnboxRowsForService} from './metadata' +import {forceUnboxRowsForService, getInboxConversationMeta, metasReceived} from './metadata' const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) @@ -46,3 +47,29 @@ test('forceUnboxRowsForService reruns once for requests made while an unbox is i resolvers[1]?.() await flushPromises() }) + +const makeMeta = (over: Partial): T.Chat.ConversationMeta => ({ + ...Meta.makeConversationMeta(), + conversationIDKey: convID, + ...over, +}) + +test('metasReceived version-gates: newer inbox version wins, older is ignored', () => { + metasReceived([makeMeta({inboxVersion: 2, snippet: 'v2', trustedState: 'trusted'})]) + metasReceived([makeMeta({inboxVersion: 1, snippet: 'v1', trustedState: 'trusted'})]) + expect(getInboxConversationMeta(convID)?.snippet).toBe('v2') + expect(getInboxConversationMeta(convID)?.inboxVersion).toBe(2) +}) + +test('metasReceived gates same-version updates (change swallowed without force)', () => { + metasReceived([makeMeta({inboxVersion: 2, snippet: 'orig', trustedState: 'trusted'})]) + metasReceived([makeMeta({inboxVersion: 2, snippet: 'changed', trustedState: 'trusted'})]) + expect(getInboxConversationMeta(convID)?.snippet).toBe('orig') +}) + +test('metasReceived force overwrites regardless of version', () => { + metasReceived([makeMeta({inboxVersion: 2, snippet: 'orig', trustedState: 'trusted'})]) + metasReceived([makeMeta({inboxVersion: 1, snippet: 'forced'})], undefined, {force: true}) + expect(getInboxConversationMeta(convID)?.snippet).toBe('forced') + expect(getInboxConversationMeta(convID)?.inboxVersion).toBe(1) +}) diff --git a/shared/chat/inbox/metadata.tsx b/shared/chat/inbox/metadata.tsx index 4a1f57ba7b49..95b1385fa249 100644 --- a/shared/chat/inbox/metadata.tsx +++ b/shared/chat/inbox/metadata.tsx @@ -16,16 +16,6 @@ import * as Z from '@/util/zustand' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useUsersState} from '@/stores/users' -import { - getInboxRowTrustedState, - setInboxRowTrustedState, - syncInboxRowBadgeState, - syncInboxRowsFromMetaAndParticipants, - syncInboxRowsFromLayout, - syncInboxRowsFromMetas, - syncInboxRowsFromParticipantMap, - syncInboxRowsFromParticipants, -} from '@/chat/inbox/rows-state' type InboxMetadataState = T.Immutable<{ metas: Map @@ -55,16 +45,21 @@ export const updateInboxConversationMeta = ( if (!oldMeta) { return } - metasReceived([ - { - ...oldMeta, - ...partial, - rekeyers: partial.rekeyers ? new Set(partial.rekeyers) : oldMeta.rekeyers, - resetParticipants: partial.resetParticipants - ? new Set(partial.resetParticipants) - : oldMeta.resetParticipants, - }, - ]) + // Already merged from the current meta, so bypass version gating. + metasReceived( + [ + { + ...oldMeta, + ...partial, + rekeyers: partial.rekeyers ? new Set(partial.rekeyers) : oldMeta.rekeyers, + resetParticipants: partial.resetParticipants + ? new Set(partial.resetParticipants) + : oldMeta.resetParticipants, + }, + ], + undefined, + {force: true} + ) } export const metaReceivedError = ( @@ -75,8 +70,6 @@ export const metaReceivedError = ( logger.info( `metaReceivedError: ignoring transient error for convID: ${conversationIDKey} error: ${error.message}` ) - // Allow a later unbox to retry; a row left 'requesting' is never re-requested. - setInboxRowTrustedState([conversationIDKey], 'untrusted') return } logger.info( @@ -90,43 +83,49 @@ export const metaReceivedError = ( if (!meta) { return } - metasReceived([meta]) + // Error metas share the prior inbox version but flip trustedState to 'error'; + // gating would swallow that, so force the overwrite. + metasReceived([meta], undefined, {force: true}) if (participants) { - participantInfoReceived(conversationIDKey, participants, meta) + participantInfoReceived(conversationIDKey, participants) } } export const participantInfoReceived = ( conversationIDKey: T.Chat.ConversationIDKey, - participantInfo: T.Chat.ParticipantInfo, - meta?: T.Chat.ConversationMeta + participantInfo: T.Chat.ParticipantInfo ) => { useInboxMetadataState.setState(s => { s.participants.set(conversationIDKey, T.castDraft(participantInfo)) }) - if (meta) { - syncInboxRowsFromMetaAndParticipants([{meta, participantInfo}]) - } } export const metasReceived = ( metas: ReadonlyArray, - removals?: ReadonlyArray + removals?: ReadonlyArray, + options?: {force?: boolean} ) => { + const force = options?.force ?? false + // Version-gate against the currently stored meta so a stale-version update + // can't clobber newer data (previously done by the thread store). Compute + // against getState() (not the immer draft) so updateMeta never sees a proxy. + const current = useInboxMetadataState.getState().metas + const nextMetas = metas.map(m => { + const old = force ? undefined : current.get(m.conversationIDKey) + return old ? Meta.updateMeta(old, m) : m + }) useInboxMetadataState.setState(s => { removals?.forEach(r => { s.metas.delete(r) s.participants.delete(r) }) - metas.forEach(m => { - s.metas.set(m.conversationIDKey, T.castDraft(m)) + nextMetas.forEach(next => { + s.metas.set(next.conversationIDKey, T.castDraft(next)) }) }) - syncInboxRowsFromMetas(metas, removals) } const updateInboxParticipants = (inboxUIItems: ReadonlyArray) => { - syncInboxRowsFromParticipants(inboxUIItems) const participantEntries = new Array<{ conversationIDKey: T.Chat.ConversationIDKey participantInfo: T.Chat.ParticipantInfo @@ -152,7 +151,6 @@ const updateInboxParticipants = (inboxUIItems: ReadonlyArray | null} | null ) => { - syncInboxRowsFromParticipantMap(participantMap) useInboxMetadataState.setState(s => { Object.keys(participantMap ?? {}).forEach(convIDStr => { const participants = participantMap?.[convIDStr] @@ -266,7 +264,6 @@ export const onChatRouteChanged = ( } export const hydrateInboxLayout = (layout: T.RPCChat.UIInboxLayout) => { - syncInboxRowsFromLayout(layout) const missingSnippetIds = (layout.smallTeams ?? []) .filter(row => !row.snippet) .map(row => T.Chat.stringToConversationIDKey(row.convID)) @@ -296,8 +293,14 @@ export const clearConversationsForInboxSync = () => { }) } -const trustedStateForConversation = (id: T.Chat.ConversationIDKey) => - useInboxMetadataState.getState().metas.get(id)?.trustedState ?? getInboxRowTrustedState(id) +const inFlightUnboxRows = new Set() +const pendingForcedUnboxRows = new Set() + +// Trusted state now lives on the meta itself; a conv with no meta is 'requesting' +// while its unbox is in flight, otherwise 'untrusted'. +const trustedStateForConversation = (id: T.Chat.ConversationIDKey): T.Chat.MetaTrustedState => + useInboxMetadataState.getState().metas.get(id)?.trustedState ?? + (inFlightUnboxRows.has(id) ? 'requesting' : 'untrusted') const untrustedConversationIDKeys = (ids: ReadonlyArray) => ids.filter(id => { @@ -305,9 +308,6 @@ const untrustedConversationIDKeys = (ids: ReadonlyArray() -const pendingForcedUnboxRows = new Set() - type ConvoMetaQueueState = T.Immutable<{ generation: number inFlight: boolean @@ -441,7 +441,6 @@ const requestInboxUnboxRows = (ids: ReadonlyArray, for return } conversationIDKeys.forEach(id => inFlightUnboxRows.add(id)) - setInboxRowTrustedState(conversationIDKeys, 'requesting') logger.info( `unboxRows: unboxing len: ${conversationIDKeys.length} convs: ${conversationIDKeys.join(',')}` ) @@ -453,9 +452,8 @@ const requestInboxUnboxRows = (ids: ReadonlyArray, for if (error instanceof RPCError) { logger.info(`unboxRows: failed ${error.desc}`) } - // No per-conversation results arrived; leaving rows 'requesting' would make - // every future non-forced unbox skip them permanently. - setInboxRowTrustedState(conversationIDKeys, 'untrusted') + // No per-conversation results arrived; the finally block clears the + // in-flight marker so these convs fall back to 'untrusted' and can retry. } finally { conversationIDKeys.forEach(id => inFlightUnboxRows.delete(id)) const rerunIDs = conversationIDKeys.filter(id => { @@ -489,12 +487,8 @@ export const queueMetaToRequest = (ids: ReadonlyArray) useConvoMetaQueueState.getState().dispatch.queueMetaToRequest(ids) } -const hasKnownMeta = (id: T.Chat.ConversationIDKey) => { - if (useInboxMetadataState.getState().metas.has(id)) { - return true - } - return getInboxRowTrustedState(id) === 'trusted' -} +const hasKnownMeta = (id: T.Chat.ConversationIDKey) => + useInboxMetadataState.getState().metas.has(id) export const ensureWidgetMetas = ( widgetList: ReadonlyArray<{convID: T.Chat.ConversationIDKey}> | null | undefined @@ -591,7 +585,8 @@ export const onChatInboxSynced = async ( }, []) const removals = syncRes.incremental.removals?.map(T.Chat.stringToConversationIDKey) if (metas.length || removals?.length) { - metasReceived(metas, removals) + // Incremental unverified sync is authoritative for these convs; force past gating. + metasReceived(metas, removals, {force: true}) } forceUnboxRowsForService( @@ -605,7 +600,3 @@ export const onChatInboxSynced = async ( await refreshInbox('inboxSyncedUnknown') } } - -export const syncBadgeState = (badgeState?: T.RPCGen.BadgeState) => { - syncInboxRowBadgeState(badgeState) -} diff --git a/shared/chat/inbox/row/small-team/index.tsx b/shared/chat/inbox/row/small-team/index.tsx index 3dc90b1c7bb0..3c03da62f0ed 100644 --- a/shared/chat/inbox/row/small-team/index.tsx +++ b/shared/chat/inbox/row/small-team/index.tsx @@ -302,7 +302,7 @@ const BottomLineDisplay = (p: BottomLineDisplayProps) => { return ( {hasResetUsers && ( - + )} {youNeedToRekey && ( ({ alertMeta: Kb.Styles.platformStyles({ - common: {alignSelf: 'center', marginRight: 6}, + common: {marginRight: 6}, isMobile: {marginTop: 2}, }), bold: {...Kb.Styles.globalStyles.fontBold}, diff --git a/shared/chat/inbox/row/teams-divider-container.tsx b/shared/chat/inbox/row/teams-divider-container.tsx index 7ca514e18f77..186490821578 100644 --- a/shared/chat/inbox/row/teams-divider-container.tsx +++ b/shared/chat/inbox/row/teams-divider-container.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import type {ChatInboxRowItem} from '../rowitem' import {useConfigState} from '@/stores/config' -import {useInboxRowsState} from '@/chat/inbox/rows-state' +import {useInboxBadgeState} from '@/chat/inbox/badge-state' import TeamsDivider from './teams-divider' type Props = Omit, 'badgeCount'> & { @@ -22,11 +22,11 @@ const TeamsDividerContainer = React.memo(function TeamsDividerContainer(props: P return ids }, [rows]) - const visibleBadges = useInboxRowsState( + const visibleBadges = useInboxBadgeState( React.useCallback(s => { let total = 0 for (const conversationIDKey of visibleSmallConvIDs) { - total += s.rowsSmall.get(conversationIDKey)?.badgeCount ?? 0 + total += s.counts.get(conversationIDKey)?.badgeCount ?? 0 } return total }, [visibleSmallConvIDs]) diff --git a/shared/chat/inbox/rows-state.test.ts b/shared/chat/inbox/rows-state.test.ts index 8b5f88867ecf..003351a5c7f0 100644 --- a/shared/chat/inbox/rows-state.test.ts +++ b/shared/chat/inbox/rows-state.test.ts @@ -1,45 +1,51 @@ +/** @jest-environment jsdom */ /// -import * as T from '@/constants/types' import * as Meta from '@/constants/chat/meta' +import * as T from '@/constants/types' +import {act, cleanup, renderHook} from '@testing-library/react' import {resetAllStores} from '@/util/zustand' import {useCurrentUserState} from '@/stores/current-user' -import { - syncInboxRowBadgeState, - syncInboxRowsFromLayout, - syncInboxRowsFromMetaAndParticipants, - syncInboxRowsFromMetas, - syncInboxRowsFromParticipantMap, - syncInboxRowsFromParticipants, - updateInboxRowTyping, - useInboxRowsState, -} from './rows-state' +import {metasReceived, participantInfoReceived} from './metadata' +import {syncInboxBadgeState} from './badge-state' +import {updateInboxTyping} from './typing-state' +import {useInboxLayoutState} from './layout-state' +import {useInboxRowBig, useInboxRowSmall} from './rows-state' -afterEach(() => { - resetAllStores() -}) +const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) + +const setLayout = (layout: Partial) => { + useInboxLayoutState.getState().dispatch.updateLayout( + JSON.stringify({bigTeams: null, smallTeams: null, totalSmallTeams: 0, ...layout}) + ) +} -test('explicit meta and participant updates merge into the row caches', () => { - const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) +beforeEach(() => { useCurrentUserState.getState().dispatch.setBootstrap({ deviceID: 'device-id', deviceName: 'device-name', uid: 'uid', username: 'alice', }) +}) - syncInboxRowBadgeState({ - conversations: [{badgeCount: 2, convID: T.Chat.keyToConversationID(convID), unreadMessages: 1}], - } as unknown as T.RPCGen.BadgeState) - updateInboxRowTyping([ - { - convID: T.Chat.keyToConversationID(convID), - typers: [{deviceID: 'device-id', uid: 'uid', username: 'carol'}], - }, - ]) +afterEach(() => { + cleanup() + resetAllStores() +}) - syncInboxRowsFromMetaAndParticipants([ - { - meta: { +test('meta, participant, badge and typing stores merge into the computed small/big rows', () => { + act(() => { + syncInboxBadgeState({ + conversations: [{badgeCount: 2, convID: T.Chat.keyToConversationID(convID), unreadMessages: 1}], + } as unknown as T.RPCGen.BadgeState) + updateInboxTyping([ + { + convID: T.Chat.keyToConversationID(convID), + typers: [{deviceID: 'device-id', uid: 'uid', username: 'carol'}], + }, + ] as ReadonlyArray) + metasReceived([ + { ...Meta.makeConversationMeta(), channelname: 'general', conversationIDKey: convID, @@ -53,21 +59,12 @@ test('explicit meta and participant updates merge into the row caches', () => { timestamp: 1234, trustedState: 'requesting', }, - participantInfo: {all: ['alice', 'bob'], contactName: new Map(), name: ['alice', 'bob']}, - }, - ]) - expect(useInboxRowsState.getState().rowsBig.get(convID)?.badgeCount).toBe(2) - - expect(useInboxRowsState.getState().rowsBig.get(convID)).toMatchObject({ - badgeCount: 2, - channelname: 'general', - hasBadge: true, - hasDraft: true, - hasUnread: true, - snippetDecoration: T.RPCChat.SnippetDecoration.pendingMessage, - unreadCount: 1, + ]) + participantInfoReceived(convID, {all: ['alice', 'bob'], contactName: new Map(), name: ['alice', 'bob']}) }) - expect(useInboxRowsState.getState().rowsSmall.get(convID)).toMatchObject({ + + const {result: small} = renderHook(() => useInboxRowSmall(convID)) + expect(small.current).toMatchObject({ badgeCount: 2, draft: 'draft text', hasBadge: true, @@ -87,82 +84,84 @@ test('explicit meta and participant updates merge into the row caches', () => { youAreReset: false, youNeedToRekey: true, }) -}) -test('layout and meta sync populate inbox rows without a convo store lookup', () => { - const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) - useCurrentUserState.getState().dispatch.setBootstrap({ - deviceID: 'device-id', - deviceName: 'device-name', - uid: 'uid', - username: 'alice', + const {result: big} = renderHook(() => useInboxRowBig(convID)) + expect(big.current).toMatchObject({ + badgeCount: 2, + channelname: 'general', + hasBadge: true, + hasDraft: true, + hasUnread: true, + snippetDecoration: T.RPCChat.SnippetDecoration.pendingMessage, + unreadCount: 1, }) +}) - syncInboxRowsFromLayout({ - bigTeams: [ - { - channel: { - channelname: 'general', +test('layout fills gaps until a trusted meta wins; participant store overrides name-split', () => { + const {result: small} = renderHook(() => useInboxRowSmall(convID)) + const {result: big} = renderHook(() => useInboxRowBig(convID)) + + // layout only: untrusted, so the layout row supplies snippet/time/muted/participants + act(() => { + setLayout({ + bigTeams: [ + { + channel: { + channelname: 'general', + convID: T.Chat.conversationIDKeyToString(convID), + draft: 'big draft', + isMuted: false, + teamname: 'team', + }, + state: T.RPCChat.UIInboxBigTeamRowTyp.channel, + }, + ], + smallTeams: [ + { convID: T.Chat.conversationIDKeyToString(convID), - draft: 'big draft', - isMuted: false, - teamname: 'team', + draft: '', + isMuted: true, + isTeam: false, + lastSendTime: 0, + name: 'alice,bob', + snippet: 'layout snippet', + snippetDecoration: T.RPCChat.SnippetDecoration.none, + time: 123, }, - state: T.RPCChat.UIInboxBigTeamRowTyp.channel, - }, - ], - smallTeams: [ - { - convID: T.Chat.conversationIDKeyToString(convID), - draft: '', - isMuted: true, - isTeam: false, - lastSendTime: 0, - name: 'alice,bob', - snippet: 'layout snippet', - snippetDecoration: T.RPCChat.SnippetDecoration.none, - time: 123, - }, - ], - totalSmallTeams: 1, + ], + totalSmallTeams: 1, + }) }) - - expect(useInboxRowsState.getState().rowsSmall.get(convID)).toMatchObject({ + expect(small.current).toMatchObject({ isMuted: true, participants: ['bob'], snippet: 'layout snippet', timestamp: 123, }) - expect(useInboxRowsState.getState().rowsBig.get(convID)).toMatchObject({ - channelname: 'general', - hasDraft: true, - teamname: 'team', - }) + expect(big.current).toMatchObject({channelname: 'general', hasDraft: true, teamname: 'team'}) - syncInboxRowsFromParticipants([ - { - convID: T.Chat.conversationIDKeyToString(convID), - participants: [ - {assertion: 'alice', inConvName: true, type: T.RPCChat.UIParticipantType.user}, - {assertion: 'carol', inConvName: true, type: T.RPCChat.UIParticipantType.user}, - ], - } as unknown as T.RPCChat.InboxUIItem, - ]) - expect(useInboxRowsState.getState().rowsSmall.get(convID)?.participants).toEqual(['carol']) + // participant store wins over the layout name-split + act(() => { + participantInfoReceived(convID, {all: ['alice', 'carol'], contactName: new Map(), name: ['alice', 'carol']}) + }) + expect(small.current.participants).toEqual(['carol']) - syncInboxRowsFromMetas([ - { - ...Meta.makeConversationMeta(), - conversationIDKey: convID, - draft: 'meta draft', - isMuted: false, - snippetDecorated: 'meta snippet', - teamname: 'meta-team', - timestamp: 456, - trustedState: 'trusted', - }, - ]) - expect(useInboxRowsState.getState().rowsSmall.get(convID)).toMatchObject({ + // a trusted meta takes precedence over the layout row for the gap fields + act(() => { + metasReceived([ + { + ...Meta.makeConversationMeta(), + conversationIDKey: convID, + draft: 'meta draft', + isMuted: false, + snippetDecorated: 'meta snippet', + teamname: 'meta-team', + timestamp: 456, + trustedState: 'trusted', + }, + ]) + }) + expect(small.current).toMatchObject({ draft: 'meta draft', isMuted: false, snippet: 'meta snippet', @@ -170,11 +169,9 @@ test('layout and meta sync populate inbox rows without a convo store lookup', () timestamp: 456, }) - syncInboxRowsFromParticipantMap({ - [convID]: [ - {assertion: 'alice', inConvName: true, type: T.RPCChat.UIParticipantType.user}, - {assertion: 'dave', inConvName: true, type: T.RPCChat.UIParticipantType.user}, - ], + // later participant updates still flow through + act(() => { + participantInfoReceived(convID, {all: ['alice', 'dave'], contactName: new Map(), name: ['alice', 'dave']}) }) - expect(useInboxRowsState.getState().rowsSmall.get(convID)?.participants).toEqual(['dave']) + expect(small.current.participants).toEqual(['dave']) }) diff --git a/shared/chat/inbox/rows-state.tsx b/shared/chat/inbox/rows-state.tsx index 9e9861df4e94..3e54550add1a 100644 --- a/shared/chat/inbox/rows-state.tsx +++ b/shared/chat/inbox/rows-state.tsx @@ -1,8 +1,16 @@ +import * as React from 'react' import * as T from '@/constants/types' -import * as Common from '@/constants/chat/common' -import * as Z from '@/util/zustand' import {useCurrentUserState} from '@/stores/current-user' -import {shallowEqual} from '@/constants/utils' +import {useInboxMetadataState} from '@/chat/inbox/metadata' +import { + getBigLayoutChannelRow, + getSmallLayoutRow, + useInboxLayoutState, + type BigLayoutChannelRow, + type SmallLayoutRow, +} from '@/chat/inbox/layout-state' +import {useInboxBadgeState, type BadgeCounts} from '@/chat/inbox/badge-state' +import {useInboxTypingState} from '@/chat/inbox/typing-state' export type InboxRowBig = { badgeCount: number @@ -41,73 +49,13 @@ export type InboxRowSmall = { youNeedToRekey: boolean } -const defaultInboxRowBig = { - badgeCount: 0, - channelname: '', - hasBadge: false, - hasDraft: false, - hasUnread: false, - isError: false, - isMuted: false, - snippet: '', - snippetDecoration: 0, - teamname: '', - trustedState: 'untrusted', - unreadCount: 0, -} satisfies InboxRowBig +type Meta = T.Immutable | undefined +type ParticipantInfo = T.Immutable | undefined -const defaultInboxRowSmall: InboxRowSmall = { - badgeCount: 0, - draft: '', - hasBadge: false, - hasResetUsers: false, - hasUnread: false, - isDecryptingSnippet: true, - isLocked: false, - isMuted: false, - participantNeedToRekey: false, - participants: [], - snippet: '', - snippetDecoration: T.RPCChat.SnippetDecoration.none, - teamDisplayName: '', - timestamp: 0, - trustedState: 'untrusted', - typingSnippet: '', - unreadCount: 0, - youAreReset: false, - youNeedToRekey: false, -} - -type State = T.Immutable<{ - rowsBig: Map - rowsSmall: Map - dispatch: { - resetState: () => void - } -}> - -const ensureBigRow = (rowsBig: Map, id: string) => { - if (!rowsBig.has(id)) { - rowsBig.set(id, {...defaultInboxRowBig}) - } - return rowsBig.get(id)! -} - -const ensureSmallRow = (rowsSmall: Map, id: string) => { - if (!rowsSmall.has(id)) { - rowsSmall.set(id, {...defaultInboxRowSmall, participants: [] as string[]}) +const buildTypingSnippet = (typing?: ReadonlySet): string => { + if (!typing?.size) { + return '' } - return rowsSmall.get(id)! -} - -export const useInboxRowsState = Z.createZustand('inboxRows', () => ({ - dispatch: {resetState: Z.defaultReset}, - rowsBig: new Map(), - rowsSmall: new Map(), -})) - -const buildTypingSnippet = (typing: ReadonlySet): string => { - if (!typing.size) return '' if (typing.size === 1) { const [t] = typing return `${t} is typing...` @@ -125,251 +73,122 @@ const bigSnippetDecoration = (sd: T.RPCChat.SnippetDecoration): number => { } } -const applyParticipantsToSmallRow = ( - small: InboxRowSmall, - participantInfo: T.Chat.ParticipantInfo, - you: string -) => { - const filtered = participantInfo.name.length - ? participantInfo.name.filter((pp, _, list) => list.length === 1 || pp !== you) - : [] - if (!shallowEqual(small.participants, filtered)) { - small.participants = filtered - } -} +const filterParticipants = (names: ReadonlyArray, you: string): Array => + names.length ? names.filter((pp, _i, list) => list.length === 1 || pp !== you) : [] -const applyMetaToRows = ( - rowsBig: Map, - rowsSmall: Map, - meta: T.Chat.ConversationMeta, - you: string, - participantInfo?: T.Chat.ParticipantInfo -) => { - const id = meta.conversationIDKey - const snippet = meta.snippetDecorated ?? meta.snippet ?? '' +const isMetaTrusted = (trustedState: T.Chat.MetaTrustedState) => + trustedState === 'trusted' || trustedState === 'error' - const big = ensureBigRow(rowsBig, id) - big.channelname = meta.channelname - big.hasBadge = big.badgeCount > 0 - big.hasDraft = !!meta.draft - big.hasUnread = big.unreadCount > 0 - big.isError = meta.trustedState === 'error' - big.isMuted = meta.isMuted - big.snippet = snippet - big.snippetDecoration = bigSnippetDecoration(meta.snippetDecoration) - big.teamname = meta.teamname - big.trustedState = meta.trustedState - - const small = ensureSmallRow(rowsSmall, id) - small.draft = meta.draft || '' - small.hasBadge = small.badgeCount > 0 - small.hasResetUsers = meta.resetParticipants.size > 0 - small.hasUnread = small.unreadCount > 0 - small.isDecryptingSnippet = - !!id && !snippet && (meta.trustedState === 'requesting' || meta.trustedState === 'untrusted') - small.isLocked = meta.rekeyers.size > 0 || !!meta.wasFinalizedBy - small.isMuted = meta.isMuted - small.participantNeedToRekey = meta.rekeyers.size > 0 - if (participantInfo) { - applyParticipantsToSmallRow(small, participantInfo, you) +const computeSmallRow = ( + id: string, + you: string, + meta: Meta, + participantInfo: ParticipantInfo, + layoutRow: SmallLayoutRow | undefined, + counts: BadgeCounts | undefined, + typing: ReadonlySet | undefined +): InboxRowSmall => { + const trustedState: T.Chat.MetaTrustedState = meta?.trustedState ?? 'untrusted' + const metaTrusted = isMetaTrusted(trustedState) + const badgeCount = counts?.badgeCount ?? 0 + const unreadCount = counts?.unreadCount ?? 0 + + const metaSnippet = meta ? (meta.snippetDecorated ?? meta.snippet ?? '') : '' + // ONE precedence rule: meta wins when trusted/error; otherwise the layout row + // fills the gaps (snippet, draft, time, isMuted, name-split participants). + const useLayout = !metaTrusted && !!layoutRow + + const snippet = useLayout ? (layoutRow.snippet ?? '') : metaSnippet + const snippetDecoration = useLayout + ? layoutRow.snippetDecoration + : (meta?.snippetDecoration ?? T.RPCChat.SnippetDecoration.none) + const draft = (useLayout ? layoutRow.draft : meta?.draft) || '' + const timestamp = (useLayout ? layoutRow.time : meta?.timestamp) || 0 + const isMuted = useLayout ? layoutRow.isMuted : (meta?.isMuted ?? false) + const teamDisplayName = useLayout + ? layoutRow.isTeam + ? (layoutRow.name.split('#')[0] ?? '') + : '' + : meta?.teamname + ? (meta.teamname.split('#')[0] ?? '') + : '' + + let participants = filterParticipants(participantInfo?.name ?? [], you) + if (participants.length === 0 && layoutRow && !layoutRow.isTeam && layoutRow.name) { + const names = layoutRow.name + .split(',') + .map(n => n.trim()) + .filter(Boolean) + participants = filterParticipants(names, you) } - small.snippet = snippet - small.snippetDecoration = meta.snippetDecoration - small.teamDisplayName = meta.teamname ? meta.teamname.split('#')[0] ?? '' : '' - small.timestamp = meta.timestamp || 0 - small.trustedState = meta.trustedState - small.youAreReset = meta.membershipType === 'youAreReset' - small.youNeedToRekey = meta.rekeyers.has(you) -} - -export const syncInboxRowsFromMetaAndParticipants = ( - entries: ReadonlyArray<{ - meta: T.Chat.ConversationMeta - participantInfo?: T.Chat.ParticipantInfo - }> -) => { - const you = useCurrentUserState.getState().username - useInboxRowsState.setState(s => { - entries.forEach(({meta, participantInfo}) => { - applyMetaToRows(s.rowsBig, s.rowsSmall, meta, you, participantInfo) - }) - }) -} -export const syncInboxRowsFromMetas = ( - metas: ReadonlyArray, - removals?: ReadonlyArray -) => { - const you = useCurrentUserState.getState().username - useInboxRowsState.setState(s => { - removals?.forEach(id => { - s.rowsBig.delete(id) - s.rowsSmall.delete(id) - }) - metas.forEach(meta => { - applyMetaToRows(s.rowsBig, s.rowsSmall, meta, you) - }) - }) -} - -export const syncInboxRowsFromParticipants = (inboxUIItems: ReadonlyArray) => { - const you = useCurrentUserState.getState().username - useInboxRowsState.setState(s => { - inboxUIItems.forEach(inboxUIItem => { - const participantInfo = Common.uiParticipantsToParticipantInfo(inboxUIItem.participants ?? []) - if (participantInfo.all.length > 0) { - const id = T.Chat.stringToConversationIDKey(inboxUIItem.convID) - applyParticipantsToSmallRow(ensureSmallRow(s.rowsSmall, id), participantInfo, you) - } - }) - }) -} - -export const syncInboxRowsFromParticipantMap = ( - participantMap?: {[key: string]: ReadonlyArray | null} | null -) => { - const you = useCurrentUserState.getState().username - useInboxRowsState.setState(s => { - Object.keys(participantMap ?? {}).forEach(convIDStr => { - const participants = participantMap?.[convIDStr] - if (!participants) { - return - } - const participantInfo = Common.uiParticipantsToParticipantInfo(participants) - if (participantInfo.all.length > 0) { - const id = T.Chat.stringToConversationIDKey(convIDStr) - applyParticipantsToSmallRow(ensureSmallRow(s.rowsSmall, id), participantInfo, you) - } - }) - }) -} - -export const syncInboxRowsFromLayout = (layout: T.RPCChat.UIInboxLayout) => { - const you = useCurrentUserState.getState().username - useInboxRowsState.setState(s => { - layout.smallTeams?.forEach(row => { - const id = T.Chat.stringToConversationIDKey(row.convID) - const small = ensureSmallRow(s.rowsSmall, id) - const snippet = row.snippet ?? '' - small.draft = row.draft || '' - small.hasBadge = small.badgeCount > 0 - small.hasUnread = small.unreadCount > 0 - small.isDecryptingSnippet = !!id && !snippet && small.trustedState !== 'trusted' - small.isMuted = row.isMuted - small.snippet = snippet - small.snippetDecoration = row.snippetDecoration - small.teamDisplayName = row.isTeam ? row.name.split('#')[0] ?? '' : '' - small.timestamp = row.time || 0 - if (!row.isTeam && row.name && small.participants.length === 0) { - const names = row.name - .split(',') - .map(n => n.trim()) - .filter(Boolean) - const participantInfo: T.Chat.ParticipantInfo = {all: names, contactName: new Map(), name: names} - applyParticipantsToSmallRow(small, participantInfo, you) - } - - const big = ensureBigRow(s.rowsBig, id) - big.hasBadge = big.badgeCount > 0 - big.hasDraft = !!row.draft - big.hasUnread = big.unreadCount > 0 - big.isMuted = row.isMuted - big.snippet = snippet - big.snippetDecoration = bigSnippetDecoration(row.snippetDecoration) - big.teamname = row.isTeam ? row.name : '' - }) - layout.bigTeams?.forEach(row => { - if (row.state !== T.RPCChat.UIInboxBigTeamRowTyp.channel) { - return - } - const id = T.Chat.stringToConversationIDKey(row.channel.convID) - const big = ensureBigRow(s.rowsBig, id) - big.channelname = row.channel.channelname - big.hasBadge = big.badgeCount > 0 - big.hasDraft = !!row.channel.draft - big.hasUnread = big.unreadCount > 0 - big.isMuted = row.channel.isMuted - big.teamname = row.channel.teamname - }) - }) -} - -export const getInboxRowTrustedState = (id: T.Chat.ConversationIDKey) => { - const {rowsBig, rowsSmall} = useInboxRowsState.getState() - return rowsSmall.get(id)?.trustedState ?? rowsBig.get(id)?.trustedState -} - -export const setInboxRowTrustedState = ( - ids: ReadonlyArray, - trustedState: T.Chat.MetaTrustedState -) => { - useInboxRowsState.setState(s => { - ids.forEach(id => { - const small = ensureSmallRow(s.rowsSmall, id) - small.trustedState = trustedState - small.isDecryptingSnippet = - !!id && !small.snippet && (trustedState === 'requesting' || trustedState === 'untrusted') - - const big = ensureBigRow(s.rowsBig, id) - big.trustedState = trustedState - big.isError = trustedState === 'error' - }) - }) + const rekeyersSize = meta?.rekeyers.size ?? 0 + return { + badgeCount, + draft, + hasBadge: badgeCount > 0, + hasResetUsers: (meta?.resetParticipants.size ?? 0) > 0, + hasUnread: unreadCount > 0, + isDecryptingSnippet: !!id && !snippet && !metaTrusted, + isLocked: rekeyersSize > 0 || !!meta?.wasFinalizedBy, + isMuted, + participantNeedToRekey: rekeyersSize > 0, + participants, + snippet, + snippetDecoration, + teamDisplayName, + timestamp, + trustedState, + typingSnippet: buildTypingSnippet(typing), + unreadCount, + youAreReset: meta?.membershipType === 'youAreReset', + youNeedToRekey: !!meta && meta.rekeyers.has(you), + } } -export const syncInboxRowBadgeState = (badgeState?: T.RPCGen.BadgeState) => { - if (!badgeState) { - return +const computeBigRow = ( + meta: Meta, + layoutChannel: BigLayoutChannelRow | undefined, + counts: BadgeCounts | undefined +): InboxRowBig => { + const trustedState: T.Chat.MetaTrustedState = meta?.trustedState ?? 'untrusted' + const metaTrusted = isMetaTrusted(trustedState) + const badgeCount = counts?.badgeCount ?? 0 + const unreadCount = counts?.unreadCount ?? 0 + const useLayout = !metaTrusted && !!layoutChannel + const metaSnippet = meta ? (meta.snippetDecorated ?? meta.snippet ?? '') : '' + return { + badgeCount, + channelname: useLayout ? layoutChannel.channelname : (meta?.channelname ?? ''), + hasBadge: badgeCount > 0, + hasDraft: useLayout ? !!layoutChannel.draft : !!meta?.draft, + hasUnread: unreadCount > 0, + isError: trustedState === 'error', + isMuted: useLayout ? layoutChannel.isMuted : (meta?.isMuted ?? false), + snippet: metaSnippet, + snippetDecoration: bigSnippetDecoration(meta?.snippetDecoration ?? T.RPCChat.SnippetDecoration.none), + teamname: useLayout ? layoutChannel.teamname : (meta?.teamname ?? ''), + trustedState, + unreadCount, } - const updated = new Set() - useInboxRowsState.setState(s => { - badgeState.conversations?.forEach(conversation => { - const id = T.Chat.conversationIDToKey(conversation.convID) - updated.add(id) - - const big = ensureBigRow(s.rowsBig, id) - big.badgeCount = conversation.badgeCount - big.hasBadge = conversation.badgeCount > 0 - big.hasUnread = conversation.unreadMessages > 0 - big.unreadCount = conversation.unreadMessages - - const small = ensureSmallRow(s.rowsSmall, id) - small.badgeCount = conversation.badgeCount - small.hasBadge = conversation.badgeCount > 0 - small.hasUnread = conversation.unreadMessages > 0 - small.unreadCount = conversation.unreadMessages - }) - - for (const [id, big] of s.rowsBig) { - if (updated.has(id)) continue - big.badgeCount = 0 - big.hasBadge = false - big.hasUnread = false - big.unreadCount = 0 - } - for (const [id, small] of s.rowsSmall) { - if (updated.has(id)) continue - small.badgeCount = 0 - small.hasBadge = false - small.hasUnread = false - small.unreadCount = 0 - } - }) } -export const updateInboxRowTyping = (updates?: ReadonlyArray | null) => { - useInboxRowsState.setState(s => { - updates?.forEach(update => { - const id = T.Chat.conversationIDToKey(update.convID) - const typing = new Set(update.typers?.map(typer => typer.username)) - const small = ensureSmallRow(s.rowsSmall, id) - small.typingSnippet = buildTypingSnippet(typing) - }) - }) +export const useInboxRowSmall = (id: string): InboxRowSmall => { + const you = useCurrentUserState(s => s.username) + const meta = useInboxMetadataState(s => s.metas.get(id)) + const participantInfo = useInboxMetadataState(s => s.participants.get(id)) + const layoutRow = useInboxLayoutState(s => getSmallLayoutRow(s, id)) + const counts = useInboxBadgeState(s => s.counts.get(id)) + const typing = useInboxTypingState(s => s.typing.get(id)) + return React.useMemo( + () => computeSmallRow(id, you, meta, participantInfo, layoutRow, counts, typing), + [id, you, meta, participantInfo, layoutRow, counts, typing] + ) } -export const useInboxRowBig = (id: string): InboxRowBig => - useInboxRowsState(s => s.rowsBig.get(id)) ?? defaultInboxRowBig - -export const useInboxRowSmall = (id: string): InboxRowSmall => - useInboxRowsState(s => s.rowsSmall.get(id)) ?? defaultInboxRowSmall +export const useInboxRowBig = (id: string): InboxRowBig => { + const meta = useInboxMetadataState(s => s.metas.get(id)) + const layoutChannel = useInboxLayoutState(s => getBigLayoutChannelRow(s, id)) + const counts = useInboxBadgeState(s => s.counts.get(id)) + return React.useMemo(() => computeBigRow(meta, layoutChannel, counts), [meta, layoutChannel, counts]) +} diff --git a/shared/chat/inbox/typing-state.test.ts b/shared/chat/inbox/typing-state.test.ts new file mode 100644 index 000000000000..5f712833e2fc --- /dev/null +++ b/shared/chat/inbox/typing-state.test.ts @@ -0,0 +1,37 @@ +/// +import * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' +import {updateInboxTyping, useInboxTypingState} from './typing-state' + +const convA = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) +const convB = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8])) + +afterEach(() => { + resetAllStores() +}) + +test('updateInboxTyping stores typers per conversation and replaces prior sets', () => { + updateInboxTyping([ + { + convID: T.Chat.keyToConversationID(convA), + typers: [{deviceID: 'd', uid: 'u', username: 'carol'}], + }, + { + convID: T.Chat.keyToConversationID(convB), + typers: [ + {deviceID: 'd', uid: 'u', username: 'bob'}, + {deviceID: 'd', uid: 'u', username: 'dave'}, + ], + }, + ] as ReadonlyArray) + + expect([...(useInboxTypingState.getState().typing.get(convA) ?? [])]).toEqual(['carol']) + expect((useInboxTypingState.getState().typing.get(convB) ?? new Set()).size).toBe(2) + + // an update for convA with no typers replaces its set; convB is left untouched + updateInboxTyping([{convID: T.Chat.keyToConversationID(convA), typers: []}] as ReadonlyArray< + T.RPCChat.ConvTypingUpdate + >) + expect((useInboxTypingState.getState().typing.get(convA) ?? new Set()).size).toBe(0) + expect((useInboxTypingState.getState().typing.get(convB) ?? new Set()).size).toBe(2) +}) diff --git a/shared/chat/inbox/typing-state.tsx b/shared/chat/inbox/typing-state.tsx new file mode 100644 index 000000000000..1d9b2cfb2c2e --- /dev/null +++ b/shared/chat/inbox/typing-state.tsx @@ -0,0 +1,35 @@ +import * as T from '@/constants/types' +import * as Z from '@/util/zustand' + +type State = T.Immutable<{ + typing: Map> + dispatch: { + resetState: () => void + } +}> + +export const useInboxTypingState = Z.createZustand('inboxTyping', () => ({ + dispatch: {resetState: Z.defaultReset}, + typing: new Map(), +})) + +// Each ChatTypingUpdate carries the current typers for the named convs, so we +// replace those convs' sets and leave the rest untouched. +export const updateInboxTyping = (updates?: ReadonlyArray | null) => { + if (!updates?.length) { + return + } + useInboxTypingState.setState(s => { + updates.forEach(update => { + const id = T.Chat.conversationIDToKey(update.convID) + const typers = new Set(update.typers?.map(typer => typer.username)) + // Absent key already means nobody typing; delete rather than store an + // empty Set so the map doesn't grow unbounded as conversations go quiet. + if (typers.size) { + s.typing.set(id, typers) + } else { + s.typing.delete(id) + } + }) + }) +} diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index 9837abe49cac..d5a571b60dba 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -1,28 +1,26 @@ import * as C from '@/constants' import * as Chat from '@/constants/chat' import * as React from 'react' +import {produce} from 'immer' import * as T from '@/constants/types' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' -import {useInboxRowsState} from '@/chat/inbox/rows-state' +import {useInboxBadgeState} from '@/chat/inbox/badge-state' import {useIsFocused} from '@react-navigation/core' import type {ChatInboxRowItem} from './rowitem' import {useInboxLayout, useInboxRetryState} from './layout-state' import {buildInboxRows} from './rows' import {queueMetaToRequest} from './metadata' -const useInboxBadges = ( - inboxRows: ReadonlyArray, - selectedConversationIDKey: string -) => { +const useInboxBadges = (inboxRows: ReadonlyArray, selectedConversationIDKey: string) => { const bigConvIds = React.useMemo(() => { return inboxRows.map(r => (r.type === 'big' ? r.conversationIDKey : '')) }, [inboxRows]) - const unreadBadges = useInboxRowsState( + const unreadBadges = useInboxBadgeState( C.useShallow(s => bigConvIds.map(conversationIDKey => - conversationIDKey ? (s.rowsBig.get(conversationIDKey)?.badgeCount ?? 0) : 0 + conversationIDKey ? (s.counts.get(conversationIDKey)?.badgeCount ?? 0) : 0 ) ) ) @@ -69,32 +67,27 @@ export function useInboxState( const loadInboxNumSmallRows = C.useRPC(T.RPCGen.configGuiGetValueRpcPromise) const {hasLoaded: inboxHasLoaded, layout: inboxLayout, refresh: inboxRefresh} = useInboxLayout() - const {retriedOnCurrentEmpty: inboxRetriedOnCurrentEmpty, setRetriedOnCurrentEmpty} = - useInboxRetryState() + const {retriedOnCurrentEmpty: inboxRetriedOnCurrentEmpty, setRetriedOnCurrentEmpty} = useInboxRetryState() const [inboxControls, setInboxControls] = React.useState(() => ({ inboxNumSmallRows: 5, inboxNumSmallRowsLoaded: false, inboxNumSmallRowsUserChanged: false, smallTeamsExpanded: false, - username, })) - const controlsMatchUser = inboxControls.username === username - const inboxNumSmallRows = controlsMatchUser ? inboxControls.inboxNumSmallRows : 5 - const inboxNumSmallRowsLoaded = controlsMatchUser ? inboxControls.inboxNumSmallRowsLoaded : false - const smallTeamsExpanded = controlsMatchUser ? inboxControls.smallTeamsExpanded : false + const {inboxNumSmallRows, inboxNumSmallRowsLoaded, smallTeamsExpanded} = inboxControls const inboxNumSmallRowsLoadVersionRef = React.useRef(0) const setInboxNumSmallRows = React.useCallback((rows: number, persist = true) => { if (rows <= 0) { return } - setInboxControls(state => ({ - inboxNumSmallRows: rows, - inboxNumSmallRowsLoaded: true, - inboxNumSmallRowsUserChanged: true, - smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, - username, - })) + setInboxControls( + produce(draft => { + draft.inboxNumSmallRows = rows + draft.inboxNumSmallRowsLoaded = true + draft.inboxNumSmallRowsUserChanged = true + }) + ) if (!persist) { return } @@ -107,17 +100,14 @@ export function useInboxState( } catch {} } C.ignorePromise(f()) - }, [username]) + }, []) const toggleSmallTeamsExpanded = React.useCallback(() => { - setInboxControls(state => ({ - inboxNumSmallRows: state.username === username ? state.inboxNumSmallRows : 5, - inboxNumSmallRowsLoaded: state.username === username ? state.inboxNumSmallRowsLoaded : false, - inboxNumSmallRowsUserChanged: - state.username === username ? state.inboxNumSmallRowsUserChanged : false, - smallTeamsExpanded: !(state.username === username ? state.smallTeamsExpanded : false), - username, - })) - }, [username]) + setInboxControls( + produce(draft => { + draft.smallTeamsExpanded = !draft.smallTeamsExpanded + }) + ) + }, []) const { allowShowFloatingButton, @@ -180,32 +170,27 @@ export function useInboxState( return } const count = rows.i ?? -1 - setInboxControls(state => { - if (state.username === username && state.inboxNumSmallRowsUserChanged) { - return state - } - return { - inboxNumSmallRows: - count > 0 ? count : state.username === username ? state.inboxNumSmallRows : 5, - inboxNumSmallRowsLoaded: true, - inboxNumSmallRowsUserChanged: false, - smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, - username, - } - }) + setInboxControls( + produce(draft => { + if (draft.inboxNumSmallRowsUserChanged) { + return + } + if (count > 0) { + draft.inboxNumSmallRows = count + } + draft.inboxNumSmallRowsLoaded = true + }) + ) }, () => { if (inboxNumSmallRowsLoadVersionRef.current !== loadVersion) { return } - setInboxControls(state => ({ - inboxNumSmallRows: state.username === username ? state.inboxNumSmallRows : 5, - inboxNumSmallRowsLoaded: true, - inboxNumSmallRowsUserChanged: - state.username === username ? state.inboxNumSmallRowsUserChanged : false, - smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, - username, - })) + setInboxControls( + produce(draft => { + draft.inboxNumSmallRowsLoaded = true + }) + ) } ) return () => { diff --git a/shared/chat/new-team-dialog-container.tsx b/shared/chat/new-team-dialog-container.tsx index 3af5b9538cc5..82b630f5503a 100644 --- a/shared/chat/new-team-dialog-container.tsx +++ b/shared/chat/new-team-dialog-container.tsx @@ -1,4 +1,3 @@ -import * as C from '@/constants' import {CreateNewTeam} from '../teams/new-team' import {useCurrentUserState} from '@/stores/current-user' import {createNewTeamAndNavigate} from '@/teams/team-page-actions' @@ -9,10 +8,6 @@ type Props = {conversationIDKey?: T.Chat.ConversationIDKey} const NewTeamDialog = (props: Props) => { const conversationIDKey = props.conversationIDKey ?? T.Chat.noConversationIDKey - const navigateUp = C.Router2.navigateUp - const onCancel = () => { - navigateUp() - } const participantInfo = useConversationParticipants(conversationIDKey) const username = useCurrentUserState(s => s.username) const onSubmit = (teamname: string) => { @@ -21,7 +16,7 @@ const NewTeamDialog = (props: Props) => { .map(assertion => ({assertion, role: 'writer' as const})) void createNewTeamAndNavigate(teamname, false, {fromChat: true, usersToAdd}) } - return + return } export default NewTeamDialog diff --git a/shared/chat/readme.md b/shared/chat/readme.md index 21640bbe9398..c77311b5c35e 100644 --- a/shared/chat/readme.md +++ b/shared/chat/readme.md @@ -1,19 +1,37 @@ How chat works: -## Inbox: +## Data ownership -Conversations are of 2 basic types. - Small: adhoc conversations or teams with only the #general channel - Big: teams with multiple channels +Chat data is split across several focused stores instead of one global redux tree. Roughly: + +- **Inbox metadata** (`chat/inbox/metadata.tsx`, `useInboxMetadataState`) is the single owner of conversation `meta` (trustedState, snippet, participants pointer, draft, timestamp, etc.) and `participants`. All meta writes go through `metasReceived`, which version-gates each incoming meta against the currently stored one (`Meta.updateMeta`) so a stale/out-of-order update can't clobber newer data. Callers that already merged from the current meta (e.g. `updateInboxConversationMeta`, error metas, incremental inbox sync) pass `{force: true}` to bypass gating. Converters live in `constants/chat/meta.tsx` (`baseMetaFromUIItem` is the shared base used by the various `*ToConversationMeta` functions). +- **Per-conversation thread store** (`chat/conversation/thread-context.tsx`) is a vanilla zustand store created fresh per mounted `ConversationThreadProvider` and destroyed when the provider unmounts. It holds `messageMap`/`messageOrdinals`/`messageIDToOrdinal`/`messageTypeMap`/`pendingOutboxToOrdinal`, live `typing` (a `Set`), exploding mode, and payment/request/flip/unfurl maps. It reads conversation meta from the inbox metadata store rather than owning its own copy (`useThreadMeta`, `getMeta`). The module is split: `thread-engine.tsx` holds engine-notification handlers (`applyMessagesUpdatedToThread`, `applyIncomingMutationToThread`, etc.) and `thread-load.tsx` holds thread-load logic (RPC calls, exploding-mode-from-gregor, pagination sizing). +- **Inbox rows are computed, not cached.** `chat/inbox/rows-state.tsx` exposes `useInboxRowSmall`/`useInboxRowBig`, which `useMemo` a display row from: inbox metadata (meta + participants), `chat/inbox/layout-state.tsx` (a memoized index built from the service's `UIInboxLayout`, used as a fallback for rows whose meta isn't trusted yet), `chat/inbox/badge-state.tsx` (badge/unread counts, fully replaced from each `BadgeState` RPC payload), and `chat/inbox/typing-state.tsx` (per-conversation typing username sets, merged in from `ChatTypingUpdate`). Merge precedence is one rule: meta wins whenever it's `trusted` or `error`; otherwise the layout row fills the gaps (snippet, draft, time, mute, name-split participants). +- **Message conversion** lives in `constants/chat/message.tsx` (`uiMessageToMessage` converts a single RPC `UIMessage` to the internal `Message` type; `parseUIMessagesJSON` does the same for a JSON-stringified array, used for bulk thread-load ingestion). +- **Orange line** (the "new messages" divider) is a small standalone store, `chat/conversation/orange-line-context.tsx` (`useExplicitOrangeLineState`), keyed by conversationIDKey -> `{ordinal, version}`. + +## How data flows in + +Engine notifications land in `shared/constants/init/shared.tsx`'s `_onEngineIncoming`, which calls `handleConvoEngineIncoming` (`chat/inbox/engine.tsx`) directly for chat-relevant action types. That function is the inbox-side router: it turns RPC notifications (`ChatConvUpdate`, `NewChatActivity`, `ChatTypingUpdate`, `ChatParticipantsInfo`, `ChatThreadsStale`, etc.) into calls against the metadata store (`metasReceived`, `metaReceivedError`, `updateInboxConversationMeta`), the typing store (`updateInboxTyping`), or an unbox request (`unboxRows`/`forceUnboxRowsForService`). Thread-specific engine events (message updates/mutations, reactions, attachments) are instead handled by `thread-engine.tsx`'s listeners, wired up per-conversation inside `thread-context.tsx` (`useThreadEngineListeners`) so they only run while that conversation's provider is mounted. Thread loads (initial, scrollback, centered, jump-to-recent) go through `loadMoreMessages` -> `loadConversationThreadMessages` in `thread-load.tsx`, which issues the RPC and calls back into the thread store's `applyThreadLoad`. + +## Lifecycle + +The thread store and its sibling `ShownUsernameCacheContext` are created in `ConversationThreadProviderInner` and torn down by unmounting; the screen mounts a fresh provider (via a React `key` on the conversationIDKey) when you switch conversations, so there's no manual "clear old thread" step — the old store and its listeners just go away. `ConversationThreadProvider` special-cases the case where the requested id matches the currently-provided one, reusing the existing store/actions instead of remounting (e.g. nested same-thread wrappers). On logout, `Z.resetAllStores()` (`util/zustand.tsx`) resets every store created via `Z.createZustand` — inbox metadata, badge, typing, layout, orange-line, etc. — back to its initial state; it's invoked from `stores/config.tsx` when `loggedIn` flips to false. -We get a list of untrusted conversations from the server. Untrusted (unboxed) means we don't have any snippets and can't verify the participants / channel name. If we've previously loaded them the daemon can give us a trusted payload with the untrusted payload -We request untrusted conversations to be unboxed (converted to trusted). This is driven by the inbox scrolling rows into view. -The primary ID of a conversation is a ConversationIDKey. Our data structures are mostly maps driven off of this key +## Intentional dualities + +A few pieces of state are deliberately duplicated rather than unified, because they represent different things: + +- **Typing**: the thread store's `typing` is a live `Set` of who's typing right now in the open conversation; the inbox's `typingSnippet` (computed in `rows-state.tsx`) is a display string ("X is typing...") for inbox rows, sourced from the separate `typing-state.tsx` map so an unopened conversation's row can still show it. +- **Draft**: the composer's unsent text (`unsentText` in `chat/conversation/input-area/input-state.tsx`) is local, per-conversation UI state scoped to the input's own React context/reducer, cleared by unmount. `meta.draft` (in the inbox metadata store) is the last draft synced to the service, used to render the draft snippet on inbox rows for conversations you aren't currently looking at. They're independent by design — the input doesn't read from or write to `meta.draft` on every keystroke. + +## Inbox + +Conversations are of 2 basic types. - Small: adhoc conversations or teams with only the #general channel - Big: teams with multiple channels -badgeMap: id to the badge number -messageMap: id to message id to message -messageOrdinals: id to list of ordinals -metaMap: id to metadata -unreadMap: id to unread count -etc +We get a list of untrusted conversations from the server. Untrusted (unboxed) means we don't have any snippets and can't verify the participants / channel name. If we've previously loaded them the daemon can give us a trusted payload with the untrusted payload. +We request untrusted conversations to be unboxed (converted to trusted). This is driven by the inbox scrolling rows into view, via a queue (`queueMetaToRequest`) that unboxes in small batches rather than all at once. +The primary ID of a conversation is a ConversationIDKey. Data structures are mostly maps driven off of this key, split by store as described above (meta/participants, thread messages, badges, layout, typing). The inbox operates in 2 modes: 'normal' and 'filtered'. Filtered is driven by a filter string. Each item calculates a score and is sorted by this score (exact match > prefix match > substring match). We show small items, then big items. No dividers or hierarchy of channel/team. @@ -24,7 +42,7 @@ The normal mode is split into 2 sections. If you have a mix of small/big teams we can show a divider between them and truncate the small list. -The inbox is entirely derived from the metaMap +Row display data is derived at render time from inbox metadata plus the layout/badge/typing stores (see above), not read out of a single cached map. Edge cases: @@ -45,4 +63,4 @@ We keep the original ordinal if we can so the ordering of the thread from our pe ## Pending -When we build a search for users we want to preview the conversation. We have a special conversationIDKey for this Constants.pendingConversationIDKey. This always exists in the metaMap. The users go into the participants property. Usually the convesationIDKey inside the meta is the same as the key in the metaMap but in this special instance the key of the preview conversation goes in there depending on the participants +When we build a search for users we want to preview the conversation. We have a special conversationIDKey for this Constants.pendingConversationIDKey. This always exists in the inbox metadata store. The users go into the participants property. Usually the convesationIDKey inside the meta is the same as the key in the metadata store but in this special instance the key of the preview conversation goes in there depending on the participants diff --git a/shared/chat/send-to-chat/index.tsx b/shared/chat/send-to-chat/index.tsx index 427fb5d19d1d..46628bcc2384 100644 --- a/shared/chat/send-to-chat/index.tsx +++ b/shared/chat/send-to-chat/index.tsx @@ -133,10 +133,12 @@ export const DesktopSendToChatRender = (props: DesktopSendToChatRenderProps) => onChangeText={props.setTitle} /> - - - - + ) } @@ -153,7 +155,6 @@ const desktopStyles = Kb.Styles.styleSheetCreate( marginBottom: Kb.Styles.globalMargins.small, ...Kb.Styles.paddingH(Kb.Styles.globalMargins.large), }, - buttonBar: {alignItems: 'center'}, container: Kb.Styles.platformStyles({ isElectron: { maxHeight: 560, diff --git a/shared/chat/user-emoji.tsx b/shared/chat/user-emoji.tsx index f6f2b0ea2a81..d3410c7f47e3 100644 --- a/shared/chat/user-emoji.tsx +++ b/shared/chat/user-emoji.tsx @@ -1,6 +1,5 @@ -import * as C from '@/constants' import * as T from '@/constants/types' -import * as React from 'react' +import {useRPCLoad} from '@/util/use-rpc-load' const emptyEmojiGroups: ReadonlyArray = [] const emptyEmojis: ReadonlyArray = [] @@ -13,12 +12,6 @@ const flattenUserEmojis = (groups: ReadonlyArray) => { return emojis } -type UserEmojiLoadState = { - completedKey: string - emojiGroups: ReadonlyArray - emojis: ReadonlyArray -} - export const useUserEmoji = ({ conversationIDKey, disabled, @@ -28,75 +21,37 @@ export const useUserEmoji = ({ disabled?: boolean onlyInTeam?: boolean }) => { - const loadUserEmoji = C.useRPC(T.RPCChat.localUserEmojisRpcPromise) const requestOnlyInTeam = onlyInTeam ?? false const requestKey = `${conversationIDKey ?? T.Chat.noConversationIDKey}:${ requestOnlyInTeam ? 'team' : 'all' }` - const [loadState, setLoadState] = React.useState(() => ({ - completedKey: '', - emojiGroups: emptyEmojiGroups, - emojis: emptyEmojis, - })) - const requestIDRef = React.useRef(0) - - React.useEffect(() => { - if (disabled) { - requestIDRef.current += 1 - return - } - - const requestID = requestIDRef.current + 1 - requestIDRef.current = requestID - - loadUserEmoji( - [ - { - convID: - conversationIDKey && conversationIDKey !== T.Chat.noConversationIDKey - ? T.Chat.keyToConversationID(conversationIDKey) - : null, - opts: { - getAliases: true, - getCreationInfo: false, - onlyInTeam: requestOnlyInTeam, - }, + const {data, loading} = useRPCLoad( + T.RPCChat.localUserEmojisRpcPromise, + [ + { + convID: + conversationIDKey && conversationIDKey !== T.Chat.noConversationIDKey + ? T.Chat.keyToConversationID(conversationIDKey) + : null, + opts: { + getAliases: true, + getCreationInfo: false, + onlyInTeam: requestOnlyInTeam, }, - ], - results => { - if (requestIDRef.current !== requestID) { - return - } + }, + ], + { + enabled: !disabled, + key: requestKey, + map: results => { const nextGroups = results.emojis.emojis ?? emptyEmojiGroups - setLoadState({ - completedKey: requestKey, - emojiGroups: nextGroups, - emojis: flattenUserEmojis(nextGroups), - }) + return {emojiGroups: nextGroups, emojis: flattenUserEmojis(nextGroups)} }, - () => { - if (requestIDRef.current !== requestID) { - return - } - setLoadState({ - completedKey: requestKey, - emojiGroups: emptyEmojiGroups, - emojis: emptyEmojis, - }) - } - ) - - return () => { - if (requestIDRef.current === requestID) { - requestIDRef.current += 1 - } } - }, [conversationIDKey, disabled, loadUserEmoji, requestKey, requestOnlyInTeam]) - - const isCurrent = loadState.completedKey === requestKey + ) return { - emojiGroups: disabled ? undefined : isCurrent ? loadState.emojiGroups : emptyEmojiGroups, - emojis: isCurrent ? loadState.emojis : emptyEmojis, - loading: !disabled && loadState.completedKey !== requestKey, + emojiGroups: disabled ? undefined : (data?.emojiGroups ?? emptyEmojiGroups), + emojis: data?.emojis ?? emptyEmojis, + loading, } } diff --git a/shared/common-adapters/avatar/icon-to-img-set.tsx b/shared/common-adapters/avatar/icon-to-img-set.tsx deleted file mode 100644 index 5b073592b022..000000000000 --- a/shared/common-adapters/avatar/icon-to-img-set.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import {iconMeta} from '../icon.constants-gen' -import type {IconType} from '../icon.constants-gen' -import type {getAssetPath as getAssetPathType} from '@/constants/platform' - -type MultMap = { - [1]?: number - [2]?: number - [3]?: number -} - -const multiKeys = [1, 2, 3] as const - -const idealSizeMultMap: {[key: string]: MultMap} = { - '128': {'1': 256, '2': 256, '3': 960}, - '16': {'1': 192, '2': 192, '3': 192}, - '32': {'1': 192, '2': 192, '3': 192}, - '48': {'1': 192, '2': 192, '3': 192}, - '64': {'1': 192, '2': 256, '3': 192}, - '96': {'1': 192, '2': 192, '3': 960}, -} - -const _getMultsMapCache: {[key: string]: MultMap} = {} -function getMultsMap(imgMap: {[size: string]: unknown}, targetSize: number): MultMap { - const ssizes = Object.keys(imgMap) - if (!ssizes.length) return {} - - const sizeKey = `${targetSize}]${ssizes.join(':')}` - if (_getMultsMapCache[sizeKey]) return _getMultsMapCache[sizeKey] - - const sizes = ssizes.map(s => parseInt(s, 10)).sort((a: number, b: number) => a - b) - const multsMap: MultMap = {1: undefined, 2: undefined, 3: undefined} - - for (const mult of multiKeys) { - const level1 = idealSizeMultMap[String(targetSize)] - if (level1) { - const level2 = level1[mult] - if (level2) { - multsMap[mult] = level2 - continue - } - } - const ideal = mult * targetSize - const size = sizes.find(size => size >= ideal) - multsMap[mult] = size || sizes.at(-1) - } - - _getMultsMapCache[sizeKey] = multsMap - return multsMap -} - -function iconTypeToImgSetDesktop(imgMap: {[key: string]: IconType}, targetSize: number) { - const {getAssetPath} = require('@/constants/platform') as {getAssetPath: typeof getAssetPathType} - const multsMap = getMultsMap(imgMap, targetSize) - const keys = Object.keys(multsMap) as unknown as Array - const sets = keys - .map(mult => { - const m = multsMap[mult] - if (!m) return null - const img: string = imgMap[m] as string - if (!img) return null - const url = getAssetPath('images', 'icons', img) - return `url('${url}.png') ${mult}x` - }) - .filter(Boolean) - .join(', ') - return sets ? `-webkit-image-set(${sets})` : '' -} - -function iconTypeToImgSetNative(imgMap: {[key: string]: IconType}, targetSize: number) { - const multsMap = getMultsMap(imgMap, targetSize) - const idealMults = [2, 3, 1] as const - for (const mult of idealMults) { - if (multsMap[mult]) { - const size = multsMap[mult] - if (!size) return null - const icon = imgMap[size] - if (!icon) return null - return iconMeta[icon].require - } - } - return null -} - -export const iconTypeToImgSet: (imgMap: {[key: string]: IconType}, targetSize: number) => string = ( - isMobile ? iconTypeToImgSetNative : iconTypeToImgSetDesktop -) as any diff --git a/shared/common-adapters/avatar/index.tsx b/shared/common-adapters/avatar/index.tsx index 9598daa4a608..4b1c72b4c3e4 100644 --- a/shared/common-adapters/avatar/index.tsx +++ b/shared/common-adapters/avatar/index.tsx @@ -5,9 +5,10 @@ import {useConfigState} from '@/stores/config' import * as AvatarZus from './store' import {Pressable, View, useColorScheme} from 'react-native' import {navToProfile} from '@/constants/router' +import {normalizeFilePathURL} from '@/util/file-url' import {Image} from 'expo-image' -import {iconTypeToImgSet} from './icon-to-img-set' -import type {IconType} from '../icon.constants-gen' +import {iconMeta, type IconType} from '../icon.constants-gen' +import type {getAssetPath as getAssetPathType} from '@/constants/platform' import type * as React from 'react' import type * as T from '@/constants/types' @@ -17,6 +18,7 @@ type Props = { imageOverrideUrl?: string isTeam?: boolean onClick?: ((e?: React.BaseSyntheticEvent) => void) | 'profile' + testID?: string size: 128 | 96 | 64 | 48 | 32 | 24 | 16 style?: Styles.CustomStyles<'borderStyle'> teamname?: string @@ -34,6 +36,91 @@ const teamPlaceHolders: {[key: string]: IconType} = { '960': 'icon-team-placeholder-avatar-960', } +// ── Placeholder img-set resolution ─────────────────────────────────────────── + +type MultMap = { + [1]?: number + [2]?: number + [3]?: number +} + +const multiKeys = [1, 2, 3] as const + +const idealSizeMultMap: {[key: string]: MultMap} = { + '128': {'1': 256, '2': 256, '3': 960}, + '16': {'1': 192, '2': 192, '3': 192}, + '32': {'1': 192, '2': 192, '3': 192}, + '48': {'1': 192, '2': 192, '3': 192}, + '64': {'1': 192, '2': 256, '3': 192}, + '96': {'1': 192, '2': 192, '3': 960}, +} + +const _getMultsMapCache: {[key: string]: MultMap} = {} +function getMultsMap(imgMap: {[size: string]: unknown}, targetSize: number): MultMap { + const ssizes = Object.keys(imgMap) + if (!ssizes.length) return {} + + const sizeKey = `${targetSize}]${ssizes.join(':')}` + if (_getMultsMapCache[sizeKey]) return _getMultsMapCache[sizeKey] + + const sizes = ssizes.map(s => parseInt(s, 10)).sort((a: number, b: number) => a - b) + const multsMap: MultMap = {1: undefined, 2: undefined, 3: undefined} + + for (const mult of multiKeys) { + const level1 = idealSizeMultMap[String(targetSize)] + if (level1) { + const level2 = level1[mult] + if (level2) { + multsMap[mult] = level2 + continue + } + } + const ideal = mult * targetSize + const size = sizes.find(size => size >= ideal) + multsMap[mult] = size || sizes.at(-1) + } + + _getMultsMapCache[sizeKey] = multsMap + return multsMap +} + +function iconTypeToImgSetDesktop(imgMap: {[key: string]: IconType}, targetSize: number) { + const {getAssetPath} = require('@/constants/platform') as {getAssetPath: typeof getAssetPathType} + const multsMap = getMultsMap(imgMap, targetSize) + const keys = Object.keys(multsMap) as unknown as Array + const sets = keys + .map(mult => { + const m = multsMap[mult] + if (!m) return null + const img: string = imgMap[m] as string + if (!img) return null + const url = getAssetPath('images', 'icons', img) + return `url('${url}.png') ${mult}x` + }) + .filter(Boolean) + .join(', ') + return sets ? `-webkit-image-set(${sets})` : '' +} + +function iconTypeToImgSetNative(imgMap: {[key: string]: IconType}, targetSize: number) { + const multsMap = getMultsMap(imgMap, targetSize) + const idealMults = [2, 3, 1] as const + for (const mult of idealMults) { + if (multsMap[mult]) { + const size = multsMap[mult] + if (!size) return null + const icon = imgMap[size] + if (!icon) return null + return iconMeta[icon].require + } + } + return null +} + +const iconTypeToImgSet: (imgMap: {[key: string]: IconType}, targetSize: number) => string = ( + isMobile ? iconTypeToImgSetNative : iconTypeToImgSetDesktop +) as any + // ── Native-only ────────────────────────────────────────────────────────────── const sizeToTeamBorderRadius: Record = { @@ -62,19 +149,6 @@ const AVATAR_CONTAINER_SIZE = 175 const AVATAR_BORDER_SIZE = 4 const AVATAR_SIZE = AVATAR_CONTAINER_SIZE - AVATAR_BORDER_SIZE * 2 -const normalizeImageOverrideUrl = (url: string) => { - const isWindowsPath = /^[a-zA-Z]:[\\/]/.test(url) - if (url.startsWith('/') || isWindowsPath) { - let path = url.replace(/\\/g, '/') - if (isWindowsPath && !path.startsWith('/')) path = '/' + path - return encodeURI(`file://${path}`).replace(/#/g, '%23') - } - if (url.startsWith('file://') && (url.includes(' ') || url.includes('#'))) { - return encodeURI(url).replace(/#/g, '%23') - } - return url -} - const clickableStyle = {cursor: 'pointer'} as const const borderTeamStyle = { boxShadow: `0px 0px 0px 1px ${Styles.globalColors.black_10} inset`, @@ -83,7 +157,7 @@ const borderTeamStyle = { // ── Component ──────────────────────────────────────────────────────────────── function Avatar(p: Props) { - const {size, teamname, username, isTeam: _isTeam, onClick: _onClick, style, children} = p + const {size, teamname, username, isTeam: _isTeam, onClick: _onClick, style, children, testID} = p const {imageOverrideUrl, crop} = p const isTeam = _isTeam || !!teamname const name = isTeam ? teamname : username @@ -101,7 +175,7 @@ function Avatar(p: Props) { let bgImage: string | undefined if (imageOverrideUrl) { - bgImage = `url("${normalizeImageOverrideUrl(imageOverrideUrl)}")` + bgImage = `url("${normalizeFilePathURL(imageOverrideUrl)}")` } else if (address && name) { const typ = isTeam ? 'team' : 'user' const imgSize = size <= 64 ? 192 : size <= 96 ? 256 : 960 @@ -117,6 +191,7 @@ function Avatar(p: Props) { return (
@@ -189,10 +264,10 @@ function Avatar(p: Props) { ) if (onClick) { - return {content} + return {content} } - return {content} + return {content} } export default Avatar diff --git a/shared/common-adapters/badge.tsx b/shared/common-adapters/badge.tsx index 1d25223565de..3c34966f9314 100644 --- a/shared/common-adapters/badge.tsx +++ b/shared/common-adapters/badge.tsx @@ -25,87 +25,61 @@ function Badge(p: Badge2Props) { const height = p.height ?? (isMobile ? 20 : 16) const leftRightPadding = p.leftRightPadding ?? (Styles.isPhone ? 5 : 4) - if (border) { - const outerSize = height - const innerSize = height - 3 - return ( - - + {!!badgeNumber && ( + - {!!badgeNumber && ( - - {badgeNumber} - - )} - - - ) - } else { - return ( - - {!!badgeNumber && ( - - {badgeNumber} - - )} - - ) + {badgeNumber} + + )} + + ) + + if (!border) { + return badge } + return ( + + {badge} + + ) } export default Badge diff --git a/shared/common-adapters/banner.tsx b/shared/common-adapters/banner.tsx index 1f4238b8cfea..6658a6d3d029 100644 --- a/shared/common-adapters/banner.tsx +++ b/shared/common-adapters/banner.tsx @@ -60,6 +60,20 @@ export const BannerParagraph = (props: BannerParagraphProps) => ( ) +// red banner for local error state; renders nothing when there is no error +export const ErrorBanner = (props: {error?: string | Error | null; onClose?: () => void}) => { + const {error, onClose} = props + const message = typeof error === 'string' ? error : error?.message + if (!message) { + return null + } + return ( + + {message} + + ) +} + type BannerProps = { color: Color children: @@ -91,12 +105,12 @@ export const Banner = (props: BannerProps) => ( direction="vertical" style={Styles.collapseStyles([ props.narrow - ? styles.narrowTextContainer + ? styles.textContainer : props.inline - ? styles.inlineTextContainer - : props.small - ? styles.smallTextContainer - : styles.textContainer, + ? styles.inlineTextContainer + : props.small + ? styles.smallTextContainer + : styles.textContainer, props.textContainerStyle, ])} centerChildren={true} @@ -152,19 +166,6 @@ const styles = Styles.styleSheetCreate( inlineTextContainer: { ...Styles.padding(Styles.globalMargins.tiny, Styles.globalMargins.small), }, - narrowTextContainer: Styles.platformStyles({ - common: { - flex: 1, - maxWidth: '100%', - ...Styles.paddingV(Styles.globalMargins.tiny), - }, - isElectron: { - ...Styles.paddingH(Styles.globalMargins.medium), - }, - isMobile: { - ...Styles.paddingH(Styles.globalMargins.small), - }, - }), smallTextContainer: { flex: 1, maxWidth: '100%', diff --git a/shared/common-adapters/bottom-sheet.desktop.tsx b/shared/common-adapters/bottom-sheet.desktop.tsx deleted file mode 100644 index c961cc4fe017..000000000000 --- a/shared/common-adapters/bottom-sheet.desktop.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export const BottomSheetView = () => null -export const BottomSheetModal = () => null -export const BottomSheetBackdrop = () => null -export const BottomSheetScrollView = () => null -export const BottomSheetHandle = () => null diff --git a/shared/common-adapters/box-grow.tsx b/shared/common-adapters/box-grow.tsx index fd3a3455f94c..4053c5ff5e8e 100644 --- a/shared/common-adapters/box-grow.tsx +++ b/shared/common-adapters/box-grow.tsx @@ -8,26 +8,35 @@ type Props = { onLayout?: (e: LayoutEvent) => void } -const BoxGrow = (p: Props) => { +const BoxGrowImpl = (p: Props & {direction: 'vertical' | 'horizontal'}) => { + const {direction, onLayout, style, children} = p return ( - - - {p.children} + + + {children} ) } +const BoxGrow = (p: Props) => +export const BoxGrow2 = (p: Props) => + const styles = Styles.styleSheetCreate( () => ({ inner: {...Styles.globalStyles.fillAbsolute, height: '100%', width: '100%'}, - inner2: {...Styles.globalStyles.fillAbsolute, display: 'flex', height: '100%', width: '100%'}, outer: { flexGrow: 1, }, + // horizontal variant also shrinks so it can't overflow its parent outer2: { - display: 'flex', flexGrow: 1, flexShrink: 1, }, @@ -35,14 +44,3 @@ const styles = Styles.styleSheetCreate( ) export default BoxGrow - -export const BoxGrow2 = (p: Props) => { - const {onLayout, style, children} = p - return ( - - - {children} - - - ) -} diff --git a/shared/common-adapters/box.tsx b/shared/common-adapters/box.tsx index c4c4cbb6962f..043afad26df0 100644 --- a/shared/common-adapters/box.tsx +++ b/shared/common-adapters/box.tsx @@ -65,7 +65,7 @@ const hgapEndStyles = new Map(marginKeys.map(gap => [gap, {paddingRight: Styles. const vgapEndStyles = new Map(marginKeys.map(gap => [gap, {paddingBottom: Styles.globalMargins[gap]}])) const paddingStyles = new Map(marginKeys.map(p => [p, {padding: Styles.globalMargins[p]}])) -export const box2SharedProps = (p: Box2Props) => { +const box2SharedProps = (p: Box2Props) => { const {direction, fullHeight, fullWidth, centerChildren, alignSelf, alignItems, noShrink} = p const {flex, justifyContent, overflow, padding, relative} = p const {collapsable = true, onLayout, pointerEvents, children, gap, gapStart, gapEnd} = p @@ -185,7 +185,7 @@ export const box2SharedProps = (p: Box2Props) => { } // Shared className generator used by Box2 and ClickableBox. -export const box2ClassNames = (p: Box2Props, extra?: string): string => { +const box2ClassNames = (p: Box2Props, extra?: string): string => { const {direction, alignItems, alignSelf, gap, gapStart, gapEnd, justifyContent, overflow} = p const {padding, centerChildren, flex, fullHeight, fullWidth, noShrink, pointerEvents, relative, tooltip, className} = p const horizontal = direction === 'horizontal' || direction === 'horizontalReverse' diff --git a/shared/common-adapters/button.tsx b/shared/common-adapters/button.tsx index 0659f44ceb34..174f3fd7e5bd 100644 --- a/shared/common-adapters/button.tsx +++ b/shared/common-adapters/button.tsx @@ -65,10 +65,8 @@ const secondaryContainer: Styles._StylesCrossPlatform = Styles.platformStyles({ common: baseContainer, isElectron: {backgroundColor: Styles.globalColors.white}, isMobile: { + ...Styles.border(Styles.globalColors.black_20, 1), backgroundColor: Styles.globalColors.white, - borderColor: Styles.globalColors.black_20, - borderStyle: 'solid' as const, - borderWidth: 1, }, }) @@ -147,8 +145,9 @@ const Progress = ({small, white}: {small?: boolean; white: boolean}) => { type FullProps = ButtonProps & {ref?: React.Ref} -const ButtonDesktop = (props: FullProps) => { - const {children, label, onClick, ref: measureRef, type = 'Default', mode = 'Primary', small, fullWidth, disabled, waiting, tooltip, style, labelStyle: labelStyleOverride, testID} = props +// Style/state derivation shared by the desktop and native renderers +const buttonShared = (props: FullProps) => { + const {children, label, type = 'Default', mode = 'Primary', small, fullWidth, disabled, waiting, style} = props const unclickable = disabled || waiting const isPrimary = mode === 'Primary' const hasChildrenOnly = !!children && !label @@ -169,6 +168,15 @@ const ButtonDesktop = (props: FullProps) => { ]) : (container as Styles.StylesCrossPlatform) + const whiteSpinner = isPrimary && type !== 'Dim' + + return {containerStyle, isPrimary, labelStyle, type, unclickable, whiteSpinner} +} + +const ButtonDesktop = (props: FullProps) => { + const {onClick, ref: measureRef, small, waiting, tooltip, labelStyle: labelStyleOverride, testID, children, label} = props + const {containerStyle, isPrimary, labelStyle, type, unclickable, whiteSpinner} = buttonShared(props) + const className = Styles.classNames( isPrimary ? 'button--primary' : 'button--secondary', `button--type-${type}`, @@ -184,8 +192,6 @@ const ButtonDesktop = (props: FullProps) => { } : undefined - const whiteSpinner = isPrimary && type !== 'Dim' - const btn = (
} data-testid={testID}> {children} @@ -207,30 +213,11 @@ const ButtonDesktop = (props: FullProps) => { const ButtonNative = (props: FullProps) => { const {Pressable, Text: RNText, View} = require('react-native') as {Pressable: typeof PressableType; Text: typeof RNTextType; View: typeof ViewType} - const {children, label, onClick, type = 'Default', mode = 'Primary', small, fullWidth, disabled, waiting, style, labelStyle: labelStyleOverride, testID} = props - const unclickable = disabled || waiting - const isPrimary = mode === 'Primary' - const hasChildrenOnly = !!children && !label - - const container = isPrimary ? primaryContainers[type] : secondaryContainer - const labelStyle = isPrimary ? primaryLabelStyles[type] : secondaryLabelStyles[type] - - const needsCollapse = small || fullWidth || unclickable || hasChildrenOnly || style - const containerStyle = needsCollapse - ? Styles.collapseStyles([ - container, - small && smallStyle, - hasChildrenOnly && childrenOnlyStyle, - hasChildrenOnly && small && childrenOnlySmallStyle, - fullWidth && fullWidthStyle, - unclickable && opacity30Style, - style, - ]) - : (container as Styles.StylesCrossPlatform) + const {children, label, onClick, small, waiting, labelStyle: labelStyleOverride, testID} = props + const {containerStyle, labelStyle, unclickable, whiteSpinner} = buttonShared(props) const handlePress = unclickable ? undefined : onClick - const whiteSpinner = isPrimary && type !== 'Dim' const fontStyle = {...Styles.globalStyles.fontSemibold, fontSize: 16} const inner = ( diff --git a/shared/common-adapters/checkbox.tsx b/shared/common-adapters/checkbox.tsx index 4a8c4c87624e..b348e088c07d 100644 --- a/shared/common-adapters/checkbox.tsx +++ b/shared/common-adapters/checkbox.tsx @@ -21,9 +21,8 @@ type Props = { } const CHECKBOX_SIZE = 13 -const CHECKBOX_MARGIN = 8 -const Kb = {Box2, ClickableBox, Icon, Styles, Switch, Text} +const Kb = {Box2, ClickableBox, Icon, Switch, Text} const Checkbox = (props: Props) => { if (!isMobile) { @@ -32,7 +31,8 @@ const Checkbox = (props: Props) => { direction="horizontal" alignItems="flex-start" alignSelf="flex-start" - style={Kb.Styles.collapseStyles([ + gap="tiny" + style={Styles.collapseStyles([ styles.container, !props.disabled && styles.clickable, props.style, @@ -42,8 +42,8 @@ const Checkbox = (props: Props) => { } >
{ >
@@ -90,42 +90,41 @@ const Checkbox = (props: Props) => { ) } -const styles = Kb.Styles.styleSheetCreate(() => ({ - checkbox: Kb.Styles.platformStyles({ +const styles = Styles.styleSheetCreate(() => ({ + checkbox: Styles.platformStyles({ isElectron: { - ...Kb.Styles.globalStyles.flexBoxColumn, - ...Kb.Styles.transition('background'), - backgroundColor: Kb.Styles.globalColors.white, - ...Kb.Styles.border(Kb.Styles.globalColors.black_20, 1, 2), + ...Styles.globalStyles.flexBoxColumn, + ...Styles.transition('background'), + backgroundColor: Styles.globalColors.white, + ...Styles.border(Styles.globalColors.black_20, 1, 2), flexShrink: 0, - ...Kb.Styles.size(CHECKBOX_SIZE), + ...Styles.size(CHECKBOX_SIZE), justifyContent: 'center', - marginRight: CHECKBOX_MARGIN, marginTop: 2, position: 'relative', }, }), checkboxChecked: { - backgroundColor: Kb.Styles.globalColors.blue, - borderColor: Kb.Styles.globalColors.blue, + backgroundColor: Styles.globalColors.blue, + borderColor: Styles.globalColors.blue, }, - checkboxInactive: {borderColor: Kb.Styles.globalColors.black_10}, - clickable: Kb.Styles.platformStyles({ + checkboxInactive: {borderColor: Styles.globalColors.black_10}, + clickable: Styles.platformStyles({ isElectron: { - ...Kb.Styles.desktopStyles.clickable, + ...Styles.desktopStyles.clickable, }, }), container: { - ...Kb.Styles.paddingV(2), + ...Styles.paddingV(2), }, - icon: Kb.Styles.platformStyles({ + icon: Styles.platformStyles({ isElectron: { - ...Kb.Styles.transition('opacity'), + ...Styles.transition('opacity'), alignSelf: 'center', }, }), mobileContainer: { - ...Kb.Styles.paddingV(Kb.Styles.globalMargins.xtiny), + ...Styles.paddingV(Styles.globalMargins.xtiny), }, semiTransparent: {opacity: 0.4}, transparent: {opacity: 0}, diff --git a/shared/common-adapters/confirm-buttons.tsx b/shared/common-adapters/confirm-buttons.tsx new file mode 100644 index 000000000000..1ed26c34a16f --- /dev/null +++ b/shared/common-adapters/confirm-buttons.tsx @@ -0,0 +1,103 @@ +import Button, {type ButtonProps} from './button' +import ButtonBar from './button-bar' +import WaitingButton from './waiting-button' +import {Box2} from './box' +import * as Styles from '@/styles' + +const Kb = { + Box2, + Button, + ButtonBar, + WaitingButton, +} + +type Props = { + cancelLabel?: string + confirmDisabled?: boolean + confirmLabel: string + confirmType?: ButtonProps['type'] + onCancel: () => void + onConfirm: () => void + // modal-footer variant: buttons share the row 50/50, cancel is desktop-only + // (mobile modals close from the header) + split?: boolean + style?: Styles.StylesCrossPlatform + // either follow keys in the waiting store, or drive directly with a boolean + waitingKey?: string | Array + waiting?: boolean +} + +/** + * The standard cancel + confirm footer for form/confirmation screens. Both + * buttons follow the same waiting state: confirm shows the spinner, cancel + * just disables. Desktop: row with confirm on the right. Mobile: stacked + * full width with confirm on top. + */ +const ConfirmButtons = (props: Props) => { + const splitStyle = props.split ? styles.split : undefined + const cancel = props.waitingKey ? ( + + ) : ( + + ) + const confirm = props.waitingKey ? ( + + ) : ( + + ) + if (props.split) { + return ( + + {!isMobile && cancel} + {confirm} + + ) + } + return ( + + {isMobile ? confirm : cancel} + {isMobile ? cancel : confirm} + + ) +} + +const styles = Styles.styleSheetCreate(() => ({ + split: {...Styles.globalStyles.flexOne}, +})) + +export default ConfirmButtons diff --git a/shared/common-adapters/confirm-modal.tsx b/shared/common-adapters/confirm-modal.tsx index fda729795473..2b9b0a306486 100644 --- a/shared/common-adapters/confirm-modal.tsx +++ b/shared/common-adapters/confirm-modal.tsx @@ -34,7 +34,6 @@ const ConfirmModal = (props: Props) => ( ) : null} { onPress={() => handleCopy()} style={props.style} > - + {props.value} - - - - {hasCopied ? 'Copied!' : 'Tap to copy'} - - + + + {hasCopied ? 'Copied!' : 'Tap to copy'} + @@ -76,7 +74,7 @@ const styles = Styles.styleSheetCreate( textAlign: 'left', }, isElectron: { - border: `solid 1px ${Styles.globalColors.black_10}`, + ...Styles.border(Styles.globalColors.black_10, 1), justifyContent: 'stretch', lineHeight: '17px', overflowX: 'hidden', @@ -95,15 +93,12 @@ const styles = Styles.styleSheetCreate( }), copyToast: { ...Styles.paddingH(Styles.globalMargins.medium), + // Box2 defaults to alignSelf center, which centers the absolute pill horizontally backgroundColor: Styles.globalColors.black_50, borderRadius: Styles.globalMargins.large, - height: Styles.globalMargins.medium + Styles.globalMargins.tiny, - }, - copyToastContainer: { bottom: Styles.globalMargins.small, - left: 0, + height: Styles.globalMargins.medium + Styles.globalMargins.tiny, position: 'absolute', - right: 0, }, copyToastText: { color: Styles.globalColors.white, diff --git a/shared/common-adapters/delayed-mounting.tsx b/shared/common-adapters/delayed-mounting.tsx deleted file mode 100644 index c63e2d089d04..000000000000 --- a/shared/common-adapters/delayed-mounting.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react' -import {useTimeout} from './use-timers' - -type Props = { - delay: number - children: React.ReactNode -} - -const DelayedMounting = (props: Props) => { - const [showing, setShowing] = React.useState(false) - const setShowingTrue = useTimeout(() => setShowing(true), props.delay) - React.useEffect(setShowingTrue, [setShowingTrue]) - return <>{showing && props.children} -} - -export default DelayedMounting diff --git a/shared/common-adapters/dropdown.css b/shared/common-adapters/dropdown.css index b7db63768f49..1edf1e788047 100644 --- a/shared/common-adapters/dropdown.css +++ b/shared/common-adapters/dropdown.css @@ -1,8 +1,8 @@ .dropdown_border { border: solid 1px var(--color-black_10); - color: var(--color.black_50); + color: var(--color-black_50); &.hover:hover { border: solid 1px var(--color-blue); - color: var(--color.blueDark); + color: var(--color-blueDark); } } diff --git a/shared/common-adapters/dropdown.tsx b/shared/common-adapters/dropdown.tsx index 15d90454cf1c..5b254f2345dd 100644 --- a/shared/common-adapters/dropdown.tsx +++ b/shared/common-adapters/dropdown.tsx @@ -39,7 +39,9 @@ export const DropdownButton = (props: DropdownButtonProps) => { return ( { }, disabled ? {opacity: 0.3} : {}, {cursor: !disabled ? 'pointer' : undefined}, - {width: inline ? undefined : '100%'}, style, ])} > @@ -223,8 +224,6 @@ const styles = Styles.styleSheetCreate( }, }), measureBox: { - ...Styles.globalStyles.flexBoxRow, - alignItems: 'center', ...(isMobile ? { borderColor: Styles.globalColors.black_10, diff --git a/shared/common-adapters/emoji/custom-emoji.tsx b/shared/common-adapters/emoji/custom-emoji.tsx index 250f40e4d1e1..ffce7fdcfd39 100644 --- a/shared/common-adapters/emoji/custom-emoji.tsx +++ b/shared/common-adapters/emoji/custom-emoji.tsx @@ -32,8 +32,7 @@ const CustomEmoji = (props: Props) => { return ( ({ diff --git a/shared/common-adapters/emoji/native-emoji.tsx b/shared/common-adapters/emoji/native-emoji.tsx index 687a5b63df41..617940063b26 100644 --- a/shared/common-adapters/emoji/native-emoji.tsx +++ b/shared/common-adapters/emoji/native-emoji.tsx @@ -27,7 +27,7 @@ const unifiedToNative = (unified: string) => const nameReg = /^(?::([^:]+):)(?::skin-tone-(\d):)?$/ -function EmojiWrapper(props: Props) { +function NativeEmoji(props: Props) { const {emojiName, size} = props if (isMobile) { @@ -115,4 +115,4 @@ function EmojiWrapper(props: Props) { ) } -export default EmojiWrapper +export default NativeEmoji diff --git a/shared/common-adapters/emoji/slow-data.tsx b/shared/common-adapters/emoji/slow-data.tsx index cf5bab1f2910..9811b2e542a5 100644 --- a/shared/common-adapters/emoji/slow-data.tsx +++ b/shared/common-adapters/emoji/slow-data.tsx @@ -36,7 +36,7 @@ for (const cat in categorized) { } } -export const categoryOrder = [ +const categoryOrder = [ 'Smileys & People', 'Animals & Nature', 'Food & Drink', diff --git a/shared/common-adapters/error-boundary.tsx b/shared/common-adapters/error-boundary.tsx index 28929fc00eae..6a13a01cb45d 100644 --- a/shared/common-adapters/error-boundary.tsx +++ b/shared/common-adapters/error-boundary.tsx @@ -132,7 +132,7 @@ type Props = { fallbackStyle?: Styles.StylesCrossPlatform } -const EB = (p: Props) => { +const ErrorBoundary = (p: Props) => { const {children, fallbackStyle, closeOnClick} = p const [componentStack, setComponentStack] = React.useState('') @@ -166,4 +166,4 @@ const styles = Styles.styleSheetCreate( }) as const ) -export default EB +export default ErrorBoundary diff --git a/shared/common-adapters/floating-menu/menu-layout/index.tsx b/shared/common-adapters/floating-menu/menu-layout/index.tsx index f1530f12a251..f8718e61a57b 100644 --- a/shared/common-adapters/floating-menu/menu-layout/index.tsx +++ b/shared/common-adapters/floating-menu/menu-layout/index.tsx @@ -188,7 +188,7 @@ const MenuRow = (props: MenuRowProps) => ( gap={props.icon ? 'small' : undefined} > {props.icon || props.isSelected ? ( - + {props.isSelected && ( ( ) : null} - + {props.title} @@ -388,7 +382,6 @@ const desktopStyles = Styles.styleSheetCreate( iconBadge: sharedIconBadgeStyle, itemBodyText: {color: undefined}, itemContainer: { - ...Styles.globalStyles.flexBoxColumn, ...Styles.padding(7, Styles.globalMargins.small), }, menuContainer: Styles.platformStyles({ diff --git a/shared/common-adapters/floating-picker.tsx b/shared/common-adapters/floating-picker.tsx index 28cd32f8c02a..283042440b00 100644 --- a/shared/common-adapters/floating-picker.tsx +++ b/shared/common-adapters/floating-picker.tsx @@ -6,7 +6,7 @@ import {Box2} from './box' import Popup from './popup' import Text from './text' -export type PickerItem = { +type PickerItem = { label: string value: T } @@ -59,8 +59,6 @@ function WrapPicker(p: { ) } -export {Picker} - // NOTE: this doesn't seem to work well when debugging w/ chrome. aka if you scroll and set a value // the native component will undo it a bunch and its very finnicky. works fine outside of that it seems const FloatingPicker = (props: Props): React.ReactNode => { diff --git a/shared/common-adapters/header-buttons.tsx b/shared/common-adapters/header-buttons.tsx index 95bd7ba17054..ba0d691d31c5 100644 --- a/shared/common-adapters/header-buttons.tsx +++ b/shared/common-adapters/header-buttons.tsx @@ -51,7 +51,6 @@ const styles = Styles.styleSheetCreate(() => ({ leftAction: Styles.platformStyles({ common: { flexShrink: 1, - justifyContent: 'flex-start', }, }), rightAction: Styles.platformStyles({ diff --git a/shared/common-adapters/icon.shared.tsx b/shared/common-adapters/icon.shared.tsx index febd237a989a..add18aa05e16 100644 --- a/shared/common-adapters/icon.shared.tsx +++ b/shared/common-adapters/icon.shared.tsx @@ -1,14 +1,6 @@ import type {IconType} from './icon.constants-gen' import {iconMeta} from './icon.constants-gen' -export function typeExtension(type: IconType): string { - return iconMeta[type].extension || 'png' -} - -export function getImagesDir(type: IconType): string { - return iconMeta[type].imagesDir || 'icons' -} - export function isValidIconType(inputType: string): inputType is IconType { if (!inputType) return false const iconType = inputType as IconType diff --git a/shared/common-adapters/icon.tsx b/shared/common-adapters/icon.tsx index 676e7743380f..6d13e58d3abc 100644 --- a/shared/common-adapters/icon.tsx +++ b/shared/common-adapters/icon.tsx @@ -18,6 +18,7 @@ export type IconProps = { hint?: string onClick?: () => void padding?: keyof typeof Styles.globalMargins + testID?: string } const sizeToFontDesktop = {Big: 24, Bigger: 36, Default: 16, Huge: 48, Small: 12, Tiny: 8} as const @@ -63,7 +64,7 @@ const IconDesktop = (props: IconProps) => { ? ({...inlineStyle, cursor: 'pointer'} as React.CSSProperties) : inlineStyle - return + return } const nativeBaseStyle: Styles._StylesCrossPlatform = { @@ -95,6 +96,7 @@ const IconNative = (props: IconProps) => { allowFontScaling={false} suppressHighlighting={true} onPress={onClick} + testID={props.testID} > {code} diff --git a/shared/common-adapters/image-icon.tsx b/shared/common-adapters/image-icon.tsx index e47b310f4ca0..8803918f0500 100644 --- a/shared/common-adapters/image-icon.tsx +++ b/shared/common-adapters/image-icon.tsx @@ -1,7 +1,6 @@ import type * as Styles from '@/styles' import {iconMeta} from './icon.constants-gen' import type {IconType} from './icon.constants-gen' -import {typeExtension, getImagesDir} from './icon.shared' import type {Image as RNImageType} from 'react-native' import type {getAssetPath as getAssetPathType} from '@/constants/platform' @@ -12,6 +11,9 @@ export type ImageIconProps = { allowLazy?: boolean } +const typeExtension = (type: IconType) => iconMeta[type].extension || 'png' +const getImagesDir = (type: IconType) => iconMeta[type].imagesDir || 'icons' + const ImageIconDesktop = (props: ImageIconProps) => { const {getAssetPath} = require('@/constants/platform') as {getAssetPath: typeof getAssetPathType} const {type, style, className, allowLazy = true} = props diff --git a/shared/common-adapters/index-impl.js b/shared/common-adapters/index-impl.js index 6569d00f13bd..53e42a3638fd 100644 --- a/shared/common-adapters/index-impl.js +++ b/shared/common-adapters/index-impl.js @@ -33,15 +33,9 @@ module.exports = { get BottomSheetScrollView() { return require('./popup/bottom-sheet').BottomSheetScrollView }, - get Box() { - return require('./box').default - }, get Box2() { return require('./box').Box2 }, - get Box2Animated() { - return require('./box').Box2Animated - }, get BoxGrow() { return require('./box-grow').default }, @@ -60,9 +54,6 @@ module.exports = { get Checkbox() { return require('./checkbox').default }, - get ChoiceList() { - return require('./choice-list').default - }, get ConfirmModal() { return require('./confirm-modal').default }, @@ -86,9 +77,6 @@ module.exports = { get CopyableText() { return require('./copyable-text').default }, - get DelayedMounting() { - return require('./delayed-mounting').default - }, get Divider() { return require('./divider').default }, @@ -170,9 +158,6 @@ module.exports = { get Placeholder() { return require('./placeholder').default }, - get PlatformIcon() { - return require('./platform-icon').default - }, get Popup() { return require('./popup/index').default }, @@ -194,9 +179,6 @@ module.exports = { get Reloadable() { return require('./reload').default }, - get RichButton() { - return require('./rich-button').default - }, get RoundedBox() { return require('./rounded-box').default }, @@ -233,15 +215,9 @@ module.exports = { get Tabs() { return require('./tabs').default }, - get TeamWithPopup() { - return require('./team-with-popup').default - }, get Text() { return require('./text').default }, - get TimelineMarker() { - return require('./timeline-marker').default - }, get Toast() { return require('./toast').default }, diff --git a/shared/common-adapters/index.tsx b/shared/common-adapters/index.tsx index 802063b73f98..7dae2f5e0709 100644 --- a/shared/common-adapters/index.tsx +++ b/shared/common-adapters/index.tsx @@ -15,7 +15,8 @@ export {default as AvatarLine} from './avatar/avatar-line' export {BottomAccessory} from './bottom-accessory' export {default as BackButton} from './back-button' export {default as Badge} from './badge' -export {Banner, BannerParagraph} from './banner' +export {Banner, BannerParagraph, ErrorBanner} from './banner' +export {default as ConfirmButtons} from './confirm-buttons' export {Box2} from './box' export type {LayoutEvent} from './box' export type {MeasureRef} from './measure-ref' @@ -30,6 +31,7 @@ export {ClickableBox} from './box' export type {ClickableBoxProps} from './box' export {default as ConfirmModal} from './confirm-modal' export {default as ModalFooter} from './modal-footer' +export {LoadingScreen, LoadingOverlay} from './loading' export {default as CopyText} from './copy-text' export {default as CopyableText} from './copyable-text' export {default as Divider} from './divider' diff --git a/shared/common-adapters/input3.tsx b/shared/common-adapters/input3.tsx index fd22408632ed..ce3d8be88997 100644 --- a/shared/common-adapters/input3.tsx +++ b/shared/common-adapters/input3.tsx @@ -21,6 +21,41 @@ type InputLikeRef = { value?: string } type InputChangeEvent = {target: {value: string}} + +// Shared container + icon/prefix/decoration frame around the platform input element +const InputFrame = (p: { + children: React.ReactNode + focused: boolean + fontSize?: number + props: Input3Props +}) => { + const {children, focused, fontSize, props} = p + const {containerStyle, decoration, disabled, error, hideBorder, icon, prefix} = props + return ( + + {!!icon && ( + + + + )} + {!!prefix && {prefix}} + {children} + {decoration} + + ) +} type InputKeyEvent = { key?: string shiftKey?: boolean @@ -30,10 +65,10 @@ type InputKeyEvent = { const DesktopInput3 = (props: Input3Props & {ref?: React.Ref}) => { const { - autoCapitalize, autoCorrect, autoFocus, containerStyle, decoration, disabled, error, - growAndScroll, hideBorder, icon, inputStyle, maxLength, multiline, selectTextOnFocus, + autoCapitalize, autoCorrect, autoFocus, disabled, + growAndScroll, inputStyle, maxLength, multiline, selectTextOnFocus, onBlur: onBlurProp, onChangeText, onClick, onEnterKeyDown, onFocus: onFocusProp, - onKeyDown: onKeyDownProp, placeholder, prefix, ref, rowsMax, rowsMin, + onKeyDown: onKeyDownProp, placeholder, ref, rowsMax, rowsMin, secureTextEntry, spellCheck, textType = 'BodySemibold', value, } = props @@ -146,36 +181,17 @@ const DesktopInput3 = (props: Input3Props & {ref?: React.Ref}) => { ) return ( - - {!!icon && ( - - - - )} - {!!prefix && {prefix}} + {inputElement} - {decoration} - + ) } const NativeInput3 = (props: Input3Props & {ref?: React.Ref}) => { const { - autoCapitalize, autoCorrect, autoFocus, containerStyle, decoration, disabled, error, - hideBorder, icon, inputStyle, keyboardType, maxLength, multiline, onEnterKeyDown, - onBlur: onBlurProp, onChangeText, onFocus: onFocusProp, placeholder, prefix, ref, + autoCapitalize, autoCorrect, autoFocus, disabled, + inputStyle, keyboardType, maxLength, multiline, onEnterKeyDown, + onBlur: onBlurProp, onChangeText, onFocus: onFocusProp, placeholder, ref, returnKeyType, rowsMax, rowsMin, secureTextEntry, selectTextOnFocus, textContentType, textType = 'BodySemibold', value, } = props @@ -220,25 +236,7 @@ const NativeInput3 = (props: Input3Props & {ref?: React.Ref}) => { const rows = rowsMin || Math.min(2, rowsMax || 2) return ( - - {!!icon && ( - - - - )} - {!!prefix && {prefix}} + }) => { underlineColorAndroid="transparent" value={value} /> - {decoration} - + ) } diff --git a/shared/common-adapters/list-common.tsx b/shared/common-adapters/list-common.tsx deleted file mode 100644 index 5fd7c3cb5f6b..000000000000 --- a/shared/common-adapters/list-common.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import {smallHeight, largeHeight} from './list-item' -import type {Props} from './list.shared' - -export function useListProps(p: Props) { - const { - items, - renderItem, - itemHeight, - onEndReached, - keyProperty, - estimatedItemHeight, - extraData: extraDataProp, - selectedIndex, - ListHeaderComponent, - ListFooterComponent, - recycleItems: recycleItemsOverride, - } = p - const {getItemType, drawDistance, onViewableItemsChanged, viewabilityConfig} = p - - const legendRenderItem = ({item, index}: {item: T; index: number}) => { - return renderItem(index, item) - } - - const keyExtractor = p.keyExtractor - ? p.keyExtractor - : keyProperty - ? (item: T, _index: number) => String((item as Record)[keyProperty]) - : (_item: T, index: number) => String(index) - - const getFixedItemSize = - itemHeight.type === 'fixed' - ? () => itemHeight.height - : itemHeight.type === 'fixedListItemAuto' - ? () => (itemHeight.sizeType === 'Large' ? largeHeight : smallHeight) - : itemHeight.type === 'variable' - ? (item: T, index: number) => itemHeight.getItemLayout(index, item).length - : itemHeight.type === 'perItem' - ? (item: T) => itemHeight.getSize(item) - : undefined - - const estimatedItemSize = - estimatedItemHeight ?? - (itemHeight.type === 'fixed' - ? itemHeight.height - : itemHeight.type === 'fixedListItemAuto' - ? itemHeight.sizeType === 'Large' - ? largeHeight - : smallHeight - : 48) - - const recycleItems = - recycleItemsOverride ?? - (itemHeight.type === 'fixed' || itemHeight.type === 'fixedListItemAuto' || itemHeight.type === 'perItem') - - return { - ListFooterComponent, - ListHeaderComponent, - data: items as T[], - drawDistance, - empty: items.length === 0, - estimatedItemSize, - extraData: extraDataProp ?? selectedIndex, - getFixedItemSize, - getItemType, - keyExtractor, - onEndReached: onEndReached ? () => onEndReached() : undefined, - onViewableItemsChanged: onViewableItemsChanged as any, - recycleItems, - renderItem: legendRenderItem, - viewabilityConfig, - } -} diff --git a/shared/common-adapters/list-item.tsx b/shared/common-adapters/list-item.tsx index 2a725c626a4d..59489af2396a 100644 --- a/shared/common-adapters/list-item.tsx +++ b/shared/common-adapters/list-item.tsx @@ -71,8 +71,7 @@ const ListItem = (props: Props) => { direction="vertical" style={getStatusIconStyle(props)} alignSelf="flex-start" - alignItems="center" - justifyContent="center" + centerChildren={true} > {props.statusIcon} @@ -136,12 +135,10 @@ const styles = Styles.styleSheetCreate(() => { clickableBoxLarge: { flexShrink: 0, minHeight: largeHeight, - width: '100%', } as const, clickableBoxSmall: { flexShrink: 0, minHeight: smallHeight, - width: '100%', } as const, contentContainer: { flexGrow: 1, @@ -338,11 +335,12 @@ const CardListItem = (props: Props) => ( direction="horizontal" alignItems="center" fullWidth={true} + gap="small" overflow="hidden" padding="small" style={Styles.collapseStyles([cardStyles.card, props.style])} > - {props.icon && {props.icon}} + {props.icon} {props.body} ) @@ -352,9 +350,6 @@ const cardStyles = Styles.styleSheetCreate(() => ({ ...Styles.border(Styles.globalColors.grey, 1, Styles.borderRadius), backgroundColor: Styles.globalColors.white, }, - icon: { - marginRight: Styles.globalMargins.small, - }, })) export default ListItem diff --git a/shared/common-adapters/list.tsx b/shared/common-adapters/list.tsx index c19297293950..aefa5d0fef3f 100644 --- a/shared/common-adapters/list.tsx +++ b/shared/common-adapters/list.tsx @@ -4,13 +4,85 @@ import * as Styles from '@/styles' import type {Props} from './list.shared' import {LegendList as LegendListWeb} from '@legendapp/list/react' import {LegendList as LegendListNative} from '@legendapp/list/react-native' -import {useListProps} from './list-common' +import {smallHeight, largeHeight} from './list-item' export type {LegendListRef, Props} from './list.shared' +function useListProps(p: Props) { + const { + items, + renderItem, + itemHeight, + onEndReached, + keyProperty, + estimatedItemHeight, + extraData: extraDataProp, + selectedIndex, + ListHeaderComponent, + ListFooterComponent, + recycleItems: recycleItemsOverride, + } = p + const {getItemType, drawDistance, onViewableItemsChanged, viewabilityConfig} = p + + const legendRenderItem = ({item, index}: {item: T; index: number}) => { + return renderItem(index, item) + } + + const keyExtractor = p.keyExtractor + ? p.keyExtractor + : keyProperty + ? (item: T, _index: number) => String((item as Record)[keyProperty]) + : (_item: T, index: number) => String(index) + + const getFixedItemSize = + itemHeight.type === 'fixed' + ? () => itemHeight.height + : itemHeight.type === 'fixedListItemAuto' + ? () => (itemHeight.sizeType === 'Large' ? largeHeight : smallHeight) + : itemHeight.type === 'variable' + ? (item: T, index: number) => itemHeight.getItemLayout(index, item).length + : itemHeight.type === 'perItem' + ? (item: T) => itemHeight.getSize(item) + : undefined + + const estimatedItemSize = + estimatedItemHeight ?? + (itemHeight.type === 'fixed' + ? itemHeight.height + : itemHeight.type === 'fixedListItemAuto' + ? itemHeight.sizeType === 'Large' + ? largeHeight + : smallHeight + : 48) + + const recycleItems = + recycleItemsOverride ?? + (itemHeight.type === 'fixed' || itemHeight.type === 'fixedListItemAuto' || itemHeight.type === 'perItem') + + return { + empty: items.length === 0 && !ListHeaderComponent && !ListFooterComponent, + listProps: { + ListFooterComponent, + ListHeaderComponent, + data: items as T[], + drawDistance, + estimatedItemSize, + extraData: extraDataProp ?? selectedIndex, + getFixedItemSize, + getItemType, + keyExtractor, + onEndReached: onEndReached ? () => onEndReached() : undefined, + onViewableItemsChanged: onViewableItemsChanged as any, + recycleItems, + renderItem: legendRenderItem, + viewabilityConfig, + }, + } +} + const DesktopList = function List({ref, ...p}: Props) { - const {empty, ...listProps} = useListProps(p as Props) + const {empty, listProps} = useListProps(p as Props) const {style} = p - if (empty && !p.ListHeaderComponent && !p.ListFooterComponent) return null + if (empty) return null return ( ({ref, ...p}: Props) { } const NativeList = function List({ref, ...p}: Props) { - const {empty, ...listProps} = useListProps(p as Props) - if (empty && !p.ListHeaderComponent && !p.ListFooterComponent) return null + const {empty, listProps} = useListProps(p as Props) + if (empty) return null return ( diff --git a/shared/common-adapters/loading-line.tsx b/shared/common-adapters/loading-line.tsx index 0fea294c0027..e6ce3bc667e7 100644 --- a/shared/common-adapters/loading-line.tsx +++ b/shared/common-adapters/loading-line.tsx @@ -22,7 +22,7 @@ function LoadingLine() { if (!isMobile) { return ( - + ) @@ -36,7 +36,6 @@ const styles = Styles.styleSheetCreate(() => ({ left: 0, position: 'absolute', top: 0, - width: '100%', }, line: { backgroundColor: Styles.globalColors.blue, diff --git a/shared/common-adapters/loading.tsx b/shared/common-adapters/loading.tsx new file mode 100644 index 000000000000..a8c5148fd022 --- /dev/null +++ b/shared/common-adapters/loading.tsx @@ -0,0 +1,27 @@ +import * as Styles from '@/styles' +import {Box2} from './box' +import ProgressIndicator from './progress-indicator' + +const Kb = { + Box2, + ProgressIndicator, +} + +// full-size centered spinner, for screens that have nothing to show yet +export const LoadingScreen = (props: {type?: 'Small' | 'Large' | 'Huge'}) => ( + + + +) + +// spinner covering the parent (which needs relative positioning) while keeping content visible +export const LoadingOverlay = (props: {show: boolean}) => + props.show ? ( + + + + ) : null + +const styles = Styles.styleSheetCreate(() => ({ + overlay: {...Styles.globalStyles.fillAbsolute}, +})) diff --git a/shared/common-adapters/markdown/react.tsx b/shared/common-adapters/markdown/react.tsx index fec79a78bd71..8c1355eeeda7 100644 --- a/shared/common-adapters/markdown/react.tsx +++ b/shared/common-adapters/markdown/react.tsx @@ -45,15 +45,20 @@ const electronWrapStyle = { wordBreak: 'break-word', } as const +// markdown spans take color/weight from the enclosing message text +const electronInherit = { + color: 'inherit', + fontWeight: 'inherit', +} as const + const _markdownStyles = Styles.styleSheetCreate( () => ({ bigTextBlockStyle: Styles.platformStyles({ isElectron: { ...electronWrapStyle, - color: 'inherit', + ...electronInherit, display: 'block', - fontWeight: 'inherit', }, isMobile: { fontSize: 32, @@ -105,11 +110,11 @@ const _markdownStyles = Styles.styleSheetCreate( }), italicStyle: Styles.platformStyles({ common: {fontStyle: 'italic'}, - isElectron: {color: 'inherit', fontWeight: 'inherit', ...electronWrapStyle}, + isElectron: {...electronInherit, ...electronWrapStyle}, isMobile: {color: undefined, fontWeight: undefined}, }), neutralPreviewStyle: Styles.platformStyles({ - isElectron: {color: 'inherit', fontWeight: 'inherit'}, + isElectron: electronInherit, isMobile: {color: Styles.globalColors.black_50, fontWeight: undefined}, }), quoteStyle: Styles.platformStyles({ @@ -137,8 +142,7 @@ const _markdownStyles = Styles.styleSheetCreate( strikeStyle: Styles.platformStyles({ isElectron: { ...electronWrapStyle, - color: 'inherit', - fontWeight: 'inherit', + ...electronInherit, textDecoration: 'line-through', }, isMobile: { @@ -148,7 +152,7 @@ const _markdownStyles = Styles.styleSheetCreate( }), textBlockStyle: Styles.platformStyles({ isAndroid: {lineHeight: undefined}, - isElectron: {color: 'inherit', display: 'block', fontWeight: 'inherit', ...electronWrapStyle}, + isElectron: {...electronInherit, display: 'block', ...electronWrapStyle}, }), wrapStyle: Styles.platformStyles({isElectron: electronWrapStyle}), }) as const diff --git a/shared/common-adapters/markdown/service-decoration.tsx b/shared/common-adapters/markdown/service-decoration.tsx index 4faa4791bf98..8a617f8f4c51 100644 --- a/shared/common-adapters/markdown/service-decoration.tsx +++ b/shared/common-adapters/markdown/service-decoration.tsx @@ -24,6 +24,33 @@ const linkStyle = Styles.platformStyles({ isMobile: {fontWeight: undefined}, }) +// Shared shell for every link decoration: same text type, hover classes and +// [wrapStyle, linkStyle, override] style merge; behavior comes from url/onClick +const LinkText = (p: { + children: React.ReactNode + className?: string + linkStyle?: StylesTextCrossPlatform | undefined + onClick?: () => void + title: string + url?: string + wrapStyle?: StylesTextCrossPlatform | undefined +}) => { + const {children, className = 'hover-underline hover_contained_color_blueDark', onClick, title, url} = p + const urlProps = useClickURL(url) + return ( + + {children} + + ) +} + type KeybaseLinkProps = { link: string linkStyle?: StylesTextCrossPlatform | undefined @@ -31,24 +58,19 @@ type KeybaseLinkProps = { } const KeybaseLink = (props: KeybaseLinkProps) => { - const onClick = () => { - // Route through the linking config for keybase:// and https://keybase.io/ URLs - // so React Navigation handles navigation declaratively. - // emitDeepLink normalizes URLs and dispatches through the linking config, - // falling back to handleAppLink for patterns not yet in the config. - emitDeepLink(props.link) - } - + // Route through the linking config for keybase:// and https://keybase.io/ URLs + // so React Navigation handles navigation declaratively. + // emitDeepLink normalizes URLs and dispatches through the linking config, + // falling back to handleAppLink for patterns not yet in the config. return ( - emitDeepLink(props.link)} > {props.link} - + ) } @@ -63,29 +85,28 @@ type WarningLinkProps = { const WarningLink = (props: WarningLinkProps) => { const {display, punycode, url} = props const navigateAppend = C.Router2.navigateAppend - const urlProps = useClickURL(url) if (isMobile) { return ( - navigateAppend({name: 'chatConfirmNavigateExternal', params: {display, punycode, url}}) } > {display} - + ) } return ( - { > {display} - - ) -} - -const URLText = (p: { - url: string - className?: string - type?: string - style?: Styles.StylesCrossPlatform - title?: string - children?: React.ReactNode -}) => { - const urlProps = useClickURL(p.url) - return ( - - {p.children} - + ) } @@ -196,30 +195,28 @@ const ServiceDecoration = (p: Props) => { wrapStyle={styles['wrapStyle']} /> ) : ( - {parsed.link.url} - + ) } else if (parsed.typ === T.RPCChat.UITextDecorationTyp.mailto) { const openUrl = parsed.mailto.url.toLowerCase().startsWith('mailto:') ? parsed.mailto.url : 'mailto:' + parsed.mailto.url return ( - {parsed.mailto.url} - + ) } else if (parsed.typ === T.RPCChat.UITextDecorationTyp.channelnamemention) { return ( diff --git a/shared/common-adapters/mention.tsx b/shared/common-adapters/mention.tsx index 66de05faeb61..80b71fe88e37 100644 --- a/shared/common-adapters/mention.tsx +++ b/shared/common-adapters/mention.tsx @@ -44,21 +44,19 @@ export default Mention const styles = Styles.styleSheetCreate(() => ({ follow: { backgroundColor: Styles.globalColors.greenLighterOrGreen, - borderRadius: 2, color: Styles.globalColors.greenDarkOrBlack, }, highlight: { backgroundColor: Styles.globalColors.yellowOrYellowAlt, - borderRadius: 2, color: Styles.globalColors.blackOrBlack, }, nonFollow: { backgroundColor: Styles.globalColors.blueLighter2, - borderRadius: 2, color: Styles.globalColors.blueDark, }, text: Styles.platformStyles({ common: { + borderRadius: 2, letterSpacing: 0.3, ...Styles.paddingH(2), }, diff --git a/shared/common-adapters/meta.tsx b/shared/common-adapters/meta.tsx index 87df1ffa9a46..dbc43fb1b81d 100644 --- a/shared/common-adapters/meta.tsx +++ b/shared/common-adapters/meta.tsx @@ -4,43 +4,60 @@ import IconAuto from './icon-auto' import type {IconType} from './icon.constants-gen' import * as Styles from '@/styles' -type Props = { - title: string | number +type BaseProps = { style?: Styles.StylesCrossPlatform size?: 'Small' color?: string - backgroundColor: string noUppercase?: boolean icon?: IconType iconColor?: Styles.Color } -const Meta = (props: Props) => ( - - {!!props.icon && } - { + const title = props.variant ? variants[props.variant].title : props.title + const backgroundColor = props.variant ? variants[props.variant].backgroundColor : props.backgroundColor + return ( + - {props.noUppercase || typeof props.title === 'number' ? props.title : props.title.toUpperCase()} - - -) + {!!props.icon && } + + {props.noUppercase || typeof title === 'number' ? title : title.toUpperCase()} + + + ) +} const styles = Styles.styleSheetCreate(() => ({ container: { @@ -50,9 +67,6 @@ const styles = Styles.styleSheetCreate(() => ({ containerSmall: { ...Styles.paddingH(2), }, - icon: { - paddingRight: Styles.globalMargins.xtiny, - }, text: Styles.platformStyles({ common: { color: Styles.globalColors.white, diff --git a/shared/common-adapters/name-with-icon.tsx b/shared/common-adapters/name-with-icon.tsx index a948f1ed3cab..c85aa3927676 100644 --- a/shared/common-adapters/name-with-icon.tsx +++ b/shared/common-adapters/name-with-icon.tsx @@ -229,23 +229,19 @@ export const NameWithIcon = (props: NameWithIconProps) => { ) + const boxProps = { + alignItems: 'center', + direction: props.horizontal ? 'horizontal' : 'vertical', + style: containerStyle, + } as const + + // ClickableBox only when clickable: it renders Pressable/clickable-box2 with different semantics return _onClickWrapper ? ( - e && _onClickWrapper(e)} - direction={props.horizontal ? 'horizontal' : 'vertical'} - alignItems="center" - style={containerStyle} - > + e && _onClickWrapper(e)}> {children} ) : ( - - {children} - + {children} ) } diff --git a/shared/common-adapters/phone-input.tsx b/shared/common-adapters/phone-input.tsx index 8b8a0a1a613f..16d7af7cda28 100644 --- a/shared/common-adapters/phone-input.tsx +++ b/shared/common-adapters/phone-input.tsx @@ -393,8 +393,9 @@ const PhoneInput = (p: Props) => { return ( @@ -512,7 +513,7 @@ const PhoneInput = (p: Props) => { return ( {!country ? !prefix @@ -525,12 +526,8 @@ const PhoneInput = (p: Props) => { return ( <> - - {getCountryEmoji(country)} - - - {'+' + String(prefix)} - + {getCountryEmoji(country)} + {'+' + String(prefix)} ) } @@ -538,8 +535,9 @@ const PhoneInput = (p: Props) => { return ( @@ -553,8 +551,9 @@ const PhoneInput = (p: Props) => { void; forceClose: () => void} type BackdropProps = BottomSheetBackdropProps & {disappearsOnIndex?: number; appearsOnIndex?: number; opacity?: number} -type HandleProps = BottomSheetHandleProps & {style?: object; indicatorStyle?: object; children?: React.ReactNode} type ScrollViewProps = {style?: object; children?: React.ReactNode} type GorhomModule = { BottomSheetModal: React.ForwardRefExoticComponent> BottomSheetView: React.ComponentType<{children?: React.ReactNode}> BottomSheetBackdrop: React.ComponentType BottomSheetScrollView: React.ComponentType - BottomSheetHandle: React.ComponentType } const _gorhom: GorhomModule | null = isMobile ? (_gorhomRaw as unknown as GorhomModule) : null @@ -59,10 +57,4 @@ export const BottomSheetScrollView = (_p: ScrollViewProps) => { return } -export const BottomSheetHandle = (_p: HandleProps) => { - if (!isMobile) return null - const {BottomSheetHandle: NativeHandle} = _gorhom! - return -} - export type {BottomSheetBackdropProps} from '@gorhom/bottom-sheet' diff --git a/shared/common-adapters/popup/index.tsx b/shared/common-adapters/popup/index.tsx index be988606d2af..28e66b945488 100644 --- a/shared/common-adapters/popup/index.tsx +++ b/shared/common-adapters/popup/index.tsx @@ -3,7 +3,6 @@ import * as Styles from '@/styles' import {Box2} from '../box' import FloatingBox from './floating-box' import {EscapeHandler} from '../key-event-handler' -import {Keyboard} from 'react-native' import {Portal} from '../portal' import { BottomSheetModal, @@ -52,30 +51,9 @@ function DesktopPopupPositioned(props: PopupProps) { ) } -function NativePopupPositioned(props: PopupProps) { - const {hideKeyboard, children, containerStyle} = props - const [lastHK, setLastHK] = React.useState(hideKeyboard) - if (lastHK !== hideKeyboard) { - setLastHK(hideKeyboard) - if (hideKeyboard) { - Keyboard.dismiss() - } - } - return ( - - - {children} - - - ) -} - function PopupPositioned(props: PopupProps) { - return isMobile ? : + // on mobile FloatingBox is the same portal + keyboard-dismiss overlay this needs + return isMobile ? : } function PopupCentered(props: PopupProps) { @@ -84,6 +62,7 @@ function PopupCentered(props: PopupProps) { {})}> { if (mouseDownOnCover) { @@ -95,7 +74,7 @@ function PopupCentered(props: PopupProps) { }} > { @@ -169,7 +148,6 @@ function Popup(props: PopupProps) { const desktopStyles = Styles.styleSheetCreate(() => ({ centeredContainer: { - ...Styles.globalStyles.flexBoxRow, maxHeight: '100%', maxWidth: '100%', }, @@ -185,9 +163,7 @@ const desktopStyles = Styles.styleSheetCreate(() => ({ }, }), cover: { - ...Styles.globalStyles.flexBoxColumn, ...Styles.globalStyles.fillAbsolute, - ...Styles.centered(), alignSelf: 'stretch', ...Styles.padding(Styles.globalMargins.large, Styles.globalMargins.large, Styles.globalMargins.small), }, diff --git a/shared/common-adapters/profile-card.tsx b/shared/common-adapters/profile-card.tsx index b4aae27e9050..054bcfc65c42 100644 --- a/shared/common-adapters/profile-card.tsx +++ b/shared/common-adapters/profile-card.tsx @@ -15,7 +15,7 @@ import {useCurrentUserState} from '@/stores/current-user' import ProgressIndicator from './progress-indicator' import Text from './text' import WithTooltip from './with-tooltip' -import DelayedMounting from './delayed-mounting' +import {useTimeout} from './use-timers' import {type default as FollowButtonType} from '../profile/user/actions/follow-button' import type ChatButtonType from '../chat/chat-button' import type {MeasureRef} from './measure-ref' @@ -25,6 +25,13 @@ import {noAssertion} from '@/tracker/model' const positionFallbacks = ['top center', 'bottom center'] as const +const DelayedMounting = (props: {delay: number; children: React.ReactNode}) => { + const [showing, setShowing] = React.useState(false) + const setShowingTrue = useTimeout(() => setShowing(true), props.delay) + React.useEffect(setShowingTrue, [setShowingTrue]) + return <>{showing && props.children} +} + const Kb = { Box2, ClickableBox, diff --git a/shared/common-adapters/radio-button.css b/shared/common-adapters/radio-button.css index 8c62b8b76c67..87e16cabb638 100644 --- a/shared/common-adapters/radio-button.css +++ b/shared/common-adapters/radio-button.css @@ -4,7 +4,6 @@ border: solid 1px var(--color-black_20); flex: none; height: 14px; - margin-right: 8px; position: relative; width: 14px; opacity: 1; diff --git a/shared/common-adapters/radio-button.tsx b/shared/common-adapters/radio-button.tsx index 316a4d9034f9..c079e689d1d3 100644 --- a/shared/common-adapters/radio-button.tsx +++ b/shared/common-adapters/radio-button.tsx @@ -18,8 +18,7 @@ const Kb = { Text, } -export const RADIOBUTTON_SIZE = 22 -export const RADIOBUTTON_MARGIN = 8 +const RADIOBUTTON_SIZE = 22 const RadioButton = ({disabled, label, onSelect, selected, style}: Props) => { if (!isMobile) { @@ -41,6 +40,7 @@ const RadioButton = ({disabled, label, onSelect, selected, style}: Props) => { onSelect(!selected)} > @@ -75,6 +75,7 @@ const desktopStyles = Styles.styleSheetCreate(() => ({ container: { ...Styles.globalStyles.flexBoxRow, alignItems: 'center', + gap: 8, }, radio: Styles.platformStyles({ isElectron: { @@ -106,13 +107,11 @@ const nativeStyles = Styles.styleSheetCreate( top: 5, }, outer: { + ...Styles.size(RADIOBUTTON_SIZE), backgroundColor: Styles.globalColors.white, borderRadius: 100, borderWidth: 1, - height: RADIOBUTTON_SIZE, - marginRight: RADIOBUTTON_MARGIN, position: 'relative' as const, - width: RADIOBUTTON_SIZE, }, }) as const ) diff --git a/shared/common-adapters/safe-area-view.tsx b/shared/common-adapters/safe-area-view.tsx index e55c90a52d32..91ae55b18e37 100644 --- a/shared/common-adapters/safe-area-view.tsx +++ b/shared/common-adapters/safe-area-view.tsx @@ -20,19 +20,18 @@ const SafeAreaViewTopNative = (p: Props) => { ) } -const SafeAreaViewTopDesktop = (props: Props): React.ReactNode => props.children ?? null +// desktop has no insets; both exports pass children straight through +const PassThrough = (props: Props): React.ReactNode => props.children ?? null const nativeStyles = Styles.styleSheetCreate(() => ({ topSafeArea: {backgroundColor: Styles.globalColors.white, flexGrow: 0}, })) -export const SafeAreaViewTop = isMobile ? SafeAreaViewTopNative : SafeAreaViewTopDesktop +export const SafeAreaViewTop = isMobile ? SafeAreaViewTopNative : PassThrough const desktopInsets = {bottom: 0, left: 0, right: 0, top: 0} export const useSafeAreaInsets = isMobile ? useSafeAreaInsetsNative : () => desktopInsets -const DesktopSafeAreaView = (props: Props): React.ReactNode => props.children ?? null - -export default isMobile ? SafeAreaView : DesktopSafeAreaView +export default isMobile ? SafeAreaView : PassThrough diff --git a/shared/common-adapters/scroll-view.tsx b/shared/common-adapters/scroll-view.tsx index 7a3b86ffb44f..0cd7008bba00 100644 --- a/shared/common-adapters/scroll-view.tsx +++ b/shared/common-adapters/scroll-view.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import * as Styles from '@/styles' -import {ScrollView as NativeScrollView} from 'react-native' +import {RefreshControl, ScrollView as NativeScrollView} from 'react-native' import type {ScrollViewProps, RefreshControlProps, GestureResponderEvent} from 'react-native' import type {StylesCrossPlatform} from '@/styles' @@ -43,6 +43,9 @@ type Props = { horizontal?: boolean snapToInterval?: number refreshControl?: React.ReactElement + // convenience for pull-to-refresh, mobile only. Ignored if refreshControl is passed + onRefresh?: () => void + refreshing?: boolean onTouchStart?: (e: GestureResponderEvent) => void onTouchEnd?: (e: GestureResponderEvent) => void testID?: string @@ -54,7 +57,7 @@ type DivScrollable = { } function ScrollView(props: Props) { - const {ref: outerRef, ...rest} = props + const {ref: outerRef, onRefresh, refreshing, ...rest} = props const divRef = React.useRef(null) const innerRef = React.useRef(null) @@ -103,6 +106,9 @@ function ScrollView(props: Props) { } const nativeProps = rest as ScrollViewProps + const refreshControl = + nativeProps.refreshControl ?? + (onRefresh ? : undefined) const keyboardShouldPersistTaps = nativeProps.keyboardShouldPersistTaps ?? 'handled' const contentInsetAdjustmentBehavior = nativeProps.contentInsetAdjustmentBehavior ?? 'automatic' @@ -110,6 +116,7 @@ function ScrollView(props: Props) { { const collapsible = props.collapsed === true || props.collapsed === false + const boxProps = { + alignItems: 'center', + direction: 'horizontal', + fullWidth: true, + gap: 'xtiny', + style: styles.container, + } as const const children = ( - + <> {typeof props.label === 'string' ? ( {props.label} ) : ( @@ -37,14 +44,14 @@ const SectionDivider = (props: Props) => { /> )} {props.showSpinner && } - + ) return collapsible ? ( - + {children} ) : ( - children + {children} ) } const height = isMobile ? 40 : 32 diff --git a/shared/common-adapters/section-list.tsx b/shared/common-adapters/section-list.tsx index d0f2dc9bfbda..dcbf8f042f6a 100644 --- a/shared/common-adapters/section-list.tsx +++ b/shared/common-adapters/section-list.tsx @@ -96,7 +96,7 @@ interface SectionFooter { type ListElement = SectionHeader | Row | SectionFooter -export interface Parameters { +interface Parameters { getItemHeight: (rowData: ItemT | undefined, sectionIndex: number, rowIndex: number) => number getSeparatorHeight?: (sectionIndex: number, rowIndex: number) => number getSectionHeaderHeight?: (sectionIndex: number) => number diff --git a/shared/common-adapters/swipeable-row.native.tsx b/shared/common-adapters/swipeable-row.native.tsx index b7423669facc..cc4eff674e92 100644 --- a/shared/common-adapters/swipeable-row.native.tsx +++ b/shared/common-adapters/swipeable-row.native.tsx @@ -1,22 +1,8 @@ import * as React from 'react' import * as Styles from '@/styles' import {Animated, PanResponder, View, type GestureResponderEvent, type PanResponderGestureState, type ViewStyle} from 'react-native' - -export type SwipeableMethods = { - close: () => void - reset: () => void -} - -type Props = { - children?: React.ReactNode - renderRightActions?: ( - progress: Animated.AnimatedDivision, - translation: Animated.Value - ) => React.ReactNode - onSwipeableOpenStartDrag?: () => void - onSwipeableWillOpen?: (direction: 'left') => void - containerStyle?: ViewStyle -} +import type {Props, SwipeableMethods} from './swipeable-row.shared' +export type {SwipeableMethods} from './swipeable-row.shared' const springConfig = {friction: 20, tension: 150, useNativeDriver: false} as const diff --git a/shared/common-adapters/swipeable-row.shared.tsx b/shared/common-adapters/swipeable-row.shared.tsx new file mode 100644 index 000000000000..844e4b662fd7 --- /dev/null +++ b/shared/common-adapters/swipeable-row.shared.tsx @@ -0,0 +1,18 @@ +import type * as React from 'react' +import type {Animated, ViewStyle} from 'react-native' + +export type SwipeableMethods = { + close: () => void + reset: () => void +} + +export type Props = { + children?: React.ReactNode + renderRightActions?: ( + progress: Animated.AnimatedDivision, + translation: Animated.Value + ) => React.ReactNode + onSwipeableOpenStartDrag?: () => void + onSwipeableWillOpen?: (direction: 'left') => void + containerStyle?: ViewStyle +} diff --git a/shared/common-adapters/swipeable-row.tsx b/shared/common-adapters/swipeable-row.tsx index cda27dab3a88..0081e63998eb 100644 --- a/shared/common-adapters/swipeable-row.tsx +++ b/shared/common-adapters/swipeable-row.tsx @@ -1,21 +1,6 @@ import type * as React from 'react' -import type {Animated} from 'react-native' - -export type SwipeableMethods = { - close: () => void - reset: () => void -} - -type Props = { - children?: React.ReactNode - renderRightActions?: ( - progress: Animated.AnimatedDivision, - translation: Animated.Value - ) => React.ReactNode - onSwipeableOpenStartDrag?: () => void - onSwipeableWillOpen?: (direction: 'left') => void - containerStyle?: object -} +import type {Props, SwipeableMethods} from './swipeable-row.shared' +export type {SwipeableMethods} from './swipeable-row.shared' const SwipeableRow = (_p: Props & {ref?: React.Ref}) => null export default SwipeableRow diff --git a/shared/common-adapters/switch.tsx b/shared/common-adapters/switch.tsx index dad7d59fbc09..0cabff85362d 100644 --- a/shared/common-adapters/switch.tsx +++ b/shared/common-adapters/switch.tsx @@ -44,11 +44,13 @@ const LabelContainer = (props: Props) => {props.children} ) : ( - - - {props.children} - - + + {props.children} + ) const getStyle = (props: Props) => diff --git a/shared/common-adapters/tabs.tsx b/shared/common-adapters/tabs.tsx index e6e4022e23e1..8b64c14824c1 100644 --- a/shared/common-adapters/tabs.tsx +++ b/shared/common-adapters/tabs.tsx @@ -79,6 +79,12 @@ const Tabs = (props: Props) => ( ) +const dividerBase = { + ...Styles.globalStyles.flexBoxRow, + minHeight: 2, + width: '100%', +} as const + const styles = Styles.styleSheetCreate(() => ({ badge: Styles.platformStyles({ isElectron: { @@ -94,16 +100,12 @@ const styles = Styles.styleSheetCreate(() => ({ maxHeight: isMobile ? 48 : 40, }, divider: { - ...Styles.globalStyles.flexBoxRow, + ...dividerBase, backgroundColor: Styles.globalColors.transparent, - minHeight: 2, - width: '100%', }, dividerSelected: { - ...Styles.globalStyles.flexBoxRow, + ...dividerBase, backgroundColor: Styles.globalColors.blue, - minHeight: 2, - width: '100%', }, icon: { alignSelf: 'center', diff --git a/shared/common-adapters/text.shared.tsx b/shared/common-adapters/text.shared.tsx index ffef9c45f6a2..68425c546993 100644 --- a/shared/common-adapters/text.shared.tsx +++ b/shared/common-adapters/text.shared.tsx @@ -36,7 +36,7 @@ const _allTextTypes = { BodyTinySemibold: 'BodyTinySemibold', BodyTinySemiboldItalic: 'BodyTinySemiboldItalic', BodyTinyBold: 'BodyTinyBold', - BodyTinyExtrabold: 'BodyTinyBold', + BodyTinyExtrabold: 'BodyTinyExtrabold', Header: 'Header', HeaderItalic: 'HeaderItalic', HeaderExtrabold: 'HeaderExtrabold', diff --git a/shared/common-adapters/toast.tsx b/shared/common-adapters/toast.tsx index 205f6cb7715c..d08c0372dbf7 100644 --- a/shared/common-adapters/toast.tsx +++ b/shared/common-adapters/toast.tsx @@ -142,7 +142,7 @@ const Toast = (props: Props) => { return shouldRender ? ( - + ({ overflow: 'hidden', ...Styles.padding(Styles.globalMargins.xtiny, Styles.globalMargins.tiny), }, - wrapper: { - ...Styles.globalStyles.fillAbsolute, - }, })) diff --git a/shared/common-adapters/video.tsx b/shared/common-adapters/video.tsx index 9e94191d4fa3..e15ac9bc4429 100644 --- a/shared/common-adapters/video.tsx +++ b/shared/common-adapters/video.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import * as Styles from '@/styles' +import {normalizeFilePathURL} from '@/util/file-url' import {Box2} from './box' import Text from './text' import {StatusBar} from 'react-native' @@ -67,22 +68,6 @@ const useCheckURL = (children: React.ReactElement, url: string, allowFile?: bool ) } -// Desktop: normalize file paths to file:// URLs -const normalizeURL = (url: string) => { - const isWindowsPath = /^[a-zA-Z]:[\\/]/.test(url) - if (url.startsWith('/') || isWindowsPath) { - let path = url.replace(/\\/g, '/') - if (isWindowsPath && !path.startsWith('/')) { - path = '/' + path - } - return encodeURI(`file://${path}`).replace(/#/g, '%23') - } - if (url.startsWith('file://') && (url.includes(' ') || url.includes('#'))) { - return encodeURI(url).replace(/#/g, '%23') - } - return url -} - // Stub type for desktop video element (avoids dom lib dependency in native tsconfig) type VideoElementRef = { paused?: boolean @@ -120,7 +105,7 @@ const DesktopVideo = (props: Props) => { } } - const url = normalizeURL(props.url) + const url = normalizeFilePathURL(props.url) const content = (