From a19c38544ccbcaba960bdca6be6944d090cd847c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 09:12:55 -0400 Subject: [PATCH 1/7] chat: pinned inbox conversations backend and TS helpers Adds isPinned to UIInboxSmallTeamRow, a synced chatPinnedConvs gregor category that orders pinned small conversations first in the inbox layout, a gregor handler that rebuilds the layout when pins change on any device, and TS helpers to read/write the pinned list. --- CLAUDE.md | 2 +- go/chat/uiinboxloader.go | 48 ++++++++++++++---- go/chat/uiinboxloader_test.go | 37 ++++++++++++++ go/chat/utils/pinnedconvs.go | 53 ++++++++++++++++++++ go/chat/utils/pinnedconvs_test.go | 17 +++++++ go/protocol/chat1/chat_ui.go | 6 ++- go/service/chat_pinned_convs_handler.go | 46 +++++++++++++++++ go/service/main.go | 1 + protocol/avdl/chat1/chat_ui.avdl | 1 + protocol/json/chat1/chat_ui.json | 4 ++ shared/chat/inbox/pinned-convs.test.tsx | 35 +++++++++++++ shared/chat/inbox/pinned-convs.tsx | 65 +++++++++++++++++++++++++ shared/chat/inbox/rows-state.test.ts | 2 + shared/chat/inbox/rows.test.tsx | 1 + shared/constants/rpc/rpc-chat-gen.tsx | 2 +- 15 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 go/chat/utils/pinnedconvs.go create mode 100644 go/chat/utils/pinnedconvs_test.go create mode 100644 go/service/chat_pinned_convs_handler.go create mode 100644 shared/chat/inbox/pinned-convs.test.tsx create mode 100644 shared/chat/inbox/pinned-convs.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 64f8e241ab61..7e9338093dc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,4 +27,4 @@ Repo root is `client/`. TS source lives in `shared/`. Always use absolute paths ## Validation 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`. `lint:bailouts` also flags components the compiler cannot name (an `isMobile ? arrow : arrow` ternary is never compiled at all, so nothing in it is memoized — name both branches instead), and memo scopes keyed on the whole props object (a `props.x` read inside a callback, or a destructure below one, makes the compiler key on `props` itself, so the cache never hits — read every prop through one destructure at the top, above every callback). Repo baseline is 0 bailouts and 0 whole-props deps; keep it there. When debugging visually, skip until fix is confirmed. Never delete the ESLint cache. -Before reporting any TS change complete: run `yarn lint:all`, then run `/code-review high` against your own diff and fix what it finds. Report done only after both are clean — do not hand unvalidated work to the user for review. If a finding is wrong, say why instead of applying it. +Before reporting any TS change complete: run `yarn lint:all` and get it clean. Do NOT run `/code-review` while iterating, building, testing, or debugging — only once the change is about to be pushed (commit for a PR, push, or open a PR). At that point run `/code-review high` against the diff and fix what it finds; if a finding is wrong, say why instead of applying it. diff --git a/go/chat/uiinboxloader.go b/go/chat/uiinboxloader.go index 0410141530c0..ade77cb5ceaf 100644 --- a/go/chat/uiinboxloader.go +++ b/go/chat/uiinboxloader.go @@ -448,6 +448,30 @@ func (c *bigTeamCollector) finalize(ctx context.Context) (res []chat1.UIInboxBig return res } +// orderSmallTeamRows puts pinned rows first in pinned-list order, then the +// rest newest first. Pinned IDs with no matching row are ignored. +func orderSmallTeamRows(rows []chat1.UIInboxSmallTeamRow, pinned []chat1.ConvIDStr) { + pinIndex := make(map[chat1.ConvIDStr]int, len(pinned)) + for i, id := range pinned { + pinIndex[id] = i + } + for i := range rows { + _, rows[i].IsPinned = pinIndex[rows[i].ConvID] + } + sort.SliceStable(rows, func(i, j int) bool { + pi, iPinned := pinIndex[rows[i].ConvID] + pj, jPinned := pinIndex[rows[j].ConvID] + switch { + case iPinned && jPinned: + return pi < pj + case iPinned != jPinned: + return iPinned + default: + return rows[i].Time.After(rows[j].Time) + } + }) +} + func (h *UIInboxLoader) buildLayout(ctx context.Context, inbox types.Inbox, reselectMode chat1.InboxLayoutReselectMode, ) (res chat1.UIInboxLayout) { @@ -484,9 +508,11 @@ func (h *UIInboxLoader) buildLayout(ctx context.Context, inbox types.Inbox, widgetList = append(widgetList, utils.PresentRemoteConversationAsSmallTeamRow(ctx, conv, h.G().GetEnv().GetUsername().String())) } - sort.Slice(res.SmallTeams, func(i, j int) bool { - return res.SmallTeams[i].Time.After(res.SmallTeams[j].Time) - }) + pinned, err := utils.GetPinnedConvs(ctx, h.G()) + if err != nil { + h.Debug(ctx, "buildLayout: failed to get pinned convs: %s", err) + } + orderSmallTeamRows(res.SmallTeams, pinned) res.BigTeams = btcollector.finalize(ctx) res.TotalSmallTeams = len(res.SmallTeams) if res.TotalSmallTeams > h.smallTeamBound { @@ -774,16 +800,20 @@ func (h *UIInboxLoader) layoutLoop(shutdownCh chan struct{}) error { } } -func (h *UIInboxLoader) isTopSmallTeamInLastLayout(convID chat1.ConversationID) bool { +// A new message can't move a pinned row, so compare against the first +// unpinned row to decide whether the order could change. +func (h *UIInboxLoader) isTopUnpinnedSmallTeamInLastLayout(convID chat1.ConversationID) bool { h.lastLayoutMu.Lock() defer h.lastLayoutMu.Unlock() if h.lastLayout == nil { return false } - if len(h.lastLayout.SmallTeams) == 0 { - return false + for _, row := range h.lastLayout.SmallTeams { + if !row.IsPinned { + return row.ConvID == convID.ConvIDStr() + } } - return h.lastLayout.SmallTeams[0].ConvID == convID.ConvIDStr() + return false } func (h *UIInboxLoader) setLastLayout(l *chat1.UIInboxLayout) { @@ -805,8 +835,8 @@ func (h *UIInboxLoader) UpdateLayout(ctx context.Context, reselectMode chat1.Inb func (h *UIInboxLoader) UpdateLayoutFromNewMessage(ctx context.Context, conv types.RemoteConversation) { defer h.Trace(ctx, nil, "UpdateLayoutFromNewMessage: %s", conv.ConvIDStr)() - if h.isTopSmallTeamInLastLayout(conv.GetConvID()) { - h.Debug(ctx, "UpdateLayoutFromNewMessage: skipping layout, conv top small team in last layout") + if h.isTopUnpinnedSmallTeamInLastLayout(conv.GetConvID()) { + h.Debug(ctx, "UpdateLayoutFromNewMessage: skipping layout, conv top unpinned small team in last layout") } else if conv.GetTeamType() == chat1.TeamType_COMPLEX { h.Debug(ctx, "UpdateLayoutFromNewMessage: skipping layout, complex team conv") } else { diff --git a/go/chat/uiinboxloader_test.go b/go/chat/uiinboxloader_test.go index b7ecd6384d93..8ace4652eb35 100644 --- a/go/chat/uiinboxloader_test.go +++ b/go/chat/uiinboxloader_test.go @@ -531,3 +531,40 @@ func TestPrepareShareConversations(t *testing.T) { require.Equal(t, "id2", calls[1][0].ConvID) }) } + +func TestOrderSmallTeamRows(t *testing.T) { + row := func(id string, secs int64) chat1.UIInboxSmallTeamRow { + return chat1.UIInboxSmallTeamRow{ConvID: chat1.ConvIDStr(id), Time: gregor1.Time(secs * 1000)} + } + ids := func(rows []chat1.UIInboxSmallTeamRow) (res []string) { + for _, r := range rows { + res = append(res, string(r.ConvID)) + } + return res + } + rows := []chat1.UIInboxSmallTeamRow{row("a", 1), row("b", 5), row("c", 3), row("d", 4)} + // "zz" is not a small row and must be ignored + orderSmallTeamRows(rows, []chat1.ConvIDStr{"c", "zz", "a"}) + require.Equal(t, []string{"c", "a", "b", "d"}, ids(rows)) + require.True(t, rows[0].IsPinned) + require.True(t, rows[1].IsPinned) + require.False(t, rows[2].IsPinned) + require.False(t, rows[3].IsPinned) + + rows = []chat1.UIInboxSmallTeamRow{row("a", 1), row("b", 5)} + orderSmallTeamRows(rows, nil) + require.Equal(t, []string{"b", "a"}, ids(rows)) +} + +func TestIsTopUnpinnedSmallTeamInLastLayout(t *testing.T) { + h := &UIInboxLoader{} + convA := chat1.ConversationID([]byte{0xa}) + convB := chat1.ConversationID([]byte{0xb}) + require.False(t, h.isTopUnpinnedSmallTeamInLastLayout(convA)) + h.setLastLayout(&chat1.UIInboxLayout{SmallTeams: []chat1.UIInboxSmallTeamRow{ + {ConvID: convA.ConvIDStr(), IsPinned: true}, + {ConvID: convB.ConvIDStr()}, + }}) + require.False(t, h.isTopUnpinnedSmallTeamInLastLayout(convA)) + require.True(t, h.isTopUnpinnedSmallTeamInLastLayout(convB)) +} diff --git a/go/chat/utils/pinnedconvs.go b/go/chat/utils/pinnedconvs.go new file mode 100644 index 000000000000..684a59dfcb5f --- /dev/null +++ b/go/chat/utils/pinnedconvs.go @@ -0,0 +1,53 @@ +package utils + +import ( + "context" + "encoding/json" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/gregor1" +) + +// PinnedConvsGregorKey holds the user's pinned inbox conversations as a JSON +// array of ConvIDStr, top of the inbox first. Written by the GUI. +const PinnedConvsGregorKey = "chatPinnedConvs" + +func ParsePinnedConvs(body []byte) []chat1.ConvIDStr { + var raw []string + if err := json.Unmarshal(body, &raw); err != nil { + return nil + } + seen := make(map[string]bool, len(raw)) + res := make([]chat1.ConvIDStr, 0, len(raw)) + for _, id := range raw { + if id == "" || seen[id] { + continue + } + seen[id] = true + res = append(res, chat1.ConvIDStr(id)) + } + return res +} + +func GetPinnedConvs(ctx context.Context, g *globals.Context) ([]chat1.ConvIDStr, error) { + st, err := g.GregorState.State(ctx) + if err != nil { + return nil, err + } + cat, err := gregor1.ObjFactory{}.MakeCategory(PinnedConvsGregorKey) + if err != nil { + return nil, err + } + items, err := st.ItemsWithCategoryPrefix(cat) + if err != nil { + return nil, err + } + for _, it := range items { + // prefix match; skip any category that merely starts with the key + if it.Category().String() == PinnedConvsGregorKey { + return ParsePinnedConvs(it.Body().Bytes()), nil + } + } + return nil, nil +} diff --git a/go/chat/utils/pinnedconvs_test.go b/go/chat/utils/pinnedconvs_test.go new file mode 100644 index 000000000000..52fbbfec6bfc --- /dev/null +++ b/go/chat/utils/pinnedconvs_test.go @@ -0,0 +1,17 @@ +package utils + +import ( + "testing" + + "github.com/keybase/client/go/protocol/chat1" + "github.com/stretchr/testify/require" +) + +func TestParsePinnedConvs(t *testing.T) { + require.Nil(t, ParsePinnedConvs(nil)) + require.Nil(t, ParsePinnedConvs([]byte("not json"))) + require.Nil(t, ParsePinnedConvs([]byte(`{"a":1}`))) + require.Equal(t, []chat1.ConvIDStr{"aa", "bb"}, + ParsePinnedConvs([]byte(`["aa","","bb","aa"]`))) + require.Empty(t, ParsePinnedConvs([]byte(`[]`))) +} diff --git a/go/protocol/chat1/chat_ui.go b/go/protocol/chat1/chat_ui.go index c1931f2ec8ff..927f13e1289a 100644 --- a/go/protocol/chat1/chat_ui.go +++ b/go/protocol/chat1/chat_ui.go @@ -41,6 +41,7 @@ type UIInboxSmallTeamRow struct { Draft *string `codec:"draft,omitempty" json:"draft,omitempty"` IsMuted bool `codec:"isMuted" json:"isMuted"` IsTeam bool `codec:"isTeam" json:"isTeam"` + IsPinned bool `codec:"isPinned" json:"isPinned"` } func (o UIInboxSmallTeamRow) DeepCopy() UIInboxSmallTeamRow { @@ -64,8 +65,9 @@ func (o UIInboxSmallTeamRow) DeepCopy() UIInboxSmallTeamRow { tmp := (*x) return &tmp })(o.Draft), - IsMuted: o.IsMuted, - IsTeam: o.IsTeam, + IsMuted: o.IsMuted, + IsTeam: o.IsTeam, + IsPinned: o.IsPinned, } } diff --git a/go/service/chat_pinned_convs_handler.go b/go/service/chat_pinned_convs_handler.go new file mode 100644 index 000000000000..5577adbea191 --- /dev/null +++ b/go/service/chat_pinned_convs_handler.go @@ -0,0 +1,46 @@ +package service + +import ( + "context" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/chat/utils" + "github.com/keybase/client/go/gregor" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/gregor1" +) + +// chatPinnedConvsGregorHandler rebuilds the inbox layout when the pinned +// conversation list changes on any device. +type chatPinnedConvsGregorHandler struct { + globals.Contextified +} + +var _ libkb.GregorInBandMessageHandler = (*chatPinnedConvsGregorHandler)(nil) + +func newChatPinnedConvsGregorHandler(g *globals.Context) *chatPinnedConvsGregorHandler { + return &chatPinnedConvsGregorHandler{Contextified: globals.NewContextified(g)} +} + +func (h *chatPinnedConvsGregorHandler) handle(ctx context.Context, category string) bool { + if category != utils.PinnedConvsGregorKey { + return false + } + if loader := h.G().UIInboxLoader; loader != nil { + loader.UpdateLayout(ctx, chat1.InboxLayoutReselectMode_DEFAULT, "pinned convs changed") + } + return true +} + +func (h *chatPinnedConvsGregorHandler) Create(ctx context.Context, _ gregor1.IncomingInterface, category string, _ gregor.Item) (bool, error) { + return h.handle(ctx, category), nil +} + +func (h *chatPinnedConvsGregorHandler) Dismiss(ctx context.Context, _ gregor1.IncomingInterface, category string, _ gregor.Item) (bool, error) { + return h.handle(ctx, category), nil +} + +func (h *chatPinnedConvsGregorHandler) IsAlive() bool { return true } + +func (h *chatPinnedConvsGregorHandler) Name() string { return "chatPinnedConvsGregorHandler" } diff --git a/go/service/main.go b/go/service/main.go index e780fb65be58..5e40dda69d5c 100644 --- a/go/service/main.go +++ b/go/service/main.go @@ -695,6 +695,7 @@ func (d *Service) startupGregor() { d.gregor.PushHandler(newPhoneNumbersGregorHandler(d.G())) d.gregor.PushHandler(newEmailsGregorHandler(d.G())) d.gregor.PushHandler(newKBFSFavoritesHandler(d.G())) + d.gregor.PushHandler(newChatPinnedConvsGregorHandler(globals.NewContext(d.G(), d.ChatG()))) // Connect to gregord if gcErr := d.tryGregordConnect(); gcErr != nil { diff --git a/protocol/avdl/chat1/chat_ui.avdl b/protocol/avdl/chat1/chat_ui.avdl index 054096969769..d22790dfd8b7 100644 --- a/protocol/avdl/chat1/chat_ui.avdl +++ b/protocol/avdl/chat1/chat_ui.avdl @@ -26,6 +26,7 @@ protocol chatUi { union { null, string } draft; boolean isMuted; boolean isTeam; + boolean isPinned; } enum UIInboxBigTeamRowTyp { diff --git a/protocol/json/chat1/chat_ui.json b/protocol/json/chat1/chat_ui.json index 8d481c46ee3c..c5c5ae437a73 100644 --- a/protocol/json/chat1/chat_ui.json +++ b/protocol/json/chat1/chat_ui.json @@ -97,6 +97,10 @@ { "type": "boolean", "name": "isTeam" + }, + { + "type": "boolean", + "name": "isPinned" } ] }, diff --git a/shared/chat/inbox/pinned-convs.test.tsx b/shared/chat/inbox/pinned-convs.test.tsx new file mode 100644 index 000000000000..414de644dd47 --- /dev/null +++ b/shared/chat/inbox/pinned-convs.test.tsx @@ -0,0 +1,35 @@ +/// +import {expect, test} from '@jest/globals' +import type * as T from '@/constants/types' +import {getPinnedConvIDs, pinToTop, pruneToLayout, unpin} from './pinned-convs' + +const enc = (s: string) => new TextEncoder().encode(s) +const item = (category: string, body: string) => + ({item: {body: enc(body), category}}) as unknown as {item: T.RPCGen.Gregor1.Item} + +test('getPinnedConvIDs reads the category and ignores junk', () => { + expect(getPinnedConvIDs(undefined)).toEqual([]) + expect(getPinnedConvIDs([item('other', '["a"]')])).toEqual([]) + expect(getPinnedConvIDs([item('chatPinnedConvs', 'nope')])).toEqual([]) + expect(getPinnedConvIDs([item('chatPinnedConvs', '["a",1,"b"]')])).toEqual(['a', 'b']) +}) + +test('pinToTop prepends and moves existing', () => { + expect(pinToTop([], 'a')).toEqual(['a']) + expect(pinToTop(['b', 'c'], 'a')).toEqual(['a', 'b', 'c']) + expect(pinToTop(['b', 'a', 'c'], 'a')).toEqual(['a', 'b', 'c']) +}) + +test('unpin removes', () => { + expect(unpin(['a', 'b'], 'a')).toEqual(['b']) + expect(unpin(['b'], 'a')).toEqual(['b']) +}) + +test('pruneToLayout keeps only ids the layout marks pinned', () => { + const rows = [ + {convID: 'a', isPinned: true}, + {convID: 'b', isPinned: false}, + ] as unknown as ReadonlyArray + expect(pruneToLayout(['gone', 'b', 'a'], rows)).toEqual(['a']) + expect(pruneToLayout(['a'], undefined)).toEqual(['a']) +}) diff --git a/shared/chat/inbox/pinned-convs.tsx b/shared/chat/inbox/pinned-convs.tsx new file mode 100644 index 000000000000..dd6c91b9fda4 --- /dev/null +++ b/shared/chat/inbox/pinned-convs.tsx @@ -0,0 +1,65 @@ +import * as C from '@/constants' +import * as T from '@/constants/types' +import * as React from 'react' +import logger from '@/logger' +import {bodyToJSON} from '@/constants/rpc-utils' +import {useConfigState} from '@/stores/config' +import {useInboxLayoutState} from './layout-state' + +export const pinnedConvsGregorKey = 'chatPinnedConvs' + +type GregorItems = ReadonlyArray<{readonly item?: T.RPCGen.Gregor1.Item | null}> | null | undefined + +export const getPinnedConvIDs = (items: GregorItems): ReadonlyArray => { + const found = items?.find(i => i.item?.category === pinnedConvsGregorKey) + const parsed = bodyToJSON(found?.item?.body) + return Array.isArray(parsed) + ? parsed.filter((id): id is T.Chat.ConversationIDKey => typeof id === 'string' && id.length > 0) + : [] +} + +export const usePinnedConvIDs = () => { + const gregorPushState = useConfigState(s => s.gregorPushState) + return React.useMemo(() => getPinnedConvIDs(gregorPushState), [gregorPushState]) +} + +export const pruneToLayout = ( + list: ReadonlyArray, + smallTeams: ReadonlyArray | null | undefined +) => { + if (!smallTeams) return [...list] + const pinned = new Set(smallTeams.filter(r => r.isPinned).map(r => r.convID as string)) + return list.filter(id => pinned.has(id)) +} + +export const pinToTop = (list: ReadonlyArray, id: string) => [id, ...list.filter(i => i !== id)] + +export const unpin = (list: ReadonlyArray, id: string) => list.filter(i => i !== id) + +export const setConversationPinned = (id: T.Chat.ConversationIDKey, pinned: boolean) => { + const f = async () => { + const current = getPinnedConvIDs(useConfigState.getState().gregorPushState) + const smallTeams = useInboxLayoutState.getState().layout?.smallTeams + const pruned = pruneToLayout(current, smallTeams) + const next = pinned ? pinToTop(pruned, id) : unpin(pruned, id) + try { + await T.RPCGen.gregorUpdateCategoryRpcPromise({ + body: JSON.stringify(next), + category: pinnedConvsGregorKey, + dtime: {offset: 0, time: 0}, + }) + } catch (error) { + logger.warn(`setConversationPinned: saving pinned convs failed: ${String(error)}`) + return + } + try { + // the gregor handler also rebuilds, but this one doesn't wait on the push round trip + await T.RPCChat.localRequestInboxLayoutRpcPromise({ + reselectMode: T.RPCChat.InboxLayoutReselectMode.default, + }) + } catch (error) { + logger.warn(`setConversationPinned: layout refresh failed: ${String(error)}`) + } + } + C.ignorePromise(f()) +} diff --git a/shared/chat/inbox/rows-state.test.ts b/shared/chat/inbox/rows-state.test.ts index 95dedbb150b6..50baeef52a29 100644 --- a/shared/chat/inbox/rows-state.test.ts +++ b/shared/chat/inbox/rows-state.test.ts @@ -121,6 +121,7 @@ test('layout fills gaps until a trusted meta wins; participant store overrides n convID: T.Chat.conversationIDKeyToString(convID), draft: '', isMuted: true, + isPinned: false, isTeam: false, lastSendTime: 0, name: 'alice,bob', @@ -189,6 +190,7 @@ test('useInboxRowIsMuted follows the same layout/meta precedence as the full row convID: T.Chat.conversationIDKeyToString(convID), draft: '', isMuted: true, + isPinned: false, isTeam: false, lastSendTime: 0, name: 'alice,bob', diff --git a/shared/chat/inbox/rows.test.tsx b/shared/chat/inbox/rows.test.tsx index 3be7889f7042..8e300b105191 100644 --- a/shared/chat/inbox/rows.test.tsx +++ b/shared/chat/inbox/rows.test.tsx @@ -6,6 +6,7 @@ const smallTeamRow = (convID: string, name = convID): T.RPCChat.UIInboxSmallTeam convID: convID as T.RPCChat.ConvIDStr, draft: null, isMuted: false, + isPinned: false, isTeam: true, lastSendTime: 0, name, diff --git a/shared/constants/rpc/rpc-chat-gen.tsx b/shared/constants/rpc/rpc-chat-gen.tsx index ef96791cd603..0bfb10c257b4 100644 --- a/shared/constants/rpc/rpc-chat-gen.tsx +++ b/shared/constants/rpc/rpc-chat-gen.tsx @@ -1545,7 +1545,7 @@ export type UIInboxBigTeamLabelRow = {readonly name: string,readonly id: TLFIDSt export type UIInboxBigTeamRow ={ state: UIInboxBigTeamRowTyp.label, label: UIInboxBigTeamLabelRow } | { state: UIInboxBigTeamRowTyp.channel, channel: UIInboxBigTeamChannelRow } export type UIInboxLayout = {readonly totalSmallTeams: number,readonly smallTeams?: ReadonlyArray | null,readonly bigTeams?: ReadonlyArray | null,readonly reselectInfo?: UIInboxReselectInfo | null,readonly widgetList?: ReadonlyArray | null,} export type UIInboxReselectInfo = {readonly oldConvID: ConvIDStr,readonly newConvID?: ConvIDStr | null,} -export type UIInboxSmallTeamRow = {readonly convID: ConvIDStr,readonly name: string,readonly time: Gregor1.Time,readonly lastSendTime: Gregor1.Time,readonly snippet?: string | null,readonly snippetDecoration: SnippetDecoration,readonly draft?: string | null,readonly isMuted: boolean,readonly isTeam: boolean,} +export type UIInboxSmallTeamRow = {readonly convID: ConvIDStr,readonly name: string,readonly time: Gregor1.Time,readonly lastSendTime: Gregor1.Time,readonly snippet?: string | null,readonly snippetDecoration: SnippetDecoration,readonly draft?: string | null,readonly isMuted: boolean,readonly isTeam: boolean,readonly isPinned: boolean,} export type UILinkDecoration = {readonly url: string,readonly punycode: string,} export type UIMaybeMentionInfo ={ status: UIMaybeMentionStatus.unknown } | { status: UIMaybeMentionStatus.user } | { status: UIMaybeMentionStatus.team, team: UITeamMention } | { status: UIMaybeMentionStatus.nothing } export type UIMessage ={ state: MessageUnboxedState.valid, valid: UIMessageValid } | { state: MessageUnboxedState.error, error: MessageUnboxedError } | { state: MessageUnboxedState.outbox, outbox: UIMessageOutbox } | { state: MessageUnboxedState.placeholder, placeholder: MessageUnboxedPlaceholder } | { state: MessageUnboxedState.journeycard, journeycard: UIMessageJourneycard } From 365ed7eee4e099d9b8ebcec5cf080094bd522cea Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 09:16:16 -0400 Subject: [PATCH 2/7] chat: pin/unpin inbox conversations from the row menu --- shared/chat/conversation/info-panel/menu.tsx | 37 ++++++++++++++++++-- shared/chat/inbox/row/small-team/index.tsx | 8 ++++- shared/chat/inbox/rows-state.tsx | 3 ++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/shared/chat/conversation/info-panel/menu.tsx b/shared/chat/conversation/info-panel/menu.tsx index 925993fe1f13..18e2eb05a7fb 100644 --- a/shared/chat/conversation/info-panel/menu.tsx +++ b/shared/chat/conversation/info-panel/menu.tsx @@ -13,6 +13,8 @@ import {makeAddMembersWizard} from '@/teams/add-members-wizard/state' import {hexToUint8Array} from '@/util/uint8array' import {hideConversation, joinConversation, muteConversation} from '../status-actions' import {useConversationMarkAsUnread, useConversationMetadata} from '../data-hooks' +import {useInboxRowIsPinned} from '@/chat/inbox/rows-state' +import {setConversationPinned, usePinnedConvIDs} from '@/chat/inbox/pinned-convs' const isHexBytes = (s: string) => s.length > 0 && s.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(s) @@ -23,6 +25,7 @@ export type OwnProps = { floatingMenuContainerStyle?: Kb.Styles.StylesCrossPlatform hasHeader: boolean isSmallTeam: boolean + showPinItems?: boolean teamID?: T.Teams.TeamID visible: boolean } @@ -92,13 +95,16 @@ const useData = (p: { const InfoPanelMenuConnector = function InfoPanelMenuConnector(p: OwnProps) { const styles = useStyles() const {attachTo, onHidden, floatingMenuContainerStyle, hasHeader} = p - const {isSmallTeam, teamID: pteamID} = p + const {isSmallTeam, teamID: pteamID, showPinItems} = p const conversationIDKey = p.conversationIDKey ?? Chat.noConversationIDKey const data = useData({conversationIDKey, isSmallTeam, pteamID}) const {teamname, teamID, channelname, isInChannel, ignored, fullname} = data const {manageChannelsSubtitle, manageChannelsTitle, participants, teamType, isMuted} = data + const isPinned = useInboxRowIsPinned(conversationIDKey) + const pinnedConvIDs = usePinnedConvIDs() + const {yourOperations} = useChatTeam(teamID, teamname) const {dismiss: dismissManageChannelsBadge, showBadge: badgeSubscribe} = useChatManageChannelsBadge( teamID, @@ -292,7 +298,34 @@ const InfoPanelMenuConnector = function InfoPanelMenuConnector(p: OwnProps) { } } - const items: Kb.MenuItems = [] + const pinItems: Kb.MenuItems = [] + if (showPinItems && conversationIDKey !== Chat.noConversationIDKey) { + if (isPinned) { + if (pinnedConvIDs[0] !== conversationIDKey) { + pinItems.push({ + icon: 'iconfont-pin', + iconIsVisible: false, + onClick: () => setConversationPinned(conversationIDKey, true), + title: 'Move to top', + } as const) + } + pinItems.push({ + icon: 'iconfont-pin', + iconIsVisible: false, + onClick: () => setConversationPinned(conversationIDKey, false), + title: 'Unpin', + } as const) + } else { + pinItems.push({ + icon: 'iconfont-pin', + iconIsVisible: false, + onClick: () => setConversationPinned(conversationIDKey, true), + title: 'Pin to top', + } as const) + } + } + + const items: Kb.MenuItems = [...pinItems] if (isAdhoc) { if (markAsUnread) { items.push(markAsUnread) diff --git a/shared/chat/inbox/row/small-team/index.tsx b/shared/chat/inbox/row/small-team/index.tsx index 1b536526a771..88feed6d3cbc 100644 --- a/shared/chat/inbox/row/small-team/index.tsx +++ b/shared/chat/inbox/row/small-team/index.tsx @@ -10,7 +10,7 @@ import './small-team.css' import {Avatars, TeamAvatar} from '@/chat/avatars' import {formatTimeForConversationList} from '@/util/timestamp' import {useOpenedRowState} from '../opened-row-state' -import {useInboxRowSmall} from '@/chat/inbox/rows-state' +import {useInboxRowIsPinned, useInboxRowSmall} from '@/chat/inbox/rows-state' import TeamMenu from '@/chat/conversation/info-panel/menu' export type Props = { conversationIDKey: string @@ -126,6 +126,7 @@ const TopLine = (p: TopLineProps) => { const styles = useStyles() const theme = Kb.Styles.useTheme() const {isSelected, backgroundColor, conversationIDKey, participants, teamDisplayName, timestamp, hasBadge, hasUnread} = p + const isPinned = useInboxRowIsPinned(conversationIDKey) const showBold = !isSelected && hasUnread const subColor = isSelected ? theme.white @@ -180,6 +181,9 @@ const TopLine = (p: TopLineProps) => { )} + {isPinned && ( + + )} {timestampText} @@ -206,6 +210,7 @@ const TopLineGear = (p: {conversationIDKey: T.Chat.ConversationIDKey; subColor: onHidden={hidePopup} hasHeader={true} isSmallTeam={true} + showPinItems={true} /> ) } @@ -512,6 +517,7 @@ const useStyles = Kb.Styles.createStyleHook( nameContainer: { ...Kb.Styles.globalStyles.fillAbsolute, }, + pinIcon: {marginRight: Kb.Styles.globalMargins.xtiny}, rowContainer: Kb.Styles.platformStyles({ common: { ...Kb.Styles.paddingH(Kb.Styles.globalMargins.xsmall), diff --git a/shared/chat/inbox/rows-state.tsx b/shared/chat/inbox/rows-state.tsx index 4407674a31b2..d463627e018a 100644 --- a/shared/chat/inbox/rows-state.tsx +++ b/shared/chat/inbox/rows-state.tsx @@ -252,6 +252,9 @@ export const useInboxRowIsMuted = (id: string): boolean => { return !metaTrusted && layoutIsMuted !== undefined ? layoutIsMuted : metaIsMuted } +export const useInboxRowIsPinned = (id: string): boolean => + useInboxLayoutState(s => getSmallLayoutRow(s, id)?.isPinned ?? false) + export const useInboxRowBig = (id: string): InboxRowBig => { const meta = useInboxMetadataState( useShallow((s): BigRowMeta => { From fae0e0bd33d9ea8db0fb7168b03edb9dacf41185 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 09:22:46 -0400 Subject: [PATCH 3/7] chat: long press inbox row on mobile opens conversation menu --- shared/chat/inbox/row/small-team/index.tsx | 103 +++++++++++------- .../small-team/swipe-conv-actions/index.tsx | 12 +- 2 files changed, 70 insertions(+), 45 deletions(-) diff --git a/shared/chat/inbox/row/small-team/index.tsx b/shared/chat/inbox/row/small-team/index.tsx index 88feed6d3cbc..fe93a9300b64 100644 --- a/shared/chat/inbox/row/small-team/index.tsx +++ b/shared/chat/inbox/row/small-team/index.tsx @@ -26,6 +26,22 @@ const SmallTeam = (p: Props) => { const row = useInboxRowSmall(conversationIDKey) const setOpenedRow = useOpenedRowState(s => s.dispatch.setOpenRow) + const makePopup = (mp: Kb.Popup2Parms) => { + const {attachTo, hidePopup} = mp + return ( + + ) + } + const {showingPopup, showPopup, popup, popupAnchor} = Kb.usePopup2(makePopup) + const {isMuted, isLocked, draft: rawDraft, teamDisplayName, hasBadge, hasUnread} = row const {hasResetUsers, youNeedToRekey, youAreReset, participantNeedToRekey, participants} = row const {snippet, snippetDecoration, typingSnippet, timestamp, isDecryptingSnippet} = row @@ -39,6 +55,12 @@ const SmallTeam = (p: Props) => { setOpenedRow(Chat.noConversationIDKey) C.Router2.navigateToThread(conversationIDKey, 'inboxSmall') })) + const onLongPress = isMobile + ? () => { + setOpenedRow(Chat.noConversationIDKey) + showPopup() + } + : undefined const backgroundColor = isSelected ? theme.blue @@ -77,6 +99,8 @@ const SmallTeam = (p: Props) => { hasUnread={hasUnread} isSelected={isSelected} backgroundColor={backgroundColor} + showPopup={showPopup} + popupAnchor={popupAnchor} /> { ) return ( - - {isMobile ? ( - - {rowContents} - - ) : ( - - {rowContents} - - )} - + <> + {showingPopup && popup} + + {isMobile ? ( + + {rowContents} + + ) : ( + + {rowContents} + + )} + + ) } @@ -120,12 +147,15 @@ type TopLineProps = { hasUnread: boolean isSelected: boolean backgroundColor?: string + showPopup: () => void + popupAnchor: React.RefObject } const TopLine = (p: TopLineProps) => { const styles = useStyles() const theme = Kb.Styles.useTheme() - const {isSelected, backgroundColor, conversationIDKey, participants, teamDisplayName, timestamp, hasBadge, hasUnread} = p + const {isSelected, backgroundColor, conversationIDKey, participants, teamDisplayName, timestamp} = p + const {hasBadge, hasUnread, showPopup, popupAnchor} = p const isPinned = useInboxRowIsPinned(conversationIDKey) const showBold = !isSelected && hasUnread const subColor = isSelected @@ -188,46 +218,35 @@ const TopLine = (p: TopLineProps) => { {timestampText} {!isMobile && ( - + )} {hasBadge ? : null} ) } -const TopLineGear = (p: {conversationIDKey: T.Chat.ConversationIDKey; subColor: string; isSelected: boolean}) => { +type TopLineGearProps = { + subColor: string + isSelected: boolean + showPopup: () => void + popupAnchor: React.RefObject +} + +const TopLineGear = (p: TopLineGearProps) => { const styles = useStyles() const theme = Kb.Styles.useTheme() - const {conversationIDKey, subColor, isSelected} = p + const {subColor, isSelected, showPopup, popupAnchor} = p const iconHoverColor = isSelected ? theme.white_75 : theme.black - const makePopup = (mp: Kb.Popup2Parms) => { - const {attachTo, hidePopup} = mp - return ( - - ) - } - const {showingPopup, showPopup, popup, popupAnchor} = Kb.usePopup2(makePopup) return ( - <> - {showingPopup && popup} - - - - + + + ) } diff --git a/shared/chat/inbox/row/small-team/swipe-conv-actions/index.tsx b/shared/chat/inbox/row/small-team/swipe-conv-actions/index.tsx index dc8baccf025d..a25418d00489 100644 --- a/shared/chat/inbox/row/small-team/swipe-conv-actions/index.tsx +++ b/shared/chat/inbox/row/small-team/swipe-conv-actions/index.tsx @@ -10,6 +10,7 @@ type Props = { children: React.ReactNode conversationIDKey: ConversationIDKey onPress?: () => void + onLongPress?: () => void } import Swipeable, {type SwipeableMethods} from '@/common-adapters/swipeable-row' import {useOpenedRowState} from '../../opened-row-state' @@ -78,7 +79,7 @@ function SwipeConvActions(p: Props) { return
{p.children}
} - const {children, onPress} = p + const {children, onPress, onLongPress} = p const closeOpenedRow = () => { if (isOpened) { @@ -136,8 +137,13 @@ function SwipeConvActions(p: Props) { ) } - const inner = onPress ? ( - + const inner = onPress || onLongPress ? ( + {children} From 55273da2de62056b0822475d5612f3ce6cb7607a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 09:31:53 -0400 Subject: [PATCH 4/7] chat: keep rapid pin changes from dropping earlier pins Read the pinned list fresh from the service, prune only IDs missing from the layout, and serialize writes. Base Move to top visibility on the layout instead of the debounced gregor push state. --- shared/chat/conversation/info-panel/menu.tsx | 8 +- shared/chat/inbox/pinned-convs.test.tsx | 5 +- shared/chat/inbox/pinned-convs.tsx | 79 +++++++++++--------- shared/chat/inbox/rows-state.tsx | 5 ++ 4 files changed, 56 insertions(+), 41 deletions(-) diff --git a/shared/chat/conversation/info-panel/menu.tsx b/shared/chat/conversation/info-panel/menu.tsx index 18e2eb05a7fb..44ceff9b0d1e 100644 --- a/shared/chat/conversation/info-panel/menu.tsx +++ b/shared/chat/conversation/info-panel/menu.tsx @@ -13,8 +13,8 @@ import {makeAddMembersWizard} from '@/teams/add-members-wizard/state' import {hexToUint8Array} from '@/util/uint8array' import {hideConversation, joinConversation, muteConversation} from '../status-actions' import {useConversationMarkAsUnread, useConversationMetadata} from '../data-hooks' -import {useInboxRowIsPinned} from '@/chat/inbox/rows-state' -import {setConversationPinned, usePinnedConvIDs} from '@/chat/inbox/pinned-convs' +import {useInboxRowIsPinned, useInboxRowIsTopPinned} from '@/chat/inbox/rows-state' +import {setConversationPinned} from '@/chat/inbox/pinned-convs' const isHexBytes = (s: string) => s.length > 0 && s.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(s) @@ -103,7 +103,7 @@ const InfoPanelMenuConnector = function InfoPanelMenuConnector(p: OwnProps) { const {manageChannelsSubtitle, manageChannelsTitle, participants, teamType, isMuted} = data const isPinned = useInboxRowIsPinned(conversationIDKey) - const pinnedConvIDs = usePinnedConvIDs() + const isTopPinned = useInboxRowIsTopPinned(conversationIDKey) const {yourOperations} = useChatTeam(teamID, teamname) const {dismiss: dismissManageChannelsBadge, showBadge: badgeSubscribe} = useChatManageChannelsBadge( @@ -301,7 +301,7 @@ const InfoPanelMenuConnector = function InfoPanelMenuConnector(p: OwnProps) { const pinItems: Kb.MenuItems = [] if (showPinItems && conversationIDKey !== Chat.noConversationIDKey) { if (isPinned) { - if (pinnedConvIDs[0] !== conversationIDKey) { + if (!isTopPinned) { pinItems.push({ icon: 'iconfont-pin', iconIsVisible: false, diff --git a/shared/chat/inbox/pinned-convs.test.tsx b/shared/chat/inbox/pinned-convs.test.tsx index 414de644dd47..3cf0b8d7d42a 100644 --- a/shared/chat/inbox/pinned-convs.test.tsx +++ b/shared/chat/inbox/pinned-convs.test.tsx @@ -25,11 +25,12 @@ test('unpin removes', () => { expect(unpin(['b'], 'a')).toEqual(['b']) }) -test('pruneToLayout keeps only ids the layout marks pinned', () => { +test('pruneToLayout keeps ids present as any row in the layout', () => { const rows = [ {convID: 'a', isPinned: true}, {convID: 'b', isPinned: false}, ] as unknown as ReadonlyArray - expect(pruneToLayout(['gone', 'b', 'a'], rows)).toEqual(['a']) + expect(pruneToLayout(['gone', 'b', 'a'], rows)).toEqual(['b', 'a']) expect(pruneToLayout(['a'], undefined)).toEqual(['a']) + expect(pruneToLayout(['a'], null)).toEqual(['a']) }) diff --git a/shared/chat/inbox/pinned-convs.tsx b/shared/chat/inbox/pinned-convs.tsx index dd6c91b9fda4..d1e2229cc71e 100644 --- a/shared/chat/inbox/pinned-convs.tsx +++ b/shared/chat/inbox/pinned-convs.tsx @@ -1,14 +1,12 @@ import * as C from '@/constants' import * as T from '@/constants/types' -import * as React from 'react' import logger from '@/logger' import {bodyToJSON} from '@/constants/rpc-utils' -import {useConfigState} from '@/stores/config' import {useInboxLayoutState} from './layout-state' export const pinnedConvsGregorKey = 'chatPinnedConvs' -type GregorItems = ReadonlyArray<{readonly item?: T.RPCGen.Gregor1.Item | null}> | null | undefined +type GregorItems = T.RPCGen.Gregor1.State['items'] export const getPinnedConvIDs = (items: GregorItems): ReadonlyArray => { const found = items?.find(i => i.item?.category === pinnedConvsGregorKey) @@ -18,48 +16,59 @@ export const getPinnedConvIDs = (items: GregorItems): ReadonlyArray { - const gregorPushState = useConfigState(s => s.gregorPushState) - return React.useMemo(() => getPinnedConvIDs(gregorPushState), [gregorPushState]) -} - export const pruneToLayout = ( list: ReadonlyArray, smallTeams: ReadonlyArray | null | undefined ) => { if (!smallTeams) return [...list] - const pinned = new Set(smallTeams.filter(r => r.isPinned).map(r => r.convID as string)) - return list.filter(id => pinned.has(id)) + const present = new Set(smallTeams.map(r => r.convID as string)) + return list.filter(id => present.has(id)) } export const pinToTop = (list: ReadonlyArray, id: string) => [id, ...list.filter(i => i !== id)] export const unpin = (list: ReadonlyArray, id: string) => list.filter(i => i !== id) -export const setConversationPinned = (id: T.Chat.ConversationIDKey, pinned: boolean) => { - const f = async () => { - const current = getPinnedConvIDs(useConfigState.getState().gregorPushState) - const smallTeams = useInboxLayoutState.getState().layout?.smallTeams - const pruned = pruneToLayout(current, smallTeams) - const next = pinned ? pinToTop(pruned, id) : unpin(pruned, id) - try { - await T.RPCGen.gregorUpdateCategoryRpcPromise({ - body: JSON.stringify(next), - category: pinnedConvsGregorKey, - dtime: {offset: 0, time: 0}, - }) - } catch (error) { - logger.warn(`setConversationPinned: saving pinned convs failed: ${String(error)}`) - return - } - try { - // the gregor handler also rebuilds, but this one doesn't wait on the push round trip - await T.RPCChat.localRequestInboxLayoutRpcPromise({ - reselectMode: T.RPCChat.InboxLayoutReselectMode.default, - }) - } catch (error) { - logger.warn(`setConversationPinned: layout refresh failed: ${String(error)}`) - } +// Chained onto so two quick pin/unpin clicks run one after another, each reading the list the +// previous write produced, instead of both racing off the same stale snapshot. +let pinChain: Promise = Promise.resolve() + +const doSetConversationPinned = async (id: T.Chat.ConversationIDKey, pinned: boolean) => { + let items: GregorItems + try { + // Read from the service instead of the gregorPushState store: the service applies its + // local outbox before answering, so a write from the previous link in this chain is + // visible here right away, where the push-based store copy lags behind by the debounce. + items = (await T.RPCGen.gregorGetStateRpcPromise()).items + } catch (error) { + logger.warn(`setConversationPinned: fetching pinned convs failed: ${String(error)}`) + return + } + const current = getPinnedConvIDs(items) + const smallTeams = useInboxLayoutState.getState().layout?.smallTeams + const pruned = pruneToLayout(current, smallTeams) + const next = pinned ? pinToTop(pruned, id) : unpin(pruned, id) + try { + await T.RPCGen.gregorUpdateCategoryRpcPromise({ + body: JSON.stringify(next), + category: pinnedConvsGregorKey, + dtime: {offset: 0, time: 0}, + }) + } catch (error) { + logger.warn(`setConversationPinned: saving pinned convs failed: ${String(error)}`) + return } - C.ignorePromise(f()) + try { + // the gregor handler also rebuilds, but this one doesn't wait on the push round trip + await T.RPCChat.localRequestInboxLayoutRpcPromise({ + reselectMode: T.RPCChat.InboxLayoutReselectMode.default, + }) + } catch (error) { + logger.warn(`setConversationPinned: layout refresh failed: ${String(error)}`) + } +} + +export const setConversationPinned = (id: T.Chat.ConversationIDKey, pinned: boolean) => { + pinChain = pinChain.then(async () => doSetConversationPinned(id, pinned)) + C.ignorePromise(pinChain) } diff --git a/shared/chat/inbox/rows-state.tsx b/shared/chat/inbox/rows-state.tsx index d463627e018a..b10b1b2c1a90 100644 --- a/shared/chat/inbox/rows-state.tsx +++ b/shared/chat/inbox/rows-state.tsx @@ -255,6 +255,11 @@ export const useInboxRowIsMuted = (id: string): boolean => { export const useInboxRowIsPinned = (id: string): boolean => useInboxLayoutState(s => getSmallLayoutRow(s, id)?.isPinned ?? false) +// True only for the first pinned row in the layout, so "Move to top" hides off the +// authoritative layout order instead of the laggy gregor-pushed pin list. +export const useInboxRowIsTopPinned = (id: string): boolean => + useInboxLayoutState(s => s.layout?.smallTeams?.find(r => r.isPinned)?.convID === id) + export const useInboxRowBig = (id: string): InboxRowBig => { const meta = useInboxMetadataState( useShallow((s): BigRowMeta => { From c4f0833de04f4f4876418b1a657ed7d6ce552d87 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 09:39:31 -0400 Subject: [PATCH 5/7] chat: cap pinned inbox conversations at 25 --- shared/chat/conversation/info-panel/menu.tsx | 7 +++++-- shared/chat/inbox/pinned-convs.test.tsx | 10 +++++++++- shared/chat/inbox/pinned-convs.tsx | 16 +++++++++++++++- shared/chat/inbox/rows-state.tsx | 3 +++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/shared/chat/conversation/info-panel/menu.tsx b/shared/chat/conversation/info-panel/menu.tsx index 44ceff9b0d1e..8d5cc0ff29ef 100644 --- a/shared/chat/conversation/info-panel/menu.tsx +++ b/shared/chat/conversation/info-panel/menu.tsx @@ -13,8 +13,8 @@ import {makeAddMembersWizard} from '@/teams/add-members-wizard/state' import {hexToUint8Array} from '@/util/uint8array' import {hideConversation, joinConversation, muteConversation} from '../status-actions' import {useConversationMarkAsUnread, useConversationMetadata} from '../data-hooks' -import {useInboxRowIsPinned, useInboxRowIsTopPinned} from '@/chat/inbox/rows-state' -import {setConversationPinned} from '@/chat/inbox/pinned-convs' +import {useInboxPinnedCount, useInboxRowIsPinned, useInboxRowIsTopPinned} from '@/chat/inbox/rows-state' +import {maxPinnedConvs, setConversationPinned} from '@/chat/inbox/pinned-convs' const isHexBytes = (s: string) => s.length > 0 && s.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(s) @@ -104,6 +104,7 @@ const InfoPanelMenuConnector = function InfoPanelMenuConnector(p: OwnProps) { const isPinned = useInboxRowIsPinned(conversationIDKey) const isTopPinned = useInboxRowIsTopPinned(conversationIDKey) + const atPinLimit = useInboxPinnedCount() >= maxPinnedConvs const {yourOperations} = useChatTeam(teamID, teamname) const {dismiss: dismissManageChannelsBadge, showBadge: badgeSubscribe} = useChatManageChannelsBadge( @@ -317,9 +318,11 @@ const InfoPanelMenuConnector = function InfoPanelMenuConnector(p: OwnProps) { } as const) } else { pinItems.push({ + disabled: atPinLimit, icon: 'iconfont-pin', iconIsVisible: false, onClick: () => setConversationPinned(conversationIDKey, true), + subTitle: atPinLimit ? `You can pin up to ${maxPinnedConvs} conversations` : undefined, title: 'Pin to top', } as const) } diff --git a/shared/chat/inbox/pinned-convs.test.tsx b/shared/chat/inbox/pinned-convs.test.tsx index 3cf0b8d7d42a..dc5a6a04aad5 100644 --- a/shared/chat/inbox/pinned-convs.test.tsx +++ b/shared/chat/inbox/pinned-convs.test.tsx @@ -1,7 +1,7 @@ /// import {expect, test} from '@jest/globals' import type * as T from '@/constants/types' -import {getPinnedConvIDs, pinToTop, pruneToLayout, unpin} from './pinned-convs' +import {getPinnedConvIDs, maxPinnedConvs, nextPinnedList, pinToTop, pruneToLayout, unpin} from './pinned-convs' const enc = (s: string) => new TextEncoder().encode(s) const item = (category: string, body: string) => @@ -34,3 +34,11 @@ test('pruneToLayout keeps ids present as any row in the layout', () => { expect(pruneToLayout(['a'], undefined)).toEqual(['a']) expect(pruneToLayout(['a'], null)).toEqual(['a']) }) + +test('nextPinnedList refuses a new pin at the limit but allows reorder and unpin', () => { + const full = Array.from({length: maxPinnedConvs}, (_, i) => `c${i}`) + expect(nextPinnedList(full, 'new', true)).toBeUndefined() + expect(nextPinnedList(full, 'c5', true)?.[0]).toBe('c5') + expect(nextPinnedList(full, 'c5', false)).toHaveLength(maxPinnedConvs - 1) + expect(nextPinnedList(full.slice(1), 'new', true)?.[0]).toBe('new') +}) diff --git a/shared/chat/inbox/pinned-convs.tsx b/shared/chat/inbox/pinned-convs.tsx index d1e2229cc71e..c74ab653b30b 100644 --- a/shared/chat/inbox/pinned-convs.tsx +++ b/shared/chat/inbox/pinned-convs.tsx @@ -5,6 +5,8 @@ import {bodyToJSON} from '@/constants/rpc-utils' import {useInboxLayoutState} from './layout-state' export const pinnedConvsGregorKey = 'chatPinnedConvs' +// The whole list is stored in one gregor item, so keep it bounded. +export const maxPinnedConvs = 25 type GregorItems = T.RPCGen.Gregor1.State['items'] @@ -29,6 +31,14 @@ export const pinToTop = (list: ReadonlyArray, id: string) => [id, ...lis export const unpin = (list: ReadonlyArray, id: string) => list.filter(i => i !== id) +// Returns undefined when pinning a new conversation would exceed maxPinnedConvs. The menu +// disables pinning at the limit, but it reads the layout, which can lag a quick write. +export const nextPinnedList = (list: ReadonlyArray, id: string, pinned: boolean) => { + if (!pinned) return unpin(list, id) + if (!list.includes(id) && list.length >= maxPinnedConvs) return undefined + return pinToTop(list, id) +} + // Chained onto so two quick pin/unpin clicks run one after another, each reading the list the // previous write produced, instead of both racing off the same stale snapshot. let pinChain: Promise = Promise.resolve() @@ -47,7 +57,11 @@ const doSetConversationPinned = async (id: T.Chat.ConversationIDKey, pinned: boo const current = getPinnedConvIDs(items) const smallTeams = useInboxLayoutState.getState().layout?.smallTeams const pruned = pruneToLayout(current, smallTeams) - const next = pinned ? pinToTop(pruned, id) : unpin(pruned, id) + const next = nextPinnedList(pruned, id, pinned) + if (!next) { + logger.warn(`setConversationPinned: already at ${maxPinnedConvs} pinned convs`) + return + } try { await T.RPCGen.gregorUpdateCategoryRpcPromise({ body: JSON.stringify(next), diff --git a/shared/chat/inbox/rows-state.tsx b/shared/chat/inbox/rows-state.tsx index b10b1b2c1a90..0e558fd0f16b 100644 --- a/shared/chat/inbox/rows-state.tsx +++ b/shared/chat/inbox/rows-state.tsx @@ -260,6 +260,9 @@ export const useInboxRowIsPinned = (id: string): boolean => export const useInboxRowIsTopPinned = (id: string): boolean => useInboxLayoutState(s => s.layout?.smallTeams?.find(r => r.isPinned)?.convID === id) +export const useInboxPinnedCount = (): number => + useInboxLayoutState(s => s.layout?.smallTeams?.reduce((n, r) => (r.isPinned ? n + 1 : n), 0) ?? 0) + export const useInboxRowBig = (id: string): InboxRowBig => { const meta = useInboxMetadataState( useShallow((s): BigRowMeta => { From eceb74cca4df72295ce46e3eea4031f7a7797a59 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 09:53:20 -0400 Subject: [PATCH 6/7] chat: show pin icon in the corner of pinned inbox rows --- shared/chat/inbox/row/small-team/index.tsx | 25 ++++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/shared/chat/inbox/row/small-team/index.tsx b/shared/chat/inbox/row/small-team/index.tsx index fe93a9300b64..69f3bac5cfd0 100644 --- a/shared/chat/inbox/row/small-team/index.tsx +++ b/shared/chat/inbox/row/small-team/index.tsx @@ -24,6 +24,7 @@ const SmallTeam = (p: Props) => { const {conversationIDKey, isSelected} = p const row = useInboxRowSmall(conversationIDKey) + const isPinned = useInboxRowIsPinned(conversationIDKey) const setOpenedRow = useOpenedRowState(s => s.dispatch.setOpenRow) const makePopup = (mp: Kb.Popup2Parms) => { @@ -75,7 +76,15 @@ const SmallTeam = (p: Props) => { ? Kb.Styles.collapseStyles([styles.container, {backgroundColor}]) : styles.container const rowContents = ( - + + {isPinned && ( + + )} {teamDisplayName ? ( ) : ( @@ -91,7 +100,6 @@ const SmallTeam = (p: Props) => { { } type TopLineProps = { - conversationIDKey: T.Chat.ConversationIDKey participants: ReadonlyArray teamDisplayName: string timestamp: number @@ -154,9 +161,8 @@ type TopLineProps = { const TopLine = (p: TopLineProps) => { const styles = useStyles() const theme = Kb.Styles.useTheme() - const {isSelected, backgroundColor, conversationIDKey, participants, teamDisplayName, timestamp} = p + const {isSelected, backgroundColor, participants, teamDisplayName, timestamp} = p const {hasBadge, hasUnread, showPopup, popupAnchor} = p - const isPinned = useInboxRowIsPinned(conversationIDKey) const showBold = !isSelected && hasUnread const subColor = isSelected ? theme.white @@ -211,9 +217,6 @@ const TopLine = (p: TopLineProps) => { )} - {isPinned && ( - - )} {timestampText} @@ -536,7 +539,11 @@ const useStyles = Kb.Styles.createStyleHook( nameContainer: { ...Kb.Styles.globalStyles.fillAbsolute, }, - pinIcon: {marginRight: Kb.Styles.globalMargins.xtiny}, + pinIcon: { + left: Kb.Styles.globalMargins.xxtiny, + position: 'absolute', + top: Kb.Styles.globalMargins.xtiny, + }, rowContainer: Kb.Styles.platformStyles({ common: { ...Kb.Styles.paddingH(Kb.Styles.globalMargins.xsmall), From 67d601f35690e1a5d5034acf9cf61b07bb58b7e2 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 09:57:28 -0400 Subject: [PATCH 7/7] chat: rebuild inbox layout when synced pins change Go reads pins from gregor state while building the layout, but that state only exists once the service connects, and items delivered in the connect-time sync never reach the in-band handler. Watch the gregor state pushed to the GUI and request a layout when the pin list in it changes. --- shared/constants/init/shared.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 2517164b6470..c0266f11ad80 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -22,6 +22,7 @@ import {ignorePromise, timeoutPromise} from '../utils' import {isPhone, serverConfigFileName} from '../platform' import {useAvatarState} from '@/common-adapters/avatar/store' import {useInboxLayoutState} from '@/chat/inbox/layout-state' +import {getPinnedConvIDs} from '@/chat/inbox/pinned-convs' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useDaemonState, type BootstrapStep} from '@/stores/daemon' @@ -160,6 +161,20 @@ const scheduleStartupOrReloginWork = () => { ignorePromise(f()) } +// Go reads pins from gregor while building the inbox layout, but gregor state only exists once +// the service connects, and items that arrive in the connect-time sync don't reach the in-band +// handlers. The GUI gets the synced state pushed afterwards, so rebuild when the pins in it change. +const onGregorPushStateChanged = ( + pushState: ConfigState['gregorPushState'], + previous: ConfigState['gregorPushState'] +) => { + if (!useConfigState.getState().loggedIn) return + if (isEqual(getPinnedConvIDs(pushState), getPinnedConvIDs(previous))) return + ignorePromise( + T.RPCChat.localRequestInboxLayoutRpcPromise({reselectMode: T.RPCChat.InboxLayoutReselectMode.default}) + ) +} + const onGregorReachableChanged = (gregorReachable: ConfigState['gregorReachable']) => { // Re-get info about our account if you log in/we're done handshaking/became reachable if ( @@ -316,6 +331,7 @@ export const initSharedSubscriptions = (platformBootstrapSteps: Array s.gregorReachable, onGregorReachableChanged), + subscribeValue(useConfigState, s => s.gregorPushState, onGregorPushStateChanged), subscribeValue(useConfigState, s => s.loggedIn, onLoggedInChanged), subscribeValue(useConfigState, s => s.revokedTrigger, onRevokedTriggerChanged), subscribeValue(useConfigState, s => s.configuredAccounts, onConfiguredAccountsChanged)