From e6ac912d418ccde5419498d2379665621fa3b467 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 3 Mar 2026 10:36:41 -0500 Subject: [PATCH 1/4] typo (#28965) --- shared/desktop/CHANGELOG.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/desktop/CHANGELOG.txt b/shared/desktop/CHANGELOG.txt index 170335b00409..94f072196ab0 100644 --- a/shared/desktop/CHANGELOG.txt +++ b/shared/desktop/CHANGELOG.txt @@ -1,4 +1,4 @@ -• On iOS quicky share to recent conversations +• On iOS quickly share to recent conversations • Emoji 16 support • iOS HEIC Avatar support • Better sharing/push support From 4258e91ef535c35ccb72ece901ae23b4be058d7f Mon Sep 17 00:00:00 2001 From: Joshua Blum Date: Fri, 13 Feb 2026 10:45:50 -0500 Subject: [PATCH 2/4] deduplicate silent notifications --- go/bind/notifications.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/go/bind/notifications.go b/go/bind/notifications.go index 3e1a9ddbbc2b..f2860bdac5f6 100644 --- a/go/bind/notifications.go +++ b/go/bind/notifications.go @@ -6,8 +6,11 @@ import ( "fmt" "regexp" "runtime" + "strconv" + "sync" "time" + lru "github.com/hashicorp/golang-lru" "github.com/keybase/client/go/chat" "github.com/keybase/client/go/chat/globals" "github.com/keybase/client/go/chat/storage" @@ -20,6 +23,20 @@ import ( "github.com/kyokomi/emoji" ) +const seenNotificationsCacheSize = 100 + +var ( + seenNotifications *lru.Cache + seenNotificationsOnce sync.Once +) + +func getSeenNotificationsCache() *lru.Cache { + seenNotificationsOnce.Do(func() { + seenNotifications, _ = lru.New(seenNotificationsCacheSize) + }) + return seenNotifications +} + type Person struct { KeybaseUsername string KeybaseAvatar string @@ -106,6 +123,17 @@ func HandleBackgroundNotification(strConvID, body, serverMessageBody, sender str return libkb.LoginRequiredError{} } mp := chat.NewMobilePush(gc) + // Dedupe by convID||msgID + dupKey := strConvID + "||" + strconv.Itoa(intMessageID) + if _, ok := getSeenNotificationsCache().Get(dupKey); ok { + // Cancel any duplicate visible notifications + if len(pushID) > 0 { + mp.AckNotificationSuccess(ctx, []string{pushID}) + } + kbCtx.Log.CDebugf(ctx, "HandleBackgroundNotification: duplicate notification convID=%s msgID=%d", strConvID, intMessageID) + // Return nil (not an error) so Android does not treat this as failure and show a fallback notification. + return nil + } uid := gregor1.UID(kbCtx.Env.GetUID().ToBytes()) convID, err := chat1.MakeConvID(strConvID) if err != nil { @@ -196,6 +224,7 @@ func HandleBackgroundNotification(strConvID, body, serverMessageBody, sender str // only display and ack this notification if we actually have something to display if pusher != nil && (len(chatNotification.Message.Plaintext) > 0 || len(chatNotification.Message.ServerMessage) > 0) { pusher.DisplayChatNotification(&chatNotification) + getSeenNotificationsCache().Add(dupKey, struct{}{}) if len(pushID) > 0 { mp.AckNotificationSuccess(ctx, []string{pushID}) } From 691e43dfb795bde943ab958cbfcc41464868c760 Mon Sep 17 00:00:00 2001 From: Joshua Blum Date: Fri, 13 Feb 2026 12:29:29 -0500 Subject: [PATCH 3/4] lock --- go/bind/notifications.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/go/bind/notifications.go b/go/bind/notifications.go index f2860bdac5f6..4482a7ca5964 100644 --- a/go/bind/notifications.go +++ b/go/bind/notifications.go @@ -26,6 +26,7 @@ import ( const seenNotificationsCacheSize = 100 var ( + seenNotificationsMtx sync.Mutex seenNotifications *lru.Cache seenNotificationsOnce sync.Once ) @@ -125,6 +126,9 @@ func HandleBackgroundNotification(strConvID, body, serverMessageBody, sender str mp := chat.NewMobilePush(gc) // Dedupe by convID||msgID dupKey := strConvID + "||" + strconv.Itoa(intMessageID) + // Check if we've already processed this notification but without + // serializing the whole function. We check the map again while holding + // a lock before anything is displayed. if _, ok := getSeenNotificationsCache().Get(dupKey); ok { // Cancel any duplicate visible notifications if len(pushID) > 0 { @@ -223,6 +227,18 @@ func HandleBackgroundNotification(strConvID, body, serverMessageBody, sender str // only display and ack this notification if we actually have something to display if pusher != nil && (len(chatNotification.Message.Plaintext) > 0 || len(chatNotification.Message.ServerMessage) > 0) { + // Lock and check if we've already processed this notification. + seenNotificationsMtx.Lock() + defer seenNotificationsMtx.Unlock() + if _, ok := getSeenNotificationsCache().Get(dupKey); ok { + // Cancel any duplicate visible notifications + if len(pushID) > 0 { + mp.AckNotificationSuccess(ctx, []string{pushID}) + } + kbCtx.Log.CDebugf(ctx, "HandleBackgroundNotification: duplicate notification convID=%s msgID=%d", strConvID, intMessageID) + // Return nil (not an error) so Android does not treat this as failure and show a fallback notification. + return nil + } pusher.DisplayChatNotification(&chatNotification) getSeenNotificationsCache().Add(dupKey, struct{}{}) if len(pushID) > 0 { From 270b84e1f97f427014de1421b7e48ff5ddf0a80b Mon Sep 17 00:00:00 2001 From: Joshua Blum Date: Fri, 13 Feb 2026 13:19:04 -0500 Subject: [PATCH 4/4] switch accounts on push --- go/libkb/secret_store.go | 13 ++++--- go/protocol/keybase1/login.go | 2 ++ protocol/avdl/keybase1/login.avdl | 1 + protocol/json/keybase1/login.json | 4 +++ shared/constants/daemon/index.tsx | 4 +-- .../platform-specific/push.native.tsx | 26 ++++++++++++++ shared/constants/push.d.ts | 2 ++ shared/constants/push.native.tsx | 34 +++++++++++++++++-- shared/constants/types/config.tsx | 1 + shared/constants/types/push.tsx | 5 ++- shared/constants/types/rpc-gen.tsx | 2 +- 11 files changed, 83 insertions(+), 11 deletions(-) diff --git a/go/libkb/secret_store.go b/go/libkb/secret_store.go index 8427edc54ca5..4eb7645138a6 100644 --- a/go/libkb/secret_store.go +++ b/go/libkb/secret_store.go @@ -117,19 +117,22 @@ func GetConfiguredAccountsFromProvisionedUsernames(m MetaContext, s SecretStoreA allUsernames = append(allUsernames, currentUsername) } + // Build UIDs first so we can attach them to each account + uids := make([]keybase1.UID, len(allUsernames)) + for idx, username := range allUsernames { + uids[idx] = GetUIDByNormalizedUsername(m.G(), username) + } + accounts := make(map[NormalizedUsername]keybase1.ConfiguredAccount) - for _, username := range allUsernames { + for idx, username := range allUsernames { accounts[username] = keybase1.ConfiguredAccount{ Username: username.String(), IsCurrent: username.Eq(currentUsername), + Uid: uids[idx], } } // Get the full names - uids := make([]keybase1.UID, len(allUsernames)) - for idx, username := range allUsernames { - uids[idx] = GetUIDByNormalizedUsername(m.G(), username) - } usernamePackages, err := m.G().UIDMapper.MapUIDsToUsernamePackages(m.Ctx(), m.G(), uids, time.Hour*24, time.Second*10, false) if err != nil { diff --git a/go/protocol/keybase1/login.go b/go/protocol/keybase1/login.go index 82da5f157e02..5d5a27dc615e 100644 --- a/go/protocol/keybase1/login.go +++ b/go/protocol/keybase1/login.go @@ -15,6 +15,7 @@ type ConfiguredAccount struct { Fullname FullName `codec:"fullname" json:"fullname"` HasStoredSecret bool `codec:"hasStoredSecret" json:"hasStoredSecret"` IsCurrent bool `codec:"isCurrent" json:"isCurrent"` + Uid UID `codec:"uid" json:"uid"` } func (o ConfiguredAccount) DeepCopy() ConfiguredAccount { @@ -23,6 +24,7 @@ func (o ConfiguredAccount) DeepCopy() ConfiguredAccount { Fullname: o.Fullname.DeepCopy(), HasStoredSecret: o.HasStoredSecret, IsCurrent: o.IsCurrent, + Uid: o.Uid.DeepCopy(), } } diff --git a/protocol/avdl/keybase1/login.avdl b/protocol/avdl/keybase1/login.avdl index 2ee7f816283a..d46df3e0ce30 100644 --- a/protocol/avdl/keybase1/login.avdl +++ b/protocol/avdl/keybase1/login.avdl @@ -9,6 +9,7 @@ protocol login { FullName fullname; boolean hasStoredSecret; boolean isCurrent; + UID uid; } /** diff --git a/protocol/json/keybase1/login.json b/protocol/json/keybase1/login.json index ec5d4da54935..20bc3bf201b5 100644 --- a/protocol/json/keybase1/login.json +++ b/protocol/json/keybase1/login.json @@ -26,6 +26,10 @@ { "type": "boolean", "name": "isCurrent" + }, + { + "type": "UID", + "name": "uid" } ] } diff --git a/shared/constants/daemon/index.tsx b/shared/constants/daemon/index.tsx index 15d3f95b7f9c..4b585cf28615 100644 --- a/shared/constants/daemon/index.tsx +++ b/shared/constants/daemon/index.tsx @@ -217,14 +217,14 @@ export const useDaemonState = Z.createZustand((set, get) => { const usernameToFullname: {[username: string]: string} = {} configuredAccounts.forEach(account => { - const {username, isCurrent, fullname, hasStoredSecret} = account + const {username, isCurrent, fullname, hasStoredSecret, uid} = account if (username === defaultUsername) { existingDefaultFound = true } if (isCurrent) { currentName = account.username } - nextConfiguredAccounts.push({hasStoredSecret, username}) + nextConfiguredAccounts.push({hasStoredSecret, uid, username}) usernameToFullname[username] = fullname }) if (!existingDefaultFound) { diff --git a/shared/constants/platform-specific/push.native.tsx b/shared/constants/platform-specific/push.native.tsx index 295d3927bb84..df75a85f0008 100644 --- a/shared/constants/platform-specific/push.native.tsx +++ b/shared/constants/platform-specific/push.native.tsx @@ -34,6 +34,7 @@ type DataNewMessageSilent2 = DataCommon & { } type DataFollow = DataCommon & { type: 'follow' + targetUID?: string username?: string } type DataChatExtension = DataCommon & { @@ -70,6 +71,8 @@ const normalizePush = (_n?: object): T.Push.PushNotification | undefined => { const data = _n as PushN const userInteraction = !!data.userInteraction + const dataUid = data as {uid?: string; targetUID?: string} + const forUid = dataUid.uid switch (data.type) { case 'chat.readmessage': { @@ -83,6 +86,7 @@ const normalizePush = (_n?: object): T.Push.PushNotification | undefined => { return data.convID ? { conversationIDKey: T.Chat.stringToConversationIDKey(data.convID), + forUid, membersType: anyToConversationMembersType(data.t), type: 'chat.newmessage', unboxPayload: data.m || '', @@ -105,6 +109,7 @@ const normalizePush = (_n?: object): T.Push.PushNotification | undefined => { case 'follow': return data.username ? { + forUid: forUid ?? dataUid.targetUID, type: 'follow', userInteraction, username: data.username, @@ -114,6 +119,7 @@ const normalizePush = (_n?: object): T.Push.PushNotification | undefined => { return data.convID ? { conversationIDKey: T.Chat.stringToConversationIDKey(data.convID), + forUid, type: 'chat.extension', } : undefined @@ -199,6 +205,26 @@ export const initPushListener = () => { storeRegistry.getState('push').dispatch.initialPermissionsCheck() + // When current-user.uid changes, run pending push if it was for this account + storeRegistry.getStore('current-user').subscribe((s, old) => { + if (s.uid === old.uid) return + const pushState = storeRegistry.getState('push') + const pending = pushState.pendingPushNotification + if (!pending || !('forUid' in pending)) return + const forUid = (pending as {forUid?: string}).forUid + if (!forUid || forUid !== s.uid) return + pushState.dispatch.clearPendingPushNotification() + pushState.dispatch.handlePush(pending) + }) + + // Clear pending push on logout + storeRegistry.getStore('config').subscribe((s, old) => { + if (s.loggedIn === old.loggedIn) return + if (!s.loggedIn) { + storeRegistry.getState('push').dispatch.clearPendingPushNotification() + } + }) + const listenNative = async () => { const RNEmitter = getNativeEmitter() diff --git a/shared/constants/push.d.ts b/shared/constants/push.d.ts index ae8fd435e57a..c0d151a5b59b 100644 --- a/shared/constants/push.d.ts +++ b/shared/constants/push.d.ts @@ -4,6 +4,7 @@ import type {UseBoundStore, StoreApi} from 'zustand' type Store = T.Immutable<{ hasPermissions: boolean justSignedUp: boolean + pendingPushNotification?: T.Push.PushNotification showPushPrompt: boolean token: string }> @@ -11,6 +12,7 @@ type Store = T.Immutable<{ export type State = Store & { dispatch: { checkPermissions: () => Promise + clearPendingPushNotification: () => void deleteToken: (version: number) => void handlePush: (notification: T.Push.PushNotification) => void initialPermissionsCheck: () => void diff --git a/shared/constants/push.native.tsx b/shared/constants/push.native.tsx index 605401cfb53f..0a7d79b1a00a 100644 --- a/shared/constants/push.native.tsx +++ b/shared/constants/push.native.tsx @@ -22,6 +22,7 @@ export const tokenType = isIOS ? (isDevApplePushToken ? 'appledev' : 'apple') : const initialStore: Store = { hasPermissions: true, justSignedUp: false, + pendingPushNotification: undefined, showPushPrompt: false, token: '', } @@ -112,6 +113,11 @@ export const usePushState = Z.createZustand((set, get) => { return false } }, + clearPendingPushNotification: () => { + set(s => { + s.pendingPushNotification = undefined + }) + }, deleteToken: version => { const f = async () => { const waitKey = 'push:deleteToken' @@ -141,6 +147,31 @@ export const usePushState = Z.createZustand((set, get) => { handlePush: notification => { const f = async () => { try { + const forUid = 'forUid' in notification ? notification.forUid : undefined + + if (forUid) { + const currentUid = storeRegistry.getState('current-user').uid + if (forUid !== currentUid) { + const {configuredAccounts, dispatch: configDispatch} = storeRegistry.getState('config') + const account = configuredAccounts.find(acc => acc.uid === forUid) + if (!account) { + logger.info('[Push] notification forUid not in configured accounts, skipping') + return + } + if (!account.hasStoredSecret) { + logger.info('[Push] account has no stored secret, cannot switch') + return + } + logger.info('[Push] switching to account for notification tap') + set(s => { + s.pendingPushNotification = notification + }) + configDispatch.setUserSwitching(true) + configDispatch.login(account.username, '') + return + } + } + switch (notification.type) { case 'chat.readmessage': if (notification.badges === 0) { @@ -176,8 +207,7 @@ export const usePushState = Z.createZustand((set, get) => { if (__DEV__) { console.error(e) } - - logger.error('[Push] unhandled!!') + logger.error('[Push] unhandled', e) } } ignorePromise(f()) diff --git a/shared/constants/types/config.tsx b/shared/constants/types/config.tsx index e001a72201e9..11b22b3b3f11 100644 --- a/shared/constants/types/config.tsx +++ b/shared/constants/types/config.tsx @@ -9,6 +9,7 @@ export type OutOfDate = { export type DaemonHandshakeState = 'starting' | 'waitingForWaiters' | 'done' export type ConfiguredAccount = { hasStoredSecret: boolean + uid?: string username: string } // 'notavailable' is the desktop default diff --git a/shared/constants/types/push.tsx b/shared/constants/types/push.tsx index 8b35307b1543..22e5166d6ee1 100644 --- a/shared/constants/types/push.tsx +++ b/shared/constants/types/push.tsx @@ -16,19 +16,22 @@ export type PushNotification = } | { conversationIDKey: ChatTypes.ConversationIDKey + forUid?: string membersType?: RPCChatTypes.ConversationMembersType type: 'chat.newmessage' unboxPayload: string userInteraction: boolean } | { + forUid?: string type: 'follow' userInteraction: boolean username: string } | { - type: 'chat.extension' conversationIDKey: ChatTypes.ConversationIDKey + forUid?: string + type: 'chat.extension' } | { type: 'settings.contacts' diff --git a/shared/constants/types/rpc-gen.tsx b/shared/constants/types/rpc-gen.tsx index 8160728a8afb..6694069ec171 100644 --- a/shared/constants/types/rpc-gen.tsx +++ b/shared/constants/types/rpc-gen.tsx @@ -2788,7 +2788,7 @@ export type ComponentResult = {readonly name: String; readonly status: Status; r export type Confidence = {readonly usernameVerifiedVia: UsernameVerificationType; readonly proofs?: ReadonlyArray | null; readonly other: String} export type Config = {readonly serverURI: String; readonly socketFile: String; readonly label: String; readonly runMode: String; readonly gpgExists: Boolean; readonly gpgPath: String; readonly version: String; readonly path: String; readonly binaryRealpath: String; readonly configPath: String; readonly versionShort: String; readonly versionFull: String; readonly isAutoForked: Boolean; readonly forkType: ForkType} export type ConfigValue = {readonly isNull: Boolean; readonly b?: Boolean | null; readonly i?: Int | null; readonly f?: Double | null; readonly s?: String | null; readonly o?: String | null} -export type ConfiguredAccount = {readonly username: String; readonly fullname: FullName; readonly hasStoredSecret: Boolean; readonly isCurrent: Boolean} +export type ConfiguredAccount = {readonly username: String; readonly fullname: FullName; readonly hasStoredSecret: Boolean; readonly isCurrent: Boolean; readonly uid: UID} export type ConfirmResult = {readonly identityConfirmed: Boolean; readonly remoteConfirmed: Boolean; readonly expiringLocal: Boolean; readonly autoConfirmed: Boolean} export type ConflictGeneration = Int export type ConflictState = {conflictStateType: ConflictStateType.normalview; normalview: FolderNormalView} | {conflictStateType: ConflictStateType.manualresolvinglocalview; manualresolvinglocalview: FolderConflictManualResolvingLocalView}