diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
new file mode 100644
index 000000000000..4cbe78fb1644
--- /dev/null
+++ b/.claude-plugin/plugin.json
@@ -0,0 +1,4 @@
+{
+ "name": "keybase-client-skills",
+ "description": "Development skills for the Keybase client repo"
+}
diff --git a/.claude/hooks/pre-commit-check.sh b/.claude/hooks/pre-commit-check.sh
new file mode 100755
index 000000000000..c7e0895f9857
--- /dev/null
+++ b/.claude/hooks/pre-commit-check.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+
+REPO_ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel)
+
+if [ ! -d "$REPO_ROOT/shared/node_modules" ]; then
+ echo "node_modules not installed — skipping lint/tsc." >&2
+ exit 0
+fi
+
+if ! (cd "$REPO_ROOT/shared" && yarn lint 2>&1); then
+ echo "Lint failed — commit blocked." >&2
+ exit 2
+fi
+
+if ! (cd "$REPO_ROOT/shared" && yarn tsc 2>&1); then
+ echo "TypeScript check failed — commit blocked." >&2
+ exit 2
+fi
+
+echo "Pre-commit checks passed." >&2
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 000000000000..949a8ab85e9c
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,34 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(yarn lint)",
+ "Bash(yarn lint-warn:*)",
+ "Bash(yarn prettier:*)",
+ "Bash(yarn build-*)",
+ "Bash(yarn package)",
+ "Bash(yarn start*)",
+ "Bash(yarn rn-gobuild*)",
+ "Bash(yarn rn-download-android*)",
+ "Bash(yarn tsc)",
+ "Bash(yarn tsc:*)",
+ "Bash(yarn pod*)",
+ "Bash(yarn coverage:*)",
+ "Bash(yarn maestro*)"
+ ]
+ },
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash(git commit*)",
+ "hooks": [
+ {
+ "type": "command",
+ "command": ".claude/hooks/pre-commit-check.sh",
+ "timeout": 120,
+ "statusMessage": "Running lint and tsc..."
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.claude/skills b/.claude/skills
new file mode 120000
index 000000000000..96973154e6c6
--- /dev/null
+++ b/.claude/skills
@@ -0,0 +1 @@
+../skill
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 471efbebaf00..a0f29bb539aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -87,6 +87,5 @@ go/keybase_netbsd
go/keybase_openbsd
.cursor
-.claude
+.claude/settings.local.json
.*-mcp
-CLAUDE.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000000..e5c25da7d0a0
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,4 @@
+# Repo Notes
+
+- This repo uses React Compiler. Assume React Compiler patterns are enabled when editing React code, and avoid adding `useMemo`/`useCallback` by default unless they are clearly needed for correctness or compatibility with existing code.
+- When a component reads multiple adjacent values from the same store hook, prefer a consolidated selector with `C.useShallow(...)` instead of multiple separate subscriptions.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000000..25405c987feb
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,19 @@
+## Rules
+- No `Co-Authored-By` in commits. Ever.
+- "Was working before" = base branch (`nojima/HOTPOT-next-670-clean` or `master`), not previous commit.
+- Never use `npm`. Always `yarn`.
+- Never silently drop features/behavior — ask first, present options.
+- No DOM elements (`
`, `
`, etc.) in plain `.tsx` files — use `Kb.*`. Guard desktop-only DOM with `Styles.isMobile`.
+- Temp files go in `/tmp/`.
+- Remove unused code when editing: styles, imports, vars, params, dead helpers.
+- Comments: no refactoring notes; only add when context isn't obvious from code.
+- Exact versions in `package.json` (no `^`/`~`).
+- Keep `react`, `react-dom`, `react-native`, `@react-native/*` in sync with Expo SDK.
+- When updating deps: edit `package.json` → `yarn` → `yarn pod-install`.
+- When updating `electron`: run `shared/desktop/extract-electron-shasums.sh `.
+
+## Working Directory
+Repo root is `client/`. TS source lives in `shared/`. Always use absolute paths for file ops. For Bash: always `cd shared/` first.
+
+## Validation
+After TS changes (from `shared/`): `yarn lint` then `yarn tsc`. When debugging visually, skip until fix is confirmed. Never delete the ESLint cache.
diff --git a/go/auth/credential_authority_test.go b/go/auth/credential_authority_test.go
index 4a5efc709095..61338322e02e 100644
--- a/go/auth/credential_authority_test.go
+++ b/go/auth/credential_authority_test.go
@@ -59,7 +59,7 @@ func newTestUser(nKeys int) *testUser {
sibkeys: make([]keybase1.KID, nKeys),
subkeys: make([]keybase1.KID, nKeys),
}
- for i := 0; i < nKeys; i++ {
+ for i := range nKeys {
ret.sibkeys[i] = genKID()
ret.subkeys[i] = genKID()
}
@@ -236,7 +236,7 @@ func TestSimple(t *testing.T) {
u1 := state.newTestUser(4)
ng := 3
- for i := 0; i < 10; i++ {
+ for range 10 {
err = credentialAuthority.CheckUserKey(context.TODO(), u1.uid, &u1.username, &u1.sibkeys[0], false)
if err != nil {
t.Fatal(err)
@@ -283,7 +283,7 @@ func TestCheckUsers(t *testing.T) {
state, credentialAuthority := newTestSetup()
var users, usersWithDud []keybase1.UID
- for i := 0; i < 10; i++ {
+ for range 10 {
u := state.newTestUser(2)
users = append(users, u.uid)
usersWithDud = append(usersWithDud, u.uid)
diff --git a/go/avatars/fileurilze_nix.go b/go/avatars/fileurilze_nix.go
index 7d42d6cb4b6a..b04086f1e648 100644
--- a/go/avatars/fileurilze_nix.go
+++ b/go/avatars/fileurilze_nix.go
@@ -1,5 +1,4 @@
//go:build !windows
-// +build !windows
package avatars
diff --git a/go/avatars/fileurlize_windows.go b/go/avatars/fileurlize_windows.go
index d8edd1e80fa5..8d54be4e12ea 100644
--- a/go/avatars/fileurlize_windows.go
+++ b/go/avatars/fileurlize_windows.go
@@ -1,5 +1,4 @@
//go:build windows
-// +build windows
package avatars
diff --git a/go/avatars/fullcaching.go b/go/avatars/fullcaching.go
index 3449be8879db..897460f8b0b2 100644
--- a/go/avatars/fullcaching.go
+++ b/go/avatars/fullcaching.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "maps"
"net/url"
"os"
"path/filepath"
@@ -97,10 +98,10 @@ type FullCachingSource struct {
prepareDirs sync.Once
- usersMissBatch func(interface{})
- teamsMissBatch func(interface{})
- usersStaleBatch func(interface{})
- teamsStaleBatch func(interface{})
+ usersMissBatch func(any)
+ teamsMissBatch func(any)
+ usersStaleBatch func(any)
+ teamsStaleBatch func(any)
// testing
populateSuccessCh chan struct{}
@@ -116,16 +117,16 @@ func NewFullCachingSource(g *libkb.GlobalContext, staleThreshold time.Duration,
staleThreshold: staleThreshold,
simpleSource: NewSimpleSource(),
}
- batcher := func(intBatched interface{}, intSingle interface{}) interface{} {
+ batcher := func(intBatched any, intSingle any) any {
reqs, _ := intBatched.([]remoteFetchArg)
single, _ := intSingle.(remoteFetchArg)
return append(reqs, single)
}
- reset := func() interface{} {
+ reset := func() any {
return []remoteFetchArg{}
}
- actor := func(loadFn func(libkb.MetaContext, []string, []keybase1.AvatarFormat) (keybase1.LoadAvatarsRes, error)) func(interface{}) {
- return func(intBatched interface{}) {
+ actor := func(loadFn func(libkb.MetaContext, []string, []keybase1.AvatarFormat) (keybase1.LoadAvatarsRes, error)) func(any) {
+ return func(intBatched any) {
reqs, _ := intBatched.([]remoteFetchArg)
s.makeRemoteFetchRequests(reqs, loadFn)
}
@@ -213,7 +214,7 @@ func (c *FullCachingSource) StartBackgroundTasks(mctx libkb.MetaContext) {
c.started = true
go c.monitorAppState(mctx)
c.populateCacheCh = make(chan populateArg, 100)
- for i := 0; i < 10; i++ {
+ for range 10 {
go c.populateCacheWorker(mctx)
}
mctx, cancel := mctx.WithContextCancel()
@@ -238,7 +239,7 @@ func (c *FullCachingSource) StopBackgroundTasks(mctx libkb.MetaContext) {
}
}
-func (c *FullCachingSource) debug(m libkb.MetaContext, msg string, args ...interface{}) {
+func (c *FullCachingSource) debug(m libkb.MetaContext, msg string, args ...any) {
m.Debug("Avatars.FullCachingSource: %s", fmt.Sprintf(msg, args...))
}
@@ -266,7 +267,7 @@ func (c *FullCachingSource) monitorAppState(m libkb.MetaContext) {
func (c *FullCachingSource) processLRUHit(entry lru.DiskLRUEntry) (res lruEntry) {
var ok bool
- if _, ok = entry.Value.(map[string]interface{}); ok {
+ if _, ok = entry.Value.(map[string]any); ok {
jstr, _ := json.Marshal(entry.Value)
_ = json.Unmarshal(jstr, &res)
return res
@@ -488,9 +489,7 @@ func (c *FullCachingSource) makeURL(m libkb.MetaContext, path string) keybase1.A
func (c *FullCachingSource) mergeRes(res *keybase1.LoadAvatarsRes, m keybase1.LoadAvatarsRes) {
for username, rec := range m.Picmap {
- for format, url := range rec {
- res.Picmap[username][format] = url
- }
+ maps.Copy(res.Picmap[username], rec)
}
}
diff --git a/go/avatars/simple.go b/go/avatars/simple.go
index 32a9c92c5c43..e9c8531ada8e 100644
--- a/go/avatars/simple.go
+++ b/go/avatars/simple.go
@@ -2,6 +2,7 @@ package avatars
import (
"fmt"
+ "maps"
"strings"
"github.com/keybase/client/go/libkb"
@@ -63,14 +64,12 @@ func (s *SimpleSource) makeRes(res *keybase1.LoadAvatarsRes, apiRes apiAvatarRes
allocRes(res, names)
for index, rec := range apiRes.Pictures {
u := names[index]
- for format, url := range rec {
- res.Picmap[u][format] = url
- }
+ maps.Copy(res.Picmap[u], rec)
}
return nil
}
-func (s *SimpleSource) debug(m libkb.MetaContext, msg string, args ...interface{}) {
+func (s *SimpleSource) debug(m libkb.MetaContext, msg string, args ...any) {
m.Debug("Avatars.SimpleSource: %s", fmt.Sprintf(msg, args...))
}
diff --git a/go/avatars/srv.go b/go/avatars/srv.go
index 2dc7e721b0c4..dbd1c91e7149 100644
--- a/go/avatars/srv.go
+++ b/go/avatars/srv.go
@@ -64,12 +64,12 @@ func (s *Srv) GetUserAvatar(username string) (string, error) {
return fmt.Sprintf("http://%v/av?typ=user&name=%v&format=square_192&token=%v", addr, username, token), nil
}
-func (s *Srv) debug(msg string, args ...interface{}) {
+func (s *Srv) debug(msg string, args ...any) {
s.G().GetLog().Debug("Avatars.Srv: %s", fmt.Sprintf(msg, args...))
}
func (s *Srv) makeError(w http.ResponseWriter, code int, msg string,
- args ...interface{},
+ args ...any,
) {
s.debug("serve: error code: %d msg %s", code, fmt.Sprintf(msg, args...))
w.WriteHeader(code)
diff --git a/go/avatars/urlcaching.go b/go/avatars/urlcaching.go
index a5f7f56bfb96..deab9994fcfd 100644
--- a/go/avatars/urlcaching.go
+++ b/go/avatars/urlcaching.go
@@ -2,6 +2,7 @@ package avatars
import (
"fmt"
+ "maps"
"time"
"github.com/keybase/client/go/libkb"
@@ -36,7 +37,7 @@ func (c *URLCachingSource) StopBackgroundTasks(m libkb.MetaContext) {
c.diskLRU.Flush(m.Ctx(), m.G())
}
-func (c *URLCachingSource) debug(m libkb.MetaContext, msg string, args ...interface{}) {
+func (c *URLCachingSource) debug(m libkb.MetaContext, msg string, args ...any) {
m.Debug("Avatars.URLCachingSource: %s", fmt.Sprintf(msg, args...))
}
@@ -89,9 +90,7 @@ func (c *URLCachingSource) specLoad(m libkb.MetaContext, names []string, formats
func (c *URLCachingSource) mergeRes(res *keybase1.LoadAvatarsRes, m keybase1.LoadAvatarsRes) {
for username, rec := range m.Picmap {
- for format, url := range rec {
- res.Picmap[username][format] = url
- }
+ maps.Copy(res.Picmap[username], rec)
}
}
diff --git a/go/bind/dns_ios.go b/go/bind/dns_ios.go
index d81cfecd7e31..9a8ef38e2c9d 100644
--- a/go/bind/dns_ios.go
+++ b/go/bind/dns_ios.go
@@ -2,7 +2,6 @@
// this source code is governed by the included BSD license.
//
//go:build ios
-// +build ios
package keybase
diff --git a/go/bind/dns_other.go b/go/bind/dns_other.go
index 369d7b01dcf8..b638c5120ebb 100644
--- a/go/bind/dns_other.go
+++ b/go/bind/dns_other.go
@@ -2,7 +2,6 @@
// this source code is governed by the included BSD license.
//
//go:build !ios
-// +build !ios
package keybase
diff --git a/go/bind/keybase.go b/go/bind/keybase.go
index 46a598215680..03d2241e5025 100644
--- a/go/bind/keybase.go
+++ b/go/bind/keybase.go
@@ -60,7 +60,7 @@ var (
)
// log writes to kbCtx.Log if available, otherwise falls back to fmt.Printf
-func log(format string, args ...interface{}) {
+func log(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if kbCtx != nil && kbCtx.Log != nil {
kbCtx.Log.Info(msg)
@@ -446,7 +446,7 @@ func LogSend(statusJSON string, feedback string, sendLogs, sendMaxBytes bool, tr
return string(logSendID), err
}
-// WriteArr sends raw bytes encoded msgpack rpc payload, ios only
+// WriteArr sends raw bytes encoded msgpack rpc payload from the native layer (iOS and Android)
func WriteArr(b []byte) (err error) {
bytes := make([]byte, len(b))
copy(bytes, b)
diff --git a/go/bind/keystore_android.go b/go/bind/keystore_android.go
index 9d2832896f1e..c873d91f6e10 100644
--- a/go/bind/keystore_android.go
+++ b/go/bind/keystore_android.go
@@ -2,7 +2,6 @@
// this source code is governed by the included BSD license.
//go:build android
-// +build android
package keybase
diff --git a/go/bind/notifications.go b/go/bind/notifications.go
index 3e1a9ddbbc2b..4482a7ca5964 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,21 @@ import (
"github.com/kyokomi/emoji"
)
+const seenNotificationsCacheSize = 100
+
+var (
+ seenNotificationsMtx sync.Mutex
+ 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 +124,20 @@ func HandleBackgroundNotification(strConvID, body, serverMessageBody, sender str
return libkb.LoginRequiredError{}
}
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 {
+ 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 {
@@ -195,7 +227,20 @@ 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 {
mp.AckNotificationSuccess(ctx, []string{pushID})
}
diff --git a/go/blindtree/defaults.go b/go/blindtree/defaults.go
index abb04192d0cb..ac37f49daa33 100644
--- a/go/blindtree/defaults.go
+++ b/go/blindtree/defaults.go
@@ -14,7 +14,7 @@ const (
)
func GetCurrentBlindTreeConfig() (cfg merkletree2.Config) {
- valueConstructor := func() interface{} { return BlindMerkleValue{} }
+ valueConstructor := func() any { return BlindMerkleValue{} }
cfg, err := merkletree2.NewConfig(
encodingType.GetEncoder(),
@@ -31,7 +31,7 @@ func GetCurrentBlindTreeConfig() (cfg merkletree2.Config) {
// This config uses the non thread safe encoder.
func GetCurrentBlindTreeConfigUnsafe() (cfg merkletree2.Config) {
- valueConstructor := func() interface{} { return BlindMerkleValue{} }
+ valueConstructor := func() any { return BlindMerkleValue{} }
cfg, err := merkletree2.NewConfig(
encodingType.GetUnsafeEncoder(),
diff --git a/go/blindtree/values.go b/go/blindtree/values.go
index 1df7cf8e5fb9..84b4b9fee836 100644
--- a/go/blindtree/values.go
+++ b/go/blindtree/values.go
@@ -12,7 +12,7 @@ import (
// appropriate.
type BlindMerkleValue struct {
ValueType BlindMerkleValueType
- InnerValue interface{}
+ InnerValue any
}
// Note: values up to 127 are preferred as they are encoded in a single byte
diff --git a/go/blindtree/values_test.go b/go/blindtree/values_test.go
index 776e27a5e742..0284a729e5cc 100644
--- a/go/blindtree/values_test.go
+++ b/go/blindtree/values_test.go
@@ -18,7 +18,7 @@ func TestEncodeMerkleValues(t *testing.T) {
}
encodingTests := []struct {
- Value interface{}
+ Value any
EncodedValue BlindMerkleValue
Type BlindMerkleValueType
}{
diff --git a/go/chat/archive.go b/go/chat/archive.go
index 85599d8796b6..c001bc50a40f 100644
--- a/go/chat/archive.go
+++ b/go/chat/archive.go
@@ -786,7 +786,6 @@ func (c *ChatArchiver) ArchiveChat(ctx context.Context, arg chat1.ArchiveChatJob
// - Messages are rendered in a text format and attachments are downloaded to the archive path.
eg.SetLimit(10)
for _, conv := range convs {
- conv := conv
eg.Go(func() error {
return c.archiveConv(ctx, arg, &jobInfo, conv)
})
diff --git a/go/chat/attachment_httpsrv.go b/go/chat/attachment_httpsrv.go
index 754fd1161925..6affad30488f 100644
--- a/go/chat/attachment_httpsrv.go
+++ b/go/chat/attachment_httpsrv.go
@@ -87,7 +87,7 @@ func NewAttachmentHTTPSrv(g *globals.Context, httpSrv *manager.Srv, fetcher type
fetcher: fetcher,
httpSrv: httpSrv,
hmacPool: sync.Pool{
- New: func() interface{} {
+ New: func() any {
return hmac.New(sha256.New, token)
},
},
@@ -105,7 +105,7 @@ func (r *AttachmentHTTPSrv) GetAttachmentFetcher() types.AttachmentFetcher {
return r.fetcher
}
-func (r *AttachmentHTTPSrv) genURLKey(prefix string, payload interface{}) (string, error) {
+func (r *AttachmentHTTPSrv) genURLKey(prefix string, payload any) (string, error) {
h := r.hmacPool.Get().(hash.Hash)
defer r.hmacPool.Put(h)
h.Reset()
@@ -119,7 +119,7 @@ func (r *AttachmentHTTPSrv) genURLKey(prefix string, payload interface{}) (strin
return prefix + hex.EncodeToString(h.Sum(nil)), nil
}
-func (r *AttachmentHTTPSrv) getURL(ctx context.Context, prefix string, payload interface{}) string {
+func (r *AttachmentHTTPSrv) getURL(ctx context.Context, prefix string, payload any) string {
if !r.httpSrv.Active() {
r.Debug(ctx, "getURL: http server failed to start earlier")
return ""
@@ -333,9 +333,9 @@ func (r *AttachmentHTTPSrv) serveGiphyGallery(ctx context.Context, w http.Respon
return
}
galleryInfo := infoInt.(giphyGalleryInfo)
- var videoStr string
+ var videoStr strings.Builder
for _, res := range galleryInfo.Results {
- videoStr += fmt.Sprintf(`
+ fmt.Fprintf(&videoStr, `
`, res.PreviewUrl, r.getGiphyGallerySelectURL(ctx, galleryInfo.ConvID, galleryInfo.TlfName,
res))
@@ -357,7 +357,7 @@ func (r *AttachmentHTTPSrv) serveGiphyGallery(ctx context.Context, w http.Respon
%s