Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
48 changes: 39 additions & 9 deletions go/chat/uiinboxloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions go/chat/uiinboxloader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
53 changes: 53 additions & 0 deletions go/chat/utils/pinnedconvs.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions go/chat/utils/pinnedconvs_test.go
Original file line number Diff line number Diff line change
@@ -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(`[]`)))
}
6 changes: 4 additions & 2 deletions go/protocol/chat1/chat_ui.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 46 additions & 0 deletions go/service/chat_pinned_convs_handler.go
Original file line number Diff line number Diff line change
@@ -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" }
1 change: 1 addition & 0 deletions go/service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions protocol/avdl/chat1/chat_ui.avdl
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ protocol chatUi {
union { null, string } draft;
boolean isMuted;
boolean isTeam;
boolean isPinned;
}

enum UIInboxBigTeamRowTyp {
Expand Down
4 changes: 4 additions & 0 deletions protocol/json/chat1/chat_ui.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 38 additions & 2 deletions shared/chat/conversation/info-panel/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -23,6 +25,7 @@ export type OwnProps = {
floatingMenuContainerStyle?: Kb.Styles.StylesCrossPlatform
hasHeader: boolean
isSmallTeam: boolean
showPinItems?: boolean
teamID?: T.Teams.TeamID
visible: boolean
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading