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/bot/install.tsx b/shared/chat/conversation/bot/install.tsx index c1ebc0a9eb96..b10036f642e5 100644 --- a/shared/chat/conversation/bot/install.tsx +++ b/shared/chat/conversation/bot/install.tsx @@ -14,7 +14,7 @@ import {useFeaturedBot} from '@/util/featured-bots' 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 +42,7 @@ export const useRefreshBotMembershipOnSuccess = ( preview => { participantInfoReceived( conversationIDKey, - ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []), - getInboxConversationMeta(conversationIDKey) + ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []) ) onSuccess() }, 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/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/info-panel/bot.tsx b/shared/chat/conversation/info-panel/bot.tsx index 856d5215f800..358016c7973a 100644 --- a/shared/chat/conversation/info-panel/bot.tsx +++ b/shared/chat/conversation/info-panel/bot.tsx @@ -9,7 +9,7 @@ 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 +74,7 @@ const AddToChannel = (props: AddToChannelProps) => { preview => { participantInfoReceived( conversationIDKey, - ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []), - getInboxConversationMeta(conversationIDKey) + ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []) ) }, () => {} @@ -237,8 +236,7 @@ const BotTab = (props: Props) => { preview => { participantInfoReceived( conversationIDKey, - ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []), - getInboxConversationMeta(conversationIDKey) + ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []) ) }, () => {} @@ -262,8 +260,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/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..d8730aff0983 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)) } 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/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index c4dd3696e8aa..6144a0f067bb 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -8,7 +8,7 @@ import SpecialBottomMessage from '../messages/special-bottom-message' import SpecialTopMessage from '../messages/special-top-message' import {MessageRow} from '../messages/wrapper' import {PerfProfiler} from '@/perf/react-profiler' -import {ScrollContext} from '../normal/context' +import {ThreadRefsContext} from '../normal/context' import {useConversationCenter} from '../center-context' import { useConversationThreadID, @@ -24,7 +24,6 @@ import {getMessageRowType} from '../messages/row-metadata' import * as InputState from '../input-area/input-state' import sortedIndexOf from 'lodash/sortedIndexOf' import {copyToClipboard} from '@/util/storeless-actions' -import {FocusContext} from '../normal/context' import noop from 'lodash/noop' import {LegendList} from '@legendapp/list/react' import type {LegendListRef} from '@/common-adapters' @@ -196,7 +195,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { const getItemType = useGetItemType() - // Imperative scroll for ScrollContext + // Imperative scroll for ThreadRefsContext const scrollToBottom = React.useCallback(() => { 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/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/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/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..9e9732aeb2b9 100644 --- a/shared/chat/conversation/team-hooks.tsx +++ b/shared/chat/conversation/team-hooks.tsx @@ -8,7 +8,7 @@ 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 @@ -504,11 +504,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/emoji-picker/use-picker.tsx b/shared/chat/emoji-picker/use-picker.tsx index fba031bbd56d..ef1825dfabf8 100644 --- a/shared/chat/emoji-picker/use-picker.tsx +++ b/shared/chat/emoji-picker/use-picker.tsx @@ -2,6 +2,13 @@ import * as Z from '@/util/zustand' import type * as T from '@/constants/types' import type {RenderableEmoji} from '@/common-adapters/emoji' +// Mailbox for handing an emoji pick back from the mobile chatChooseEmoji route +// to whichever screen pushed it. On mobile the picker is a separate routed +// screen, so its result can't come back as a callback prop (nav params must be +// serializable); on desktop the picker renders in-tree inside a popup and uses +// a plain onPickAction callback instead, bypassing this store entirely. +// Each consumer must clear its own key with updatePickerMap(key, undefined) +// once it reads a pick, so a stale value isn't replayed on the next mount. export type PickKey = 'addAlias' | 'chatInput' | 'reaction' type PickerValue = { emojiStr: string diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index 8872e976b1b6..d1179712b06c 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -9,7 +9,7 @@ import {setInboxHeaderPortalNode, useInboxHeaderPortalContent} from '@/chat/inbo import {useChatTeam} from '@/chat/conversation/team-hooks' import {useRoute} from '@react-navigation/native' import {useInboxMetadataState} from '@/chat/inbox/metadata' -import {useInboxRowsState} from '@/chat/inbox/rows-state' +import {useInboxRowBig, useInboxRowSmall} from '@/chat/inbox/rows-state' import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' import {navToPath} from '@/constants/fs' @@ -33,17 +33,13 @@ const Header = () => { 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/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..1f7075483bb1 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -4,7 +4,7 @@ import * as React from 'react' 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' @@ -19,10 +19,10 @@ const useInboxBadges = ( 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 ) ) ) @@ -76,12 +76,8 @@ export function useInboxState( 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) => { @@ -89,11 +85,10 @@ export function useInboxState( return } setInboxControls(state => ({ + ...state, inboxNumSmallRows: rows, inboxNumSmallRowsLoaded: true, inboxNumSmallRowsUserChanged: true, - smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, - username, })) if (!persist) { return @@ -107,17 +102,13 @@ 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, + ...state, + smallTeamsExpanded: !state.smallTeamsExpanded, })) - }, [username]) + }, []) const { allowShowFloatingButton, @@ -181,16 +172,14 @@ export function useInboxState( } const count = rows.i ?? -1 setInboxControls(state => { - if (state.username === username && state.inboxNumSmallRowsUserChanged) { + if (state.inboxNumSmallRowsUserChanged) { return state } return { - inboxNumSmallRows: - count > 0 ? count : state.username === username ? state.inboxNumSmallRows : 5, + ...state, + inboxNumSmallRows: count > 0 ? count : state.inboxNumSmallRows, inboxNumSmallRowsLoaded: true, inboxNumSmallRowsUserChanged: false, - smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, - username, } }) }, @@ -199,12 +188,8 @@ export function useInboxState( return } setInboxControls(state => ({ - inboxNumSmallRows: state.username === username ? state.inboxNumSmallRows : 5, + ...state, inboxNumSmallRowsLoaded: true, - inboxNumSmallRowsUserChanged: - state.username === username ? state.inboxNumSmallRowsUserChanged : false, - smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, - username, })) } ) 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/constants/chat/message.tsx b/shared/constants/chat/message.tsx index 62519a22807d..110f5ee6e86a 100644 --- a/shared/constants/chat/message.tsx +++ b/shared/constants/chat/message.tsx @@ -1213,6 +1213,33 @@ export const uiMessageToMessage = ( } } +export const parseUIMessagesJSON = ( + conversationIDKey: T.Chat.ConversationIDKey, + threadJSON: string, + username: string, + devicename: string, + getLastOrdinal: () => T.Chat.Ordinal, + // called as each message is converted, before the next conversion; callers can use it to keep a + // running max feeding getLastOrdinal + onMessage?: (m: T.Chat.Message) => void +): {messages: Array; pagination?: T.RPCChat.UIPagination} => { + try { + const uiMessages = JSON.parse(threadJSON) as T.RPCChat.UIMessages + const messages = (uiMessages.messages ?? []).reduce>((arr, uiMessage) => { + const message = uiMessageToMessage(conversationIDKey, uiMessage, username, getLastOrdinal, devicename) + if (message) { + arr.push(message) + onMessage?.(message) + } + return arr + }, []) + return {messages, pagination: uiMessages.pagination ?? undefined} + } catch (error) { + logger.warn(`parseUIMessagesJSON: failed for ${conversationIDKey}: ${String(error)}`) + return {messages: []} + } +} + const assertNever = (_: never) => undefined function nextFractionalOrdinal(ord: T.Chat.Ordinal) { diff --git a/shared/constants/chat/meta.test.tsx b/shared/constants/chat/meta.test.tsx new file mode 100644 index 000000000000..2fc37a009402 --- /dev/null +++ b/shared/constants/chat/meta.test.tsx @@ -0,0 +1,208 @@ +/// +import * as T from '@/constants/types' +import { + getEffectiveRetentionPolicy, + inboxUIItemToConversationMeta, + unverifiedInboxUIItemToConversationMeta, +} from './meta' + +const commands = {typ: T.RPCChat.ConversationCommandGroupsTyp.none} as T.RPCChat.ConversationCommandGroups + +const makeTrustedFixture = ( + overrides: Partial = {} +): T.RPCChat.InboxUIItem => ({ + botAliases: {}, + botCommands: commands, + channel: '', + commands, + convID: 'convIDTeam' as T.RPCChat.ConvIDStr, + convRetention: undefined, + draft: undefined, + finalizeInfo: undefined, + headline: 'the headline', + headlineDecorated: 'the headline decorated', + isDefaultConv: false, + isEmpty: false, + isPublic: false, + maxMsgID: 5 as T.RPCChat.MessageID, + maxVisibleMsgID: 5 as T.RPCChat.MessageID, + memberStatus: T.RPCChat.ConversationMemberStatus.active, + membersType: T.RPCChat.ConversationMembersType.team, + name: 'acme', + notifications: undefined, + participants: undefined, + pinnedMsg: undefined, + readMsgID: 5 as T.RPCChat.MessageID, + resetParticipants: undefined, + snippet: 'the snippet', + snippetDecorated: 'the snippet decorated', + snippetDecoration: T.RPCChat.SnippetDecoration.none, + status: T.RPCChat.ConversationStatus.unfiled, + supersededBy: undefined, + supersedes: undefined, + teamRetention: undefined, + teamType: T.RPCChat.TeamType.simple, + time: 12345, + tlfID: 'tlfIDTeam' as T.RPCChat.TLFIDStr, + topicType: T.RPCChat.TopicType.chat, + version: 1 as T.RPCChat.ConversationVers, + localVersion: 1 as T.RPCChat.LocalConversationVers, + visibility: T.RPCGen.TLFVisibility.private, + ...overrides, +}) + +const makeUnverifiedFixture = ( + overrides: Partial = {} +): T.RPCChat.UnverifiedInboxUIItem => ({ + commands, + convID: 'convIDAdhoc' as T.RPCChat.ConvIDStr, + convRetention: undefined, + draft: undefined, + finalizeInfo: undefined, + isDefaultConv: false, + isPublic: false, + localMetadata: { + channelName: '', + headline: '', + headlineDecorated: '', + resetParticipants: undefined, + snippet: 'unverified snippet', + snippetDecoration: T.RPCChat.SnippetDecoration.none, + writerNames: undefined, + }, + maxMsgID: 3 as T.RPCChat.MessageID, + maxVisibleMsgID: 3 as T.RPCChat.MessageID, + memberStatus: T.RPCChat.ConversationMemberStatus.active, + membersType: T.RPCChat.ConversationMembersType.impteamnative, + name: 'testuser,testuser-mac', + notifications: undefined, + readMsgID: 3 as T.RPCChat.MessageID, + status: T.RPCChat.ConversationStatus.unfiled, + supersededBy: undefined, + supersedes: undefined, + teamRetention: undefined, + teamType: T.RPCChat.TeamType.none, + time: 6789, + tlfID: 'tlfIDAdhoc' as T.RPCChat.TLFIDStr, + topicType: T.RPCChat.TopicType.chat, + version: 2 as T.RPCChat.ConversationVers, + localVersion: 2 as T.RPCChat.LocalConversationVers, + visibility: T.RPCGen.TLFVisibility.private, + ...overrides, +}) + +describe('meta converters', () => { + it('trusted team item maps fields', () => { + const meta = inboxUIItemToConversationMeta(makeTrustedFixture()) + expect(meta?.trustedState).toBe('trusted') + expect(meta?.snippet).toBe('the snippet') + expect(meta?.channelname).toBe('') + expect(meta?.teamname).toBe('acme') + expect(meta?.teamType).toBe('small') + expect(meta?.resetParticipants).toEqual(new Set()) + expect(meta?.isMuted).toBe(false) + expect(meta?.notificationsDesktop).toBe('never') + }) + + it('trusted adhoc item with reset participants maps fields', () => { + const meta = inboxUIItemToConversationMeta( + makeTrustedFixture({ + channel: 'general', + membersType: T.RPCChat.ConversationMembersType.impteamnative, + name: 'testuser,testuser-mac', + resetParticipants: ['testuser-mac'], + teamType: T.RPCChat.TeamType.none, + }) + ) + expect(meta?.trustedState).toBe('trusted') + expect(meta?.teamType).toBe('adhoc') + expect(meta?.teamname).toBe('') + expect(meta?.channelname).toBe('') + expect(meta?.resetParticipants).toEqual(new Set(['testuser-mac'])) + }) + + it('trusted muted item with retention set maps fields', () => { + const meta = inboxUIItemToConversationMeta( + makeTrustedFixture({ + convRetention: {retain: {}, typ: T.RPCChat.RetentionPolicyType.retain}, + status: T.RPCChat.ConversationStatus.muted, + }) + ) + expect(meta?.isMuted).toBe(true) + expect(meta?.retentionPolicy.type).toBe('retain') + expect(getEffectiveRetentionPolicy(meta!).type).toBe('retain') + }) + + it('returns undefined for non-private trusted items', () => { + const meta = inboxUIItemToConversationMeta( + makeTrustedFixture({visibility: T.RPCGen.TLFVisibility.public}) + ) + expect(meta).toBeUndefined() + }) + + it('unverified item maps fields', () => { + const meta = unverifiedInboxUIItemToConversationMeta(makeUnverifiedFixture()) + expect(meta?.trustedState).toBe('untrusted') + expect(meta?.snippet).toBe('unverified snippet') + expect(meta?.channelname).toBe('') + expect(meta?.teamname).toBe('') + expect(meta?.teamType).toBe('adhoc') + expect(meta?.resetParticipants).toEqual(new Set()) + // fields the unverified path must NOT set (trusted-only fields stay defaults) + expect(meta?.botAliases).toEqual({}) + expect(meta?.isEmpty).toBe(false) + expect(meta?.pinnedMsg).toBeUndefined() + expect(meta?.minWriterRole).toBe('reader') + }) + + it('unverified team item with reset participants and muted status maps fields', () => { + const meta = unverifiedInboxUIItemToConversationMeta( + makeUnverifiedFixture({ + localMetadata: { + channelName: 'general', + headline: 'headline', + headlineDecorated: 'headline decorated', + resetParticipants: ['testuser-mac'], + snippet: 'team snippet', + snippetDecoration: T.RPCChat.SnippetDecoration.none, + writerNames: undefined, + }, + membersType: T.RPCChat.ConversationMembersType.team, + name: 'acme', + status: T.RPCChat.ConversationStatus.muted, + teamType: T.RPCChat.TeamType.simple, + }) + ) + expect(meta?.trustedState).toBe('untrusted') + expect(meta?.teamname).toBe('acme') + expect(meta?.channelname).toBe('general') + expect(meta?.teamType).toBe('small') + expect(meta?.isMuted).toBe(true) + // team (not impteam) members type never populates resetParticipants + expect(meta?.resetParticipants).toEqual(new Set()) + }) + + it('unverified adhoc item with reset participants maps fields', () => { + const meta = unverifiedInboxUIItemToConversationMeta( + makeUnverifiedFixture({ + localMetadata: { + channelName: '', + headline: '', + headlineDecorated: '', + resetParticipants: ['testuser-mac'], + snippet: 'adhoc snippet', + snippetDecoration: T.RPCChat.SnippetDecoration.none, + writerNames: undefined, + }, + }) + ) + expect(meta?.resetParticipants).toEqual(new Set(['testuser-mac'])) + }) + + it('returns undefined for non-private unverified items', () => { + const meta = unverifiedInboxUIItemToConversationMeta( + makeUnverifiedFixture({visibility: T.RPCGen.TLFVisibility.public}) + ) + expect(meta).toBeUndefined() + }) +}) diff --git a/shared/constants/chat/meta.tsx b/shared/constants/chat/meta.tsx index bb4032301d50..2cce8261e78a 100644 --- a/shared/constants/chat/meta.tsx +++ b/shared/constants/chat/meta.tsx @@ -6,7 +6,9 @@ import * as Message from './message' import {base64ToUint8Array, uint8ArrayToHex} from '@/util/uint8array' import {useCurrentUserState} from '@/stores/current-user' -const conversationMemberStatusToMembershipType = (m: T.RPCChat.ConversationMemberStatus) => { +const conversationMemberStatusToMembershipType = ( + m: T.RPCChat.ConversationMemberStatus +): T.Chat.MembershipType => { switch (m) { case T.RPCChat.ConversationMemberStatus.active: return 'active' @@ -24,51 +26,36 @@ const supersededConversationIDToKey = (id: string | Uint8Array): string => { return typeof id === 'string' ? uint8ArrayToHex(base64ToUint8Array(id)) : uint8ArrayToHex(id) } -export const unverifiedInboxUIItemToConversationMeta = ( - i: T.RPCChat.UnverifiedInboxUIItem -): T.Chat.ConversationMeta | undefined => { - // Private chats only - if (i.visibility !== T.RPCGen.TLFVisibility.private) { - return undefined - } - - // Should be impossible - if (!i.convID) { - return undefined - } +// We only treat implicit adhoc teams as having resetParticipants +const isImpteamMembersType = (membersType: T.RPCChat.ConversationMembersType) => + membersType === T.RPCChat.ConversationMembersType.impteamnative || + membersType === T.RPCChat.ConversationMembersType.impteamupgrade - // We only treat implicit adhoc teams as having resetParticipants +// Shared field mappings between InboxUIItem (trusted) and UnverifiedInboxUIItem. +// `resetParticipants` is passed in explicitly since its source field differs +// per type (`i.resetParticipants` vs `i.localMetadata?.resetParticipants`). +const baseMetaFromUIItem = ( + i: T.RPCChat.InboxUIItem | T.RPCChat.UnverifiedInboxUIItem, + isTeam: boolean, + resetParticipantsSource: ReadonlyArray | null | undefined +) => { const resetParticipants: Set = new Set( - i.localMetadata && - (i.membersType === T.RPCChat.ConversationMembersType.impteamnative || - i.membersType === T.RPCChat.ConversationMembersType.impteamupgrade) && - i.localMetadata.resetParticipants - ? i.localMetadata.resetParticipants - : [] + isImpteamMembersType(i.membersType) && resetParticipantsSource ? resetParticipantsSource : [] ) - const isTeam = i.membersType === T.RPCChat.ConversationMembersType.team - const channelname = isTeam && i.localMetadata ? i.localMetadata.channelName : '' - const supersededBy = conversationMetadataToMetaSupersedeInfo(i.supersededBy ?? undefined) const supersedes = conversationMetadataToMetaSupersedeInfo(i.supersedes ?? undefined) - const teamname = isTeam ? i.name : '' const {retentionPolicy, teamRetentionPolicy} = UIItemToRetentionPolicies(i, isTeam) const {notificationsDesktop, notificationsGlobalIgnoreMentions, notificationsMobile} = parseNotificationSettings(i.notifications ?? undefined) return { - ...makeConversationMeta(), - channelname, commands: i.commands, conversationIDKey: T.Chat.stringToConversationIDKey(i.convID), - description: i.localMetadata?.headline || '', - descriptionDecorated: i.localMetadata?.headlineDecorated || '', draft: i.draft || '', inboxLocalVersion: i.localVersion, inboxVersion: i.version, - isEmpty: false, isMuted: i.status === T.RPCChat.ConversationStatus.muted, maxMsgID: T.Chat.numberToMessageID(i.maxMsgID), maxVisibleMsgID: T.Chat.numberToMessageID(i.maxVisibleMsgID), @@ -79,23 +66,50 @@ export const unverifiedInboxUIItemToConversationMeta = ( readMsgID: T.Chat.numberToMessageID(i.readMsgID), resetParticipants, retentionPolicy, - snippet: i.localMetadata ? i.localMetadata.snippet : undefined, - snippetDecorated: undefined, - snippetDecoration: i.localMetadata ? i.localMetadata.snippetDecoration : T.RPCChat.SnippetDecoration.none, status: i.status, supersededBy: supersededBy ? T.Chat.stringToConversationIDKey(supersededBy) : T.Chat.noConversationIDKey, supersedes: supersedes ? T.Chat.stringToConversationIDKey(supersedes) : T.Chat.noConversationIDKey, teamID: i.tlfID, teamRetentionPolicy, teamType: getTeamType(i), - teamname, timestamp: i.time, tlfname: i.name, - trustedState: 'untrusted', wasFinalizedBy: i.finalizeInfo ? i.finalizeInfo.resetUser : '', } } +export const unverifiedInboxUIItemToConversationMeta = ( + i: T.RPCChat.UnverifiedInboxUIItem +): T.Chat.ConversationMeta | undefined => { + // Private chats only + if (i.visibility !== T.RPCGen.TLFVisibility.private) { + return undefined + } + + // Should be impossible + if (!i.convID) { + return undefined + } + + const isTeam = i.membersType === T.RPCChat.ConversationMembersType.team + const channelname = isTeam && i.localMetadata ? i.localMetadata.channelName : '' + const teamname = isTeam ? i.name : '' + + return { + ...makeConversationMeta(), + ...baseMetaFromUIItem(i, isTeam, i.localMetadata?.resetParticipants), + channelname, + description: i.localMetadata?.headline || '', + descriptionDecorated: i.localMetadata?.headlineDecorated || '', + isEmpty: false, + snippet: i.localMetadata ? i.localMetadata.snippet : undefined, + snippetDecorated: undefined, + snippetDecoration: i.localMetadata ? i.localMetadata.snippetDecoration : T.RPCChat.SnippetDecoration.none, + teamname, + trustedState: 'untrusted', + } +} + export const inboxUIItemErrorToConversationMetaAndParticipants = ( error: T.RPCChat.InboxUIItemError, username: string, @@ -285,23 +299,7 @@ export const inboxUIItemToConversationMeta = ( return } - // We only treat implied adhoc teams as having resetParticipants - const resetParticipants = new Set( - (i.membersType === T.RPCChat.ConversationMembersType.impteamnative || - i.membersType === T.RPCChat.ConversationMembersType.impteamupgrade) && - i.resetParticipants - ? i.resetParticipants - : [] - ) - - const supersededBy = conversationMetadataToMetaSupersedeInfo(i.supersededBy ?? undefined) - const supersedes = conversationMetadataToMetaSupersedeInfo(i.supersedes ?? undefined) - const isTeam = i.membersType === T.RPCChat.ConversationMembersType.team - const {notificationsDesktop, notificationsGlobalIgnoreMentions, notificationsMobile} = - parseNotificationSettings(i.notifications ?? undefined) - - const {retentionPolicy, teamRetentionPolicy} = UIItemToRetentionPolicies(i, isTeam) const minWriterRoleEnum = i.convSettings?.minWriterRoleInfo ? i.convSettings.minWriterRoleInfo.role @@ -336,44 +334,21 @@ export const inboxUIItemToConversationMeta = ( return { ...makeConversationMeta(), + ...baseMetaFromUIItem(i, isTeam, i.resetParticipants), botAliases: i.botAliases ?? {}, botCommands: i.botCommands, cannotWrite, channelname: (isTeam && i.channel) || '', - commands: i.commands, - conversationIDKey, description: i.headline, descriptionDecorated: i.headlineDecorated, - draft: i.draft || '', - inboxLocalVersion: i.localVersion, - inboxVersion: i.version, isEmpty: i.isEmpty, - isMuted: i.status === T.RPCChat.ConversationStatus.muted, - maxMsgID: T.Chat.numberToMessageID(i.maxMsgID), - maxVisibleMsgID: T.Chat.numberToMessageID(i.maxVisibleMsgID), - membershipType: conversationMemberStatusToMembershipType(i.memberStatus), minWriterRole, - notificationsDesktop, - notificationsGlobalIgnoreMentions, - notificationsMobile, pinnedMsg, - readMsgID: T.Chat.numberToMessageID(i.readMsgID), - resetParticipants, - retentionPolicy, snippet: i.snippet, snippetDecorated: i.snippetDecorated, snippetDecoration: i.snippetDecoration, - status: i.status, - supersededBy: supersededBy ? T.Chat.stringToConversationIDKey(supersededBy) : T.Chat.noConversationIDKey, - supersedes: supersedes ? T.Chat.stringToConversationIDKey(supersedes) : T.Chat.noConversationIDKey, - teamID: i.tlfID, - teamRetentionPolicy, - teamType: getTeamType(i), teamname: (isTeam && i.name) || '', - timestamp: i.time, - tlfname: i.name, trustedState: 'trusted', - wasFinalizedBy: i.finalizeInfo ? i.finalizeInfo.resetUser : '', } } diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index c946979249da..74ffd817df41 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -44,8 +44,8 @@ import { onGetInboxUnverifiedConvs, onInboxLayoutChanged, onIncomingInboxUIItem, - syncBadgeState, } from '@/chat/inbox/metadata' +import {syncInboxBadgeState} from '@/chat/inbox/badge-state' import {clearSignupEmail} from '@/people/signup-email' import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' import {clearNavBadges} from '@/teams/actions' @@ -346,7 +346,7 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { case 'keybase.1.NotifyBadges.badgeState': { const {badgeState} = action.payload.params - syncBadgeState(badgeState) + syncInboxBadgeState(badgeState) const {useNotifState} = require('@/stores/notifications') as typeof UseNotificationsStateType useNotifState.getState().dispatch.onEngineIncomingImpl(action) } diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index e4ffcd43b477..4cdb3fede4a4 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -554,7 +554,7 @@ export const createConversation = ( const participantInfo = uiParticipantsToParticipantInfo(uiConv.participants ?? []) if (participantInfo.all.length > 0) { const {participantInfoReceived} = require('@/chat/inbox/metadata') as typeof ChatInboxMetadataType - participantInfoReceived(conversationIDKey, participantInfo, meta) + participantInfoReceived(conversationIDKey, participantInfo) } navigateToThread(conversationIDKey, 'justCreated', highlightMessageID) diff --git a/shared/teams/team/settings-tab/retention/index.tsx b/shared/teams/team/settings-tab/retention/index.tsx index ff7010d9edce..f51eb1ea05ea 100644 --- a/shared/teams/team/settings-tab/retention/index.tsx +++ b/shared/teams/team/settings-tab/retention/index.tsx @@ -8,7 +8,7 @@ import SaveIndicator from '@/common-adapters/save-indicator' import {useEngineActionListener} from '@/engine/action-listener' import {useLoadedTeam} from '../../use-loaded-team' import {useConfirm} from './use-confirm' -import {ConversationThreadProvider, useConversationThreadSelector} from '@/chat/conversation/thread-context' +import {ConversationThreadProvider, useThreadMeta} from '@/chat/conversation/thread-context' export type RetentionEntityType = 'adhoc' | 'channel' | 'small team' | 'big team' @@ -491,7 +491,7 @@ const Container = (ownProps: OwnProps) => { } const ConversationPolicyContainer = (ownProps: OwnProps & {conversationIDKey: T.Chat.ConversationIDKey}) => { - const policy = useConversationThreadSelector(s => s.meta.retentionPolicy) + const policy = useThreadMeta(m => m.retentionPolicy) return }