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/conversation/info-panel/menu.tsx b/shared/chat/conversation/info-panel/menu.tsx
index 925993fe1f13..8d5cc0ff29ef 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 {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)
@@ -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,17 @@ 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 isTopPinned = useInboxRowIsTopPinned(conversationIDKey)
+ const atPinLimit = useInboxPinnedCount() >= maxPinnedConvs
+
const {yourOperations} = useChatTeam(teamID, teamname)
const {dismiss: dismissManageChannelsBadge, showBadge: badgeSubscribe} = useChatManageChannelsBadge(
teamID,
@@ -292,7 +299,36 @@ const InfoPanelMenuConnector = function InfoPanelMenuConnector(p: OwnProps) {
}
}
- const items: Kb.MenuItems = []
+ const pinItems: Kb.MenuItems = []
+ if (showPinItems && conversationIDKey !== Chat.noConversationIDKey) {
+ if (isPinned) {
+ if (!isTopPinned) {
+ 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({
+ 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)
+ }
+ }
+
+ const items: Kb.MenuItems = [...pinItems]
if (isAdhoc) {
if (markAsUnread) {
items.push(markAsUnread)
diff --git a/shared/chat/inbox/pinned-convs.test.tsx b/shared/chat/inbox/pinned-convs.test.tsx
new file mode 100644
index 000000000000..dc5a6a04aad5
--- /dev/null
+++ b/shared/chat/inbox/pinned-convs.test.tsx
@@ -0,0 +1,44 @@
+///
+import {expect, test} from '@jest/globals'
+import type * as T from '@/constants/types'
+import {getPinnedConvIDs, maxPinnedConvs, nextPinnedList, 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 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(['b', 'a'])
+ 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
new file mode 100644
index 000000000000..c74ab653b30b
--- /dev/null
+++ b/shared/chat/inbox/pinned-convs.tsx
@@ -0,0 +1,88 @@
+import * as C from '@/constants'
+import * as T from '@/constants/types'
+import logger from '@/logger'
+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']
+
+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 pruneToLayout = (
+ list: ReadonlyArray,
+ smallTeams: ReadonlyArray | null | undefined
+) => {
+ if (!smallTeams) return [...list]
+ 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)
+
+// 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()
+
+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 = nextPinnedList(pruned, id, pinned)
+ if (!next) {
+ logger.warn(`setConversationPinned: already at ${maxPinnedConvs} pinned convs`)
+ return
+ }
+ 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)}`)
+ }
+}
+
+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/row/small-team/index.tsx b/shared/chat/inbox/row/small-team/index.tsx
index 1b536526a771..69f3bac5cfd0 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
@@ -24,8 +24,25 @@ 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) => {
+ 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 +56,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
@@ -53,7 +76,15 @@ const SmallTeam = (p: Props) => {
? Kb.Styles.collapseStyles([styles.container, {backgroundColor}])
: styles.container
const rowContents = (
-
+
+ {isPinned && (
+
+ )}
{teamDisplayName ? (
) : (
@@ -69,7 +100,6 @@ const SmallTeam = (p: Props) => {
{
hasUnread={hasUnread}
isSelected={isSelected}
backgroundColor={backgroundColor}
+ showPopup={showPopup}
+ popupAnchor={popupAnchor}
/>
{
)
return (
-
- {isMobile ? (
-
- {rowContents}
-
- ) : (
-
- {rowContents}
-
- )}
-
+ <>
+ {showingPopup && popup}
+
+ {isMobile ? (
+
+ {rowContents}
+
+ ) : (
+
+ {rowContents}
+
+ )}
+
+ >
)
}
type TopLineProps = {
- conversationIDKey: T.Chat.ConversationIDKey
participants: ReadonlyArray
teamDisplayName: string
timestamp: number
@@ -120,12 +154,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, participants, teamDisplayName, timestamp} = p
+ const {hasBadge, hasUnread, showPopup, popupAnchor} = p
const showBold = !isSelected && hasUnread
const subColor = isSelected
? theme.white
@@ -184,45 +221,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}
-
-
-
- >
+
+
+
)
}
@@ -512,6 +539,11 @@ const useStyles = Kb.Styles.createStyleHook(
nameContainer: {
...Kb.Styles.globalStyles.fillAbsolute,
},
+ 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),
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}
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-state.tsx b/shared/chat/inbox/rows-state.tsx
index 4407674a31b2..0e558fd0f16b 100644
--- a/shared/chat/inbox/rows-state.tsx
+++ b/shared/chat/inbox/rows-state.tsx
@@ -252,6 +252,17 @@ 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)
+
+// 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 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 => {
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/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)
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 }