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 }