Skip to content
Draft
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
106 changes: 35 additions & 71 deletions shared/chat/conversation/team-hooks.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import * as C from '@/constants'
import * as T from '@/constants/types'
import {useEngineActionListener} from '@/engine/action-listener'
import {useCurrentUserState} from '@/stores/current-user'
import {useUsersState} from '@/stores/users'
import * as Teams from '@/constants/teams'
import logger from '@/logger'
import * as React from 'react'
import {useTeamsListMap, useTeamsRoleMap} from '@/teams/use-teams-list'
import type * as EngineGen from '@/constants/rpc'
import {
type CachedResourceCache,
getCachedResourceCache,
type CachedResourceInvalidation,
createCachedResourceNamespace,
useCachedResource,
} from '@/util/use-cached-resource'
import {updateChosenChannelsTeamnames, useChosenChannelsTeamnames} from './manage-channels-badge'
import {useThreadMeta} from './thread-context'
import {registerExternalResetter} from '@/util/zustand'

type ChatTeamState = {
role: T.Teams.MaybeTeamRoleType
Expand Down Expand Up @@ -43,30 +42,38 @@ export type ChatManageChannelsBadge = ChatManageChannelsBadgeState & {
}

type ChatTeamMembersData = ReadonlyMap<string, T.Teams.MemberInfo>
type TeamCacheKey = T.Teams.TeamID | undefined
type TeamCacheMap<D> = Map<TeamCacheKey, CachedResourceCache<D, TeamCacheKey>>

const emptyChatTeamMembersData: ChatTeamMembersData = new Map<string, T.Teams.MemberInfo>()

// Module level so switching conversations (or channels within a team) reuses
// loaded members instead of refetching. teamChangedByID & friends invalidate.
const chatTeamMembersCacheMap: TeamCacheMap<ChatTeamMembersData> = new Map()
const chatTeamMembers = createCachedResourceNamespace<ChatTeamMembersData, T.Teams.TeamID>(
'chat-team-hooks-caches',
() => emptyChatTeamMembersData
)
const chatTeamReloadStaleMs = 5 * 60_000

// module scope outlives sign-out, so the next user would be served the previous
// user's member lists
registerExternalResetter('chat-team-hooks-caches', () => {
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.
// Give shadows a private throwaway map so their resets are harmless.
const useTeamCacheMap = <D,>(sharedCacheMap: TeamCacheMap<D>, forceLocalCache: boolean) => {
const [localCacheMap] = React.useState<TeamCacheMap<D>>(() => new Map())
return forceLocalCache ? localCacheMap : sharedCacheMap
}
const teamMemberInvalidations = (teamID?: T.Teams.TeamID) =>
[
{
type: 'keybase.1.NotifyTeam.teamChangedByID',
when: (action: EngineGen.Actions) =>
(action as EngineGen.ActionOf<'keybase.1.NotifyTeam.teamChangedByID'>).payload.params.teamID ===
teamID,
},
{
effect: 'clear',
type: 'keybase.1.NotifyTeam.teamDeleted',
when: (action: EngineGen.Actions) =>
(action as EngineGen.ActionOf<'keybase.1.NotifyTeam.teamDeleted'>).payload.params.teamID === teamID,
},
{
effect: 'clear',
type: 'keybase.1.NotifyTeam.teamExit',
when: (action: EngineGen.Actions) =>
(action as EngineGen.ActionOf<'keybase.1.NotifyTeam.teamExit'>).payload.params.teamID === teamID,
},
] satisfies ReadonlyArray<CachedResourceInvalidation>

const loadableTeamID = (teamID: T.Teams.TeamID) =>
teamID && teamID !== T.Teams.noTeamID && teamID !== T.Teams.newTeamWizardTeamID ? teamID : undefined
Expand Down Expand Up @@ -104,24 +111,13 @@ const useChatTeamRaw = (teamID: T.Teams.TeamID, teamname?: string): ChatTeam =>
}
}

const useChatTeamMembersRaw = (
teamID: T.Teams.TeamID,
enabled = true,
subscribeToUpdates = enabled,
forceLocalCache = false
): ChatTeamMembers => {
const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeamMembers => {
const validTeamID = loadableTeamID(teamID)
const cacheMap = useTeamCacheMap(chatTeamMembersCacheMap, forceLocalCache)
const cache = React.useMemo(
() => getCachedResourceCache(cacheMap, emptyChatTeamMembersData, validTeamID),
[cacheMap, validTeamID]
)

const {clear, data, loaded, loading, reload} = useCachedResource({
cache,
const {data, loaded, loading, reload} = useCachedResource({
cacheKey: validTeamID,
enabled: enabled && !!validTeamID,
enabled,
initialData: emptyChatTeamMembersData,
invalidateOn: teamMemberInvalidations(validTeamID),
load: async () => {
const members = Teams.rpcDetailsToMemberInfos(
(await T.RPCGen.teamsTeamGetMembersByIDRpcPromise({id: validTeamID ?? T.Teams.noTeamID})) ?? []
Expand All @@ -134,40 +130,13 @@ const useChatTeamMembersRaw = (
)
return members
},
namespace: chatTeamMembers,
onError: error => {
logger.warn(`Failed to reload chat team members for ${validTeamID}`, error)
},
staleMs: chatTeamReloadStaleMs,
})

useEngineActionListener(
'keybase.1.NotifyTeam.teamChangedByID',
action => {
if (action.payload.params.teamID === validTeamID) {
void reload()
}
},
subscribeToUpdates
)
useEngineActionListener(
'keybase.1.NotifyTeam.teamDeleted',
action => {
if (action.payload.params.teamID === validTeamID) {
clear(validTeamID)
}
},
subscribeToUpdates
)
useEngineActionListener(
'keybase.1.NotifyTeam.teamExit',
action => {
if (action.payload.params.teamID === validTeamID) {
clear(validTeamID)
}
},
subscribeToUpdates
)

// `loading` means "nothing to show yet" - a background revalidation of cached
// data must not flip callers back to their empty/spinner state.
return {loading: loading && !loaded, members: data, reload}
Expand Down Expand Up @@ -195,12 +164,7 @@ export const ChatTeamProvider = (props: React.PropsWithChildren) => {
const enabled = teamType !== 'adhoc' && !!loadableTeamID(teamID)
const sameAsOuter = outer?.teamID === teamID
const team = useChatTeamRaw(teamID, teamname)
const members = useChatTeamMembersRaw(
teamID,
enabled && !sameAsOuter,
enabled && !sameAsOuter,
sameAsOuter
)
const members = useChatTeamMembersRaw(teamID, enabled && !sameAsOuter)
const value: ChatTeamContextValue = sameAsOuter ? outer! : {members, team, teamID}
return <ChatTeamContext.Provider value={value}>{children}</ChatTeamContext.Provider>
}
Expand All @@ -215,7 +179,7 @@ export const useChatTeam = (teamID: T.Teams.TeamID, teamname?: string): ChatTeam
export const useChatTeamMembers = (teamID: T.Teams.TeamID): ChatTeamMembers => {
const context = React.useContext(ChatTeamContext)
const useContextValue = context?.teamID === teamID
const raw = useChatTeamMembersRaw(teamID, !useContextValue, !useContextValue, useContextValue)
const raw = useChatTeamMembersRaw(teamID, !useContextValue)
return useContextValue ? context.members : raw
}

Expand Down
57 changes: 9 additions & 48 deletions shared/chat/user-emoji.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import * as React from 'react'
import * as T from '@/constants/types'
import {useEmojiState} from '@/teams/emojis/use-emoji'
import {
type CachedResourceCache,
createCachedResourceCache,
getCachedResourceCache,
useCachedResource,
} from '@/util/use-cached-resource'
import {registerExternalResetter} from '@/util/zustand'
import {createCachedResourceNamespace, useCachedResource} from '@/util/use-cached-resource'

const emptyEmojiGroups: ReadonlyArray<T.RPCChat.EmojiGroup> = []
const emptyEmojis: ReadonlyArray<T.RPCChat.Emoji> = []
Expand All @@ -23,18 +17,15 @@ const emptyUserEmojiData: UserEmojiData = {emojiGroups: emptyEmojiGroups, emojis
// the service side - it resolves two attachment URLs per custom emoji - so the
// suggestor remounting on each ':' trigger used to refetch the entire set. The
// shared cache also collapses concurrent mounts onto a single in-flight request.
const userEmojiCaches = new Map<string, CachedResourceCache<UserEmojiData, string>>()
const userEmojiStaleMs = 60_000
// an entry holds every custom emoji the conv can see, with two resolved
// attachment URLs each, and every channel of a team keeps its own copy, so this
// is capped by size rather than left to grow for the life of the session
const userEmojiCacheMax = 32

// module scope outlives sign-out, so the next user would be served the previous
// user's custom emoji until the entries went stale
registerExternalResetter('chat-user-emoji-caches', () => {
userEmojiCaches.clear()
})
const userEmoji = createCachedResourceNamespace<UserEmojiData, string>(
'chat-user-emoji-caches',
() => emptyUserEmojiData,
{maxEntries: 32}
)
const userEmojiStaleMs = 60_000

// Adding, aliasing or removing an emoji has to drop the shared entries from the
// store, not from a mounted consumer: the edit happens on the team emoji page,
Expand All @@ -45,7 +36,7 @@ useEmojiState.subscribe((state, prev) => {
if (state.emojiUpdatedTrigger === prev.emojiUpdatedTrigger) {
return
}
userEmojiCaches.forEach((cache, key) => cache.invalidate(key))
userEmoji.invalidateAll()
})

const flattenUserEmojis = (groups: ReadonlyArray<T.RPCChat.EmojiGroup>) => {
Expand All @@ -72,36 +63,6 @@ export const useUserEmoji = ({
// the store subscription above already dropped the shared entries; this makes
// the mounted consumers re-run their load and pick the new set up
const emojiUpdatedTrigger = useEmojiState(s => s.emojiUpdatedTrigger)
// a disabled instance resets the cache it holds, so keep those off the shared one
const [localCache] = React.useState<CachedResourceCache<UserEmojiData, string>>(() =>
createCachedResourceCache(emptyUserEmojiData, requestKey)
)
// a disabled instance must not seed the shared map: it never loads, so the
// entry it created would sit there empty for the life of the session
const sharedCache = React.useMemo(() => {
if (disabled) {
return undefined
}
// drop the least recently asked for entries rather than the whole map: a
// wholesale clear at the cap makes the conversation you switch back to
// re-issue localUserEmojis even though its entry was still fresh. Map
// iterates in insertion order, and re-inserting on use below keeps that
// order meaningful.
if (userEmojiCaches.has(requestKey)) {
const existing = userEmojiCaches.get(requestKey)!
userEmojiCaches.delete(requestKey)
userEmojiCaches.set(requestKey, existing)
} else {
while (userEmojiCaches.size >= userEmojiCacheMax) {
const oldest = userEmojiCaches.keys().next()
if (oldest.done) {
break
}
userEmojiCaches.delete(oldest.value)
}
}
return getCachedResourceCache(userEmojiCaches, emptyUserEmojiData, requestKey)
}, [disabled, requestKey])
const load = React.useCallback(async () => {
const results = await T.RPCChat.localUserEmojisRpcPromise({
convID:
Expand All @@ -119,11 +80,11 @@ export const useUserEmoji = ({
}, [conversationIDKey, requestOnlyInTeam])

const {data, loading} = useCachedResource({
cache: sharedCache ?? localCache,
cacheKey: requestKey,
enabled: !disabled,
initialData: emptyUserEmojiData,
load,
namespace: userEmoji,
refreshKey: emojiUpdatedTrigger,
staleMs: userEmojiStaleMs,
})
Expand Down
27 changes: 14 additions & 13 deletions shared/teams/common/activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react'
import * as Kb from '@/common-adapters'
import * as T from '@/constants/types'
import logger from '@/logger'
import {createCachedResourceCache, type CachedResourceCache, useCachedResource} from '@/util/use-cached-resource'
import {createCachedResourceNamespace, useCachedResource} from '@/util/use-cached-resource'

const activityToIcon: {[key in 'active' | 'recently']: Kb.IconType} = {
active: 'iconfont-campfire-burning',
Expand Down Expand Up @@ -76,16 +76,23 @@ const parseActivityLevels = (
}
}

const useActivityLevelsRaw = (
cache: CachedResourceCache<ActivityLevelsData, typeof activityLevelsCacheKey>,
enabled = true
): ActivityLevels => {
// One entry for the whole app rather than one per provider: the teams root, a
// team, a channel and the add-to-channels modal nest, and each mount used to pay
// its own getLastActiveForTeams. The trade is that a remount inside the stale
// window is served from the entry instead of refetching - activity levels are a
// coarse "how busy is this" bucket, so up to staleMs of drift is acceptable.
const activityLevels = createCachedResourceNamespace<ActivityLevelsData, typeof activityLevelsCacheKey>(
'teams-activity-levels',
() => emptyActivityLevelsData
)

const useActivityLevelsRaw = (enabled = true): ActivityLevels => {
const {data, loaded, loading, reload} = useCachedResource({
cache,
cacheKey: activityLevelsCacheKey,
enabled,
initialData: emptyActivityLevelsData,
load: async () => parseActivityLevels(await T.RPCChat.localGetLastActiveForTeamsRpcPromise()),
namespace: activityLevels,
onError: error => {
logger.warn('Failed to load activity levels', error)
},
Expand All @@ -97,13 +104,7 @@ const useActivityLevelsRaw = (

export const ActivityLevelsProvider = (props: React.PropsWithChildren) => {
const {children} = props
const [cache] = React.useState(() =>
createCachedResourceCache<ActivityLevelsData, typeof activityLevelsCacheKey>(
emptyActivityLevelsData,
activityLevelsCacheKey
)
)
const value = useActivityLevelsRaw(cache)
const value = useActivityLevelsRaw()
return <ActivityLevelsContext.Provider value={value}>{children}</ActivityLevelsContext.Provider>
}

Expand Down
40 changes: 8 additions & 32 deletions shared/teams/common/general-conv.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import * as React from 'react'
import * as T from '@/constants/types'
import * as Meta from '@/constants/chat/meta'
import {metasReceived} from '@/chat/inbox/metadata'
import {registerExternalResetter} from '@/util/zustand'
import {
type CachedResourceCache,
createCachedResourceCache,
getCachedResourceCache,
useCachedResource,
} from '@/util/use-cached-resource'
import {createCachedResourceNamespace, useCachedResource} from '@/util/use-cached-resource'

type GeneralConvData = T.Chat.ConversationIDKey | undefined

Expand All @@ -18,35 +11,17 @@ const noGeneralConv: GeneralConvData = undefined
// team rows and the bot install modal - and each used to hold the answer in its
// own state, so every mount was another findGeneralConvFromTeamID. Share one
// cache per team, and let it live a while since the answer is effectively static.
const generalConvCaches = new Map<T.Teams.TeamID, CachedResourceCache<GeneralConvData, T.Teams.TeamID>>()
const generalConvs = createCachedResourceNamespace<GeneralConvData, T.Teams.TeamID>(
'teams-general-conv-caches',
() => noGeneralConv
)
const generalConvStaleMs = 5 * 60_000

// module scope outlives sign-out. Dropping the map is not enough on its own: a
// consumer that is still mounted through the sign-out holds the cache object
// itself, so each one has to be emptied as well.
registerExternalResetter('teams-general-conv-caches', () => {
generalConvCaches.forEach((cache, teamID) => cache.reset(noGeneralConv, teamID))
generalConvCaches.clear()
})

export const useGeneralConvIDKey = (teamID?: T.Teams.TeamID, enabled = true) => {
const validTeamID = teamID && teamID !== T.Teams.noTeamID ? teamID : undefined
const on = enabled && !!validTeamID
const cacheKey = validTeamID ?? T.Teams.noTeamID
// a disabled instance resets whatever cache it holds, so keep it off the shared one
const [localCache] = React.useState<CachedResourceCache<GeneralConvData, T.Teams.TeamID>>(() =>
createCachedResourceCache<GeneralConvData, T.Teams.TeamID>(noGeneralConv, cacheKey)
)
// an off instance must not seed the shared map: it never loads, so the entry it
// created would sit there empty for the life of the session
const sharedCache = React.useMemo(
() => (on ? getCachedResourceCache(generalConvCaches, noGeneralConv, cacheKey) : undefined),
[cacheKey, on]
)
const {data} = useCachedResource({
cache: sharedCache ?? localCache,
cacheKey,
enabled: on,
cacheKey: validTeamID,
enabled,
initialData: noGeneralConv,
load: async () => {
const conv = await T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise({
Expand All @@ -59,6 +34,7 @@ export const useGeneralConvIDKey = (teamID?: T.Teams.TeamID, enabled = true) =>
metasReceived([meta])
return meta.conversationIDKey
},
namespace: generalConvs,
staleMs: generalConvStaleMs,
})
return data
Expand Down
Loading