Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fc88a7a
refactor(chat): dedupe InboxUIItem meta converters via shared base he…
chrisnojima Jul 3, 2026
ca44ce6
refactor(chat): shared UIMessages JSON parse helper
chrisnojima Jul 3, 2026
76a463a
refactor(chat): thread store reads participants from inbox metadata s…
chrisnojima Jul 3, 2026
b5a84e9
fix(chat): version-gate metasReceived like thread-store path did
chrisnojima Jul 3, 2026
1016444
refactor(chat): single-owner conversation meta in inbox metadata store
chrisnojima Jul 3, 2026
a471845
feat(chat): dedicated inbox badge store
chrisnojima Jul 3, 2026
38f136a
feat(chat): typing store + layout row index
chrisnojima Jul 3, 2026
992dc9a
refactor(chat): inbox rows computed from stores, delete rows-state cache
chrisnojima Jul 3, 2026
bc84c58
refactor(chat): split thread-context into engine/load modules
chrisnojima Jul 3, 2026
2992db3
refactor(chat): per-conversation orange-line updates map
chrisnojima Jul 3, 2026
37da589
refactor(chat): merge focus/scroll contexts into ThreadRefsContext
chrisnojima Jul 3, 2026
555a3f8
refactor(chat): inbox controls reset via username key remount
chrisnojima Jul 3, 2026
863ebdf
refactor(chat): emoji picker pick handoff via self-cleaning mailbox
chrisnojima Jul 3, 2026
4ed72ee
chore(chat): lifecycle-audit module-level chat state
chrisnojima Jul 3, 2026
312b5dd
docs(chat): update chat readme to current data architecture
chrisnojima Jul 3, 2026
772874b
perf(chat): reload-free participants read in message rows
chrisnojima Jul 3, 2026
6bcd651
chore(chat): post-simplify cleanup (badge passthrough, exploding dup,…
chrisnojima Jul 3, 2026
38edaa3
chore: untrack plan doc
chrisnojima Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions shared/chat/blocking/block-buttons-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ const gregorItemsToBlockButtons = (

type Store = T.Immutable<{
blockButtonsMap: ReadonlyMap<T.RPCGen.TeamID, T.Chat.BlockButtonsInfo>
loadGeneration: number
loaded: boolean
}>

const makeInitialStore = (): Store => ({
blockButtonsMap: new Map(),
loadGeneration: 0,
loaded: false,
})

Expand All @@ -46,8 +48,11 @@ type State = Store & {
}
}

let loadPromise: Promise<void> | 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<void> | undefined

export const useBlockButtonsState = Z.createZustand<State>('block-buttons', (set, get) => {
const setFromGregorItems: State['dispatch']['updateFromGregorItems'] = items => {
Expand All @@ -59,39 +64,41 @@ export const useBlockButtonsState = Z.createZustand<State>('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)
},
}
Expand Down
23 changes: 12 additions & 11 deletions shared/chat/blocking/invitation-to-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 =
Expand Down
5 changes: 2 additions & 3 deletions shared/chat/conversation/bot/install.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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---'
Expand Down Expand Up @@ -42,8 +42,7 @@ export const useRefreshBotMembershipOnSuccess = (
preview => {
participantInfoReceived(
conversationIDKey,
ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []),
getInboxConversationMeta(conversationIDKey)
ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? [])
)
onSuccess()
},
Expand Down
14 changes: 6 additions & 8 deletions shared/chat/conversation/bottom-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T.Chat.ConversationIDKey>
Expand Down Expand Up @@ -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('@'))

Expand Down Expand Up @@ -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
}
Expand Down
10 changes: 8 additions & 2 deletions shared/chat/conversation/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
66 changes: 17 additions & 49 deletions shared/chat/conversation/data-hooks.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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: [],
Expand Down Expand Up @@ -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)

Expand All @@ -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<Array<T.Chat.Message>>((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 (
Expand Down
4 changes: 2 additions & 2 deletions shared/chat/conversation/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Kb.Box2 direction="vertical" fullWidth={true} padding="medium" gap="small">
<Kb.Text type="Header">There was an error loading this conversation.</Kb.Text>
Expand Down
11 changes: 4 additions & 7 deletions shared/chat/conversation/info-panel/bot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -74,8 +74,7 @@ const AddToChannel = (props: AddToChannelProps) => {
preview => {
participantInfoReceived(
conversationIDKey,
ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []),
getInboxConversationMeta(conversationIDKey)
ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? [])
)
},
() => {}
Expand Down Expand Up @@ -237,8 +236,7 @@ const BotTab = (props: Props) => {
preview => {
participantInfoReceived(
conversationIDKey,
ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []),
getInboxConversationMeta(conversationIDKey)
ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? [])
)
},
() => {}
Expand All @@ -262,8 +260,7 @@ const BotTab = (props: Props) => {
preview => {
participantInfoReceived(
conversationIDKey,
ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []),
getInboxConversationMeta(conversationIDKey)
ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? [])
)
},
() => {}
Expand Down
12 changes: 6 additions & 6 deletions shared/chat/conversation/input-area/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
)

Expand Down
14 changes: 0 additions & 14 deletions shared/chat/conversation/input-area/input-state.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]))

Expand Down
Loading