Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
- No `Co-Authored-By` in commits. Ever.
- Never interact with the Electron app or iOS simulator (screenshots, driving UI, debug ports) without asking first. The user drives and takes screenshots.
- Use `--no-ext-diff` with `git diff` (and `git show`/`git log -p`) so external diff tools don't hijack output.
- "Was working before" = base branch, not previous commit. Base branch is almost always `nojima/HOTPOT-next-670-clean-2` (not `master`). Always run `gh pr view --json baseRefName` to confirm before any `git diff` or `git log` comparison.
- "Was working before" = base branch, not previous commit. Base is normally `master`. Always run `gh pr view --json baseRefName` to confirm before any `git diff` or `git log` comparison.
- Never use `npm`. Always `yarn`.
- Never silently drop features/behavior — ask first, present options.
- In tests/stories, use `testuser` / `testuser-mac` as placeholder usernames — never real usernames like `chrisnojima`.
Expand All @@ -23,4 +23,4 @@ Repo root is `client/`. TS source lives in `shared/`. Always use absolute paths
- Plans created by superpowers skills go into `plans/` at the repo root.

## Validation
After TS changes (from `shared/`): `yarn lint` then `yarn tsc`. When debugging visually, skip until fix is confirmed. Never delete the ESLint cache.
After TS changes (from `shared/`): `yarn lint:all` (= `yarn lint` && `yarn lint:bailouts` && `yarn tsc`). Plain `yarn lint` is eslint only and does NOT catch react-compiler bailouts — no compiler rule is wired into `eslint.config.mjs`, so bailouts only surface via `lint:bailouts`. Repo baseline is 0 bailouts; keep it there. When debugging visually, skip until fix is confirmed. Never delete the ESLint cache.
7 changes: 7 additions & 0 deletions shared/chat/conversation/attachment-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
useConversationThreadID,
useConversationThreadStore,
} from './thread-context'
import {registerExternalResetter} from '@/util/zustand'

const {darwinCopyToChatTempUploadFile} = KB2.functions

Expand Down Expand Up @@ -93,6 +94,12 @@ export const showAttachmentPreview = (

const pdfMessageHandoff = new Map<string, T.Chat.MessageAttachment>()

// module scope outlives sign-out; both hold attachment messages keyed by conversation
registerExternalResetter('chat-attachment-message-handoff', () => {
attachmentPreviewMessageHandoff.clear()
pdfMessageHandoff.clear()
})

export const takePDFMessage = (
conversationIDKey: T.Chat.ConversationIDKey,
messageID: T.Chat.MessageID
Expand Down
6 changes: 6 additions & 0 deletions shared/chat/conversation/attachment-fullscreen/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../attachment-actions'
import {showConversationInfoPanel} from '../thread-context'
import {useConversationMessage} from '../data-hooks'
import {registerExternalResetter} from '@/util/zustand'

const blankMessage = Chat.makeMessageAttachment({})
export const useData = (
Expand Down Expand Up @@ -116,6 +117,11 @@ export const useData = (

// if we've seen it its likely cached so lets just always just show it and never fallback
const seenPaths = new Set<string>()

// module scope outlives sign-out; holds the previous user's attachment cache paths
registerExternalResetter('chat-attachment-seen-paths', () => {
seenPaths.clear()
})
// preload full and return ''. If too much time passes show preview. Show full when loaded
export const usePreviewFallback = (
path: string,
Expand Down
30 changes: 11 additions & 19 deletions shared/chat/conversation/bot/install.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as C from '@/constants'
import * as ChatCommon from '@/constants/chat/common'
import * as Meta from '@/constants/chat/meta'
import * as Teams from '@/constants/teams'
import * as Kb from '@/common-adapters'
import * as React from 'react'
Expand All @@ -9,13 +8,15 @@ import ChannelPicker from './channel-picker'
import {useChatTeam} from '../team-hooks'
import {openURL} from '@/util/misc'
import * as T from '@/constants/types'
import * as TestIDs from '@/tests/e2e/shared/test-ids'
import {useAllChannelMetas} from '@/teams/common/channel-hooks'
import {useGeneralConvIDKey} from '@/teams/common/general-conv'
import {useFeaturedBot} from '@/util/featured-bots'
import {useRPCLoad} from '@/util/use-rpc-load'
import {RPCError} from '@/util/errors'
import logger from '@/logger'
import {useBotSettings} from './settings'
import {metasReceived, participantInfoReceived} from '@/chat/inbox/metadata'
import {participantInfoReceived} from '@/chat/inbox/metadata'
import {useConversationMeta} from '../data-hooks'

const RestrictedItem = '---RESTRICTED---'
Expand Down Expand Up @@ -66,22 +67,7 @@ export const useRefreshBotMembershipOnSuccess = (

export const useBotConversationIDKey = (inConvIDKey?: T.Chat.ConversationIDKey, teamID?: T.Teams.TeamID) => {
const cleanInConvIDKey = T.Chat.isValidConversationIDKey(inConvIDKey ?? '') ? inConvIDKey : undefined
const {data: generalConvIDKey} = useRPCLoad(
T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise,
[{teamID: teamID ?? T.Teams.noTeamID}],
{
enabled: !cleanInConvIDKey && !!teamID,
key: teamID ?? T.Teams.noTeamID,
map: conv => {
const meta = Meta.inboxUIItemToConversationMeta(conv)
if (!meta) {
return undefined
}
metasReceived([meta])
return meta.conversationIDKey
},
}
)
const generalConvIDKey = useGeneralConvIDKey(teamID, !cleanInConvIDKey)
return cleanInConvIDKey ?? generalConvIDKey
}

Expand Down Expand Up @@ -622,7 +608,13 @@ const InstallBotPopup = (props: Props) => {
)
return (
<>
<Kb.Box2 direction="vertical" flex={1} style={styles.outerContainer} fullWidth={true}>
<Kb.Box2
direction="vertical"
flex={1}
style={styles.outerContainer}
fullWidth={true}
testID={TestIDs.CHAT_BOT_INSTALL}
>
{bodyContent}
</Kb.Box2>
{enabled && (!readOnly || showReviewButton) ? (
Expand Down
6 changes: 6 additions & 0 deletions shared/chat/conversation/fwd-msg.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {useNavigation} from '@react-navigation/native'
import {Avatars, TeamAvatar} from '@/chat/avatars'
import logger from '@/logger'
import {useConversationMessage} from './data-hooks'
import {registerExternalResetter} from '@/util/zustand'

type Props = {conversationIDKey?: T.Chat.ConversationIDKey; messageID: T.Chat.MessageID}

Expand All @@ -16,6 +17,11 @@ const forwardMessageHandoff = new Map<string, T.Chat.Message>()
const forwardMessageKey = (conversationIDKey: T.Chat.ConversationIDKey, messageID: T.Chat.MessageID) =>
`${conversationIDKey}:${T.Chat.messageIDToNumber(messageID)}`

// module scope outlives sign-out; holds message bodies keyed by conversation
registerExternalResetter('chat-forward-message-handoff', () => {
forwardMessageHandoff.clear()
})

const getForwardMessage = (conversationIDKey: T.Chat.ConversationIDKey, messageID: T.Chat.MessageID) => {
const key = forwardMessageKey(conversationIDKey, messageID)
return forwardMessageHandoff.get(key)
Expand Down
10 changes: 8 additions & 2 deletions shared/chat/conversation/input-area/suggestors/emoji.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,15 @@ const cachedEmojiData = (emoji: T.RPCChat.Emoji) => {
}

const useDataSource = (conversationIDKey: T.Chat.ConversationIDKey, filter: string) => {
const {emojis: userEmojis, loading: userEmojisLoading} = useUserEmoji({conversationIDKey})
// a filter the prepass rejects discards the result below, so don't pay for the
// fetch — userEmojis is expensive to resolve service-side
const matchesPrepass = emojiPrepass.test(filter)
const {emojis: userEmojis, loading: userEmojisLoading} = useUserEmoji({
conversationIDKey,
disabled: !matchesPrepass,
})

if (!emojiPrepass.test(filter)) {
if (!matchesPrepass) {
return {
items: empty,
loading: false,
Expand Down
6 changes: 6 additions & 0 deletions shared/chat/conversation/input-area/suggestors/users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {useUsersState} from '@/stores/users'
import {useChatTeamMembers} from '../../team-hooks'
import {useInboxLayoutState} from '@/chat/inbox/layout-state'
import {useConversationMetadata} from '../../data-hooks'
import {registerExternalResetter} from '@/util/zustand'

export const transformer = (
input: {
Expand Down Expand Up @@ -259,6 +260,11 @@ const keyExtractor = (item: ListItem) => {
// the memoized suggestion rows can bail. Bounded by users/channels ever
// suggested; the size guard is a backstop for pathological accounts.
const listItemCache = new Map<string, ListItem>()

// module scope outlives sign-out; keyed by username / team#channel
registerExternalResetter('chat-user-suggestor-item-cache', () => {
listItemCache.clear()
})
const canonicalizeItems = (items: Array<ListItem>) => {
if (listItemCache.size > 8192) {
listItemCache.clear()
Expand Down
14 changes: 8 additions & 6 deletions shared/chat/conversation/messages/attachment/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,14 @@ export const ShowToastAfterSaving = ({transferState, toastTargetRef}: Props) =>
const [allowToast, setAllowToast] = React.useState(true)

// since this uses portals we need to hide if we're hidden else we can get stuck showing if our render is frozen
C.Router2.useSafeFocusEffect(() => {
setAllowToast(true)
return () => {
setAllowToast(false)
}
})
C.Router2.useSafeFocusEffect(
React.useCallback(() => {
setAllowToast(true)
return () => {
setAllowToast(false)
}
}, [])
)

return allowToast && showingToast ? (
<Kb.SimpleToast iconType="iconfont-check" text="Saved" visible={true} toastTargetRef={toastTargetRef} />
Expand Down
6 changes: 6 additions & 0 deletions shared/chat/conversation/messages/wrapper/send-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react'
import * as Kb from '@/common-adapters'
import {produce} from 'immer'
import {useColorScheme} from 'react-native'
import {registerExternalResetter} from '@/util/zustand'

type AnimationStatus =
| 'encrypting'
Expand Down Expand Up @@ -46,6 +47,11 @@ const statusToIconDarkExploding = {

const shownEncryptingSet = new Set()

// module scope outlives sign-out; holds the previous user's outbox ids
registerExternalResetter('chat-send-indicator-shown', () => {
shownEncryptingSet.clear()
})

type OwnProps = {
failed: boolean
id: number
Expand Down
8 changes: 8 additions & 0 deletions shared/chat/conversation/team-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@/teams/use-cached-resource'
import {updateChosenChannelsTeamnames, useChosenChannelsTeamnames} from './manage-channels-badge'
import {useThreadMeta} from './thread-context'
import {registerExternalResetter} from '@/util/zustand'

type ChatTeamState = {
allowPromote: boolean
Expand Down Expand Up @@ -67,6 +68,13 @@ const chatTeamCacheMap: TeamCacheMap<ChatTeamData> = new Map()
const chatTeamMembersCacheMap: TeamCacheMap<ChatTeamMembersData> = new Map()
const chatTeamReloadStaleMs = 5 * 60_000

// module scope outlives sign-out, so the next user would be served the previous
// user's team data and member lists
registerExternalResetter('chat-team-hooks-caches', () => {
chatTeamCacheMap.clear()
chatTeamMembersCacheMap.clear()
})

// A disabled "shadow" instance (one that returns the context value instead of
// its own) must NOT share the loader's cache: with enabled=false
// useCachedResource resets the cache, which would clobber the loader's data.
Expand Down
2 changes: 2 additions & 0 deletions shared/chat/create-channel/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as T from '@/constants/types'
import {RPCError} from '@/util/errors'
import upperFirst from 'lodash/upperFirst'
import type {Props} from './index.shared'
import {invalidateTeamChannels} from '@/teams/common/team-channels-invalidation'
import {useChatTeam} from '../conversation/team-hooks'

export default (p: Props) => {
Expand Down Expand Up @@ -57,6 +58,7 @@ export default (p: Props) => {
C.waitingKeyTeamsCreateChannel(teamID)
)
}
invalidateTeamChannels(teamID)
onBack()
if (navToChatOnSuccess) {
previewConversation({channelname, conversationIDKey, reason: 'newChannel', teamname})
Expand Down
11 changes: 9 additions & 2 deletions shared/chat/inbox-search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type InboxSearchVisibleResultCounts,
} from '../inbox/use-inbox-search'
import {showTeamByName} from '@/teams/team-page-actions'
import {registerExternalResetter} from '@/util/zustand'

type OwnProps = {
header?: React.ReactElement | null
Expand Down Expand Up @@ -57,6 +58,12 @@ const textResultCache = new Map<string, TextResult>()
const openTeamItemCache = new WeakMap<T.Chat.InboxSearchOpenTeamHit, OpenTeamResult>()
const botItemCache = new WeakMap<T.RPCGen.FeaturedBot, BotResult>()

// module scope outlives sign-out; these hold conversation and username hits
registerExternalResetter('chat-inbox-search-item-caches', () => {
nameResultCache.clear()
textResultCache.clear()
})

const canonNameResult = (next: NameResult) => {
if (nameResultCache.size > 4096) {
nameResultCache.clear()
Expand Down Expand Up @@ -258,7 +265,7 @@ export default function InboxSearchContainer(ownProps: OwnProps) {
renderHeaderWithMore(section, _botsResults.length, botsCollapsed, botsAll, toggleBotsAll)

const renderTextHeader = (section: Section) => {
const ratio = indexPercent / 100.0
const ratio = (indexPercent ?? 0) / 100.0
return (
<Kb.Box2 direction="vertical" fullWidth={true} style={styles.textHeader}>
<Kb.SectionDivider
Expand All @@ -273,7 +280,7 @@ export default function InboxSearchContainer(ownProps: OwnProps) {
Search failed, please try again, or contact Keybase describing the problem.
</Kb.Text>
</Kb.Box2>
) : indexPercent > 0 && indexPercent < 100 ? (
) : indexPercent !== undefined && indexPercent < 100 ? (
<Kb.Box2 direction="horizontal" gap="xtiny" style={styles.percentContainer} fullWidth={true}>
<Kb.Text type="BodyTiny">Indexing...</Kb.Text>
{isMobile ? (
Expand Down
8 changes: 5 additions & 3 deletions shared/chat/inbox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -510,9 +510,11 @@ function NativeInboxBody(p: ControlledInboxProps) {

const setOpenRow = useOpenedRowState(s => s.dispatch.setOpenRow)

C.Router2.useSafeFocusEffect(() => {
setOpenRow(Chat.noConversationIDKey)
})
C.Router2.useSafeFocusEffect(
React.useCallback(() => {
setOpenRow(Chat.noConversationIDKey)
}, [setOpenRow])
)

const promptSmallTeamsNum = React.useCallback(() => {
if (isIOS) {
Expand Down
5 changes: 4 additions & 1 deletion shared/chat/inbox/use-inbox-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const makeInboxSearchInfo = (): T.Chat.InboxSearchInfo => ({
botsResults: [],
botsResultsSuggested: false,
botsStatus: 'initial',
indexPercent: 0,
indexPercent: undefined,
nameResults: [],
nameResultsUnread: false,
nameStatus: 'initial',
Expand Down Expand Up @@ -311,6 +311,9 @@ export function useInboxSearch(): InboxSearchController {
updateIfActive(prev => ({
...prev,
botsStatus: 'inprogress',
// unknown again until this search reports one, rather than carrying
// the previous search's number
indexPercent: undefined,
nameStatus: 'inprogress',
openTeamsStatus: 'inprogress',
selectedIndex: 0,
Expand Down
12 changes: 7 additions & 5 deletions shared/chat/inbox/use-inbox-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,13 @@ export function useInboxState(
C.Router2.setChatRootParams({refreshInbox: undefined})
}, [inboxRefresh, isFocused, loggedIn, refreshInbox, username])

C.Router2.useSafeFocusEffect(() => {
if (!inboxHasLoaded) {
C.ignorePromise(inboxRefresh('componentNeverLoaded'))
}
})
C.Router2.useSafeFocusEffect(
React.useCallback(() => {
if (!inboxHasLoaded) {
C.ignorePromise(inboxRefresh('componentNeverLoaded'))
}
}, [inboxHasLoaded, inboxRefresh])
)

React.useEffect(() => {
const ready = loggedIn && !!username
Expand Down
Loading