From f3af97003130098251c7efe31e5d86c6e42d7770 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Sat, 23 May 2026 00:57:27 +0900 Subject: [PATCH] feat: add Activity view Add a cache-backed Activity view for mentions, subscribed thread replies, and unread channel activity so users can navigate Slack-style activity from the TUI. --- cmd/slk/main.go | 13 + internal/cache/activity.go | 174 +++++++++ internal/cache/activity_test.go | 81 +++++ internal/ui/activityview/model.go | 458 ++++++++++++++++++++++++ internal/ui/activityview/model_test.go | 33 ++ internal/ui/app.go | 221 +++++++++++- internal/ui/app_test.go | 47 +++ internal/ui/channelfinder/model.go | 12 +- internal/ui/channelfinder/model_test.go | 17 + internal/ui/sidebar/collapse_test.go | 4 +- internal/ui/sidebar/model.go | 107 +++++- internal/ui/sidebar/model_test.go | 17 +- internal/ui/sidebar/section_nav_test.go | 3 +- 13 files changed, 1171 insertions(+), 16 deletions(-) create mode 100644 internal/cache/activity.go create mode 100644 internal/cache/activity_test.go create mode 100644 internal/ui/activityview/model.go create mode 100644 internal/ui/activityview/model_test.go diff --git a/cmd/slk/main.go b/cmd/slk/main.go index a2eebf2..f538887 100644 --- a/cmd/slk/main.go +++ b/cmd/slk/main.go @@ -1198,6 +1198,19 @@ func run() error { } }) + app.SetActivityListFetcher(func(teamID string) tea.Msg { + wctx := router.Active() + if wctx == nil { + return nil + } + items, err := db.ListActivityItems(teamID, wctx.Client.UserID(), 100) + if err != nil { + log.Printf("Warning: ListActivityItems(%s): %v", teamID, err) + return ui.ActivityListLoadedMsg{TeamID: teamID, Items: nil} + } + return ui.ActivityListLoadedMsg{TeamID: teamID, Items: items} + }) + app.SetThreadReplySender(func(channelID, threadTS, text string) tea.Msg { wctx := router.Active() if wctx == nil { diff --git a/internal/cache/activity.go b/internal/cache/activity.go new file mode 100644 index 0000000..85ebe0b --- /dev/null +++ b/internal/cache/activity.go @@ -0,0 +1,174 @@ +package cache + +import ( + "fmt" + "sort" +) + +// ActivityItem is one row in the Activity view. It is derived entirely from +// the local cache and represents a user-relevant event in a channel. +type ActivityItem struct { + Kind string // mention | thread_reply | unread + ChannelID string + ChannelName string + ChannelType string + TS string + ThreadTS string + UserID string + Text string + Unread bool +} + +// ListActivityItems returns a cache-backed approximation of Slack's Activity +// view for the current user. It combines direct mentions, unread subscribed +// thread replies, and unread channel activity, then de-duplicates by message. +func (db *DB) ListActivityItems(workspaceID, selfUserID string, limit int) ([]ActivityItem, error) { + if limit <= 0 { + limit = 100 + } + + const q = ` +SELECT + m.ts, + COALESCE(m.thread_ts, ''), + m.channel_id, + COALESCE(c.name, ''), + COALESCE(c.type, ''), + COALESCE(m.user_id, ''), + COALESCE(m.text, ''), + COALESCE(c.last_read_ts, ''), + COALESCE(c.has_unread, 0), + COALESCE(ts.last_read, ''), + COALESCE(ts.active, 0) +FROM messages m +LEFT JOIN channels c + ON c.id = m.channel_id +LEFT JOIN thread_subscriptions ts + ON ts.workspace_id = m.workspace_id + AND ts.channel_id = m.channel_id + AND ts.thread_ts = m.thread_ts +WHERE m.workspace_id = ? + AND m.is_deleted = 0 + AND ( + m.text LIKE ? + OR c.has_unread = 1 + OR (ts.active = 1 AND m.thread_ts != '' AND m.ts != m.thread_ts) + ) +ORDER BY m.ts DESC +LIMIT ? +` + + mention := "%<@" + selfUserID + ">%" + rows, err := db.conn.Query(q, workspaceID, mention, limit*10) + if err != nil { + return nil, fmt.Errorf("listing activity items: %w", err) + } + defer rows.Close() + + byKey := map[string]ActivityItem{} + priority := map[string]int{"mention": 3, "thread_reply": 2, "unread": 1} + + for rows.Next() { + var item ActivityItem + var channelLastRead string + var channelHasUnread int + var threadLastRead string + var threadActive int + if err := rows.Scan( + &item.TS, + &item.ThreadTS, + &item.ChannelID, + &item.ChannelName, + &item.ChannelType, + &item.UserID, + &item.Text, + &channelLastRead, + &channelHasUnread, + &threadLastRead, + &threadActive, + ); err != nil { + return nil, fmt.Errorf("scanning activity row: %w", err) + } + + if item.UserID == selfUserID { + continue + } + + kind := "" + unread := false + isMention := item.Text != "" && selfUserID != "" && containsMention(item.Text, selfUserID) + isThreadReply := item.ThreadTS != "" && item.ThreadTS != item.TS + isUnreadChannelMsg := channelHasUnread == 1 && item.TS > channelLastRead && (item.ThreadTS == "" || item.ThreadTS == item.TS || false) + isUnreadThreadReply := threadActive == 1 && isThreadReply && item.TS > threadLastRead + + switch { + case isMention: + kind = "mention" + unread = channelHasUnread == 1 || isUnreadThreadReply || item.TS > channelLastRead + case isUnreadThreadReply: + kind = "thread_reply" + unread = true + case isUnreadChannelMsg: + kind = "unread" + unread = true + default: + continue + } + + item.Kind = kind + item.Unread = unread + if item.ThreadTS == "" { + item.ThreadTS = item.TS + } + + key := item.ChannelID + ":" + item.TS + if existing, ok := byKey[key]; ok { + if priority[item.Kind] > priority[existing.Kind] { + byKey[key] = item + } + continue + } + byKey[key] = item + } + if err := rows.Err(); err != nil { + return nil, err + } + + out := make([]ActivityItem, 0, len(byKey)) + for _, item := range byKey { + out = append(out, item) + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].Unread != out[j].Unread { + return out[i].Unread + } + if priority[out[i].Kind] != priority[out[j].Kind] { + return priority[out[i].Kind] > priority[out[j].Kind] + } + return out[i].TS > out[j].TS + }) + + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func containsMention(text, userID string) bool { + if text == "" || userID == "" { + return false + } + return contains(text, "<@"+userID+">") +} + +func contains(s, sub string) bool { + return len(sub) > 0 && len(s) >= len(sub) && func() bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + }() +} diff --git a/internal/cache/activity_test.go b/internal/cache/activity_test.go new file mode 100644 index 0000000..1056490 --- /dev/null +++ b/internal/cache/activity_test.go @@ -0,0 +1,81 @@ +package cache + +import "testing" + +func TestListActivityItems_MentionThreadUnreadAndPriority(t *testing.T) { + db := setupDBWithWorkspace(t) + defer db.Close() + + const selfID = "USELF" + must := func(err error) { + if err != nil { + t.Fatal(err) + } + } + + must(db.UpsertChannel(Channel{ID: "C1", WorkspaceID: "T1", Name: "general", Type: "channel", IsMember: true})) + must(db.UpsertChannel(Channel{ID: "C2", WorkspaceID: "T1", Name: "design", Type: "channel", IsMember: true})) + must(db.UpsertChannel(Channel{ID: "C3", WorkspaceID: "T1", Name: "ops", Type: "channel", IsMember: true})) + + must(db.UpdateChannelReadState("C1", "1700000000.000000", true)) + must(db.UpdateChannelReadState("C2", "1700000100.000000", false)) + must(db.UpdateChannelReadState("C3", "1700000200.000000", true)) + + // Mention should outrank thread/unread and appear once. + must(db.UpsertMessage(Message{TS: "1700000300.000000", ChannelID: "C1", WorkspaceID: "T1", UserID: "U2", Text: "hey <@USELF>", ThreadTS: ""})) + + // Self-authored mention should be excluded. + must(db.UpsertMessage(Message{TS: "1700000310.000000", ChannelID: "C1", WorkspaceID: "T1", UserID: selfID, Text: "I mentioned <@USELF>", ThreadTS: ""})) + + // Thread reply. + must(db.UpsertMessage(Message{TS: "1700000400.000000", ChannelID: "C2", WorkspaceID: "T1", UserID: "U3", Text: "parent", ThreadTS: "1700000390.000000"})) + must(db.UpsertMessage(Message{TS: "1700000410.000000", ChannelID: "C2", WorkspaceID: "T1", UserID: "U4", Text: "reply in thread", ThreadTS: "1700000390.000000"})) + must(db.UpsertThreadSubscription("T1", "C2", "1700000390.000000", "1700000405.000000", true)) + + // Unread top-level message. + must(db.UpsertMessage(Message{TS: "1700000500.000000", ChannelID: "C3", WorkspaceID: "T1", UserID: "U5", Text: "fresh unread", ThreadTS: ""})) + + items, err := db.ListActivityItems("T1", selfID, 20) + if err != nil { + t.Fatalf("ListActivityItems: %v", err) + } + + if len(items) != 3 { + t.Fatalf("want 3 activity items, got %d: %+v", len(items), items) + } + + if items[0].Kind != "mention" || items[0].TS != "1700000300.000000" { + t.Fatalf("first item = %+v, want mention at 1700000300.000000", items[0]) + } + if items[1].Kind != "thread_reply" || items[1].TS != "1700000410.000000" { + t.Fatalf("second item = %+v, want thread_reply at 1700000410.000000", items[1]) + } + if items[2].Kind != "unread" || items[2].TS != "1700000500.000000" { + t.Fatalf("third item = %+v, want unread at 1700000500.000000", items[2]) + } +} + +func TestListActivityItems_DedupPrefersMention(t *testing.T) { + db := setupDBWithWorkspace(t) + defer db.Close() + + must := func(err error) { + if err != nil { + t.Fatal(err) + } + } + must(db.UpsertChannel(Channel{ID: "C1", WorkspaceID: "T1", Name: "general", Type: "channel", IsMember: true})) + must(db.UpdateChannelReadState("C1", "1700000000.000000", true)) + must(db.UpsertMessage(Message{TS: "1700000600.000000", ChannelID: "C1", WorkspaceID: "T1", UserID: "U2", Text: "ping <@USELF>", ThreadTS: ""})) + + items, err := db.ListActivityItems("T1", "USELF", 20) + if err != nil { + t.Fatalf("ListActivityItems: %v", err) + } + if len(items) != 1 { + t.Fatalf("want 1 deduped item, got %d: %+v", len(items), items) + } + if items[0].Kind != "mention" { + t.Fatalf("kind = %q, want mention", items[0].Kind) + } +} diff --git a/internal/ui/activityview/model.go b/internal/ui/activityview/model.go new file mode 100644 index 0000000..c4a9e57 --- /dev/null +++ b/internal/ui/activityview/model.go @@ -0,0 +1,458 @@ +package activityview + +import ( + "strconv" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/gammons/slk/internal/cache" + "github.com/gammons/slk/internal/ui/messages" + "github.com/gammons/slk/internal/ui/styles" + "github.com/muesli/reflow/truncate" +) + +const ( + cardContentLines = 3 + cardStride = cardContentLines + 1 +) + +func mutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(styles.TextMuted) +} + +func unreadDotStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(styles.Primary).Bold(true) +} + +func channelNameStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(styles.Primary).Bold(true) +} + +var thickLeftBorder = lipgloss.Border{Left: "▌"} + +func borderInvisStyle() lipgloss.Style { + return lipgloss.NewStyle(). + BorderStyle(thickLeftBorder).BorderLeft(true). + BorderForeground(styles.Background). + BorderBackground(styles.Background) +} + +func borderSelectStyle(focused bool) lipgloss.Style { + return lipgloss.NewStyle(). + BorderStyle(thickLeftBorder).BorderLeft(true). + BorderForeground(styles.SelectionBorderColor(focused)). + BorderBackground(styles.SelectionTintColor(focused)). + Background(styles.SelectionTintColor(focused)) +} + +func borderFillStyle() lipgloss.Style { + return lipgloss.NewStyle().Background(styles.Background) +} + +type Model struct { + items []cache.ActivityItem + userNames map[string]string + channelNames map[string]string + selfUserID string + selected int + focused bool + yOffset int + snappedSelection int + hasSnapped bool + version int64 +} + +func New(userNames map[string]string, selfUserID string) Model { + if userNames == nil { + userNames = map[string]string{} + } + return Model{ + userNames: userNames, + channelNames: map[string]string{}, + selfUserID: selfUserID, + } +} + +func (m *Model) Version() int64 { return m.version } + +func (m *Model) dirty() { m.version++ } + +func (m *Model) SetItems(items []cache.ActivityItem) { + prevCh, prevTS, hadSel := m.selectedKey() + m.items = items + newSel := 0 + if hadSel { + for i, item := range items { + if item.ChannelID == prevCh && item.TS == prevTS { + newSel = i + break + } + } + } + m.selected = newSel + m.clampSelection() + m.hasSnapped = false + m.dirty() +} + +func (m *Model) SetUserNames(names map[string]string) { + if names == nil { + names = map[string]string{} + } + if stringMapsEqual(m.userNames, names) { + return + } + m.userNames = names + m.dirty() +} + +func (m *Model) SetChannelNames(names map[string]string) { + if names == nil { + names = map[string]string{} + } + if stringMapsEqual(m.channelNames, names) { + return + } + m.channelNames = names + m.dirty() +} + +func (m *Model) SetSelfUserID(id string) { + if m.selfUserID == id { + return + } + m.selfUserID = id + m.dirty() +} + +func (m *Model) SetFocused(f bool) { + if m.focused == f { + return + } + m.focused = f + m.dirty() +} + +func (m *Model) SelectedItem() (cache.ActivityItem, bool) { + if len(m.items) == 0 || m.selected < 0 || m.selected >= len(m.items) { + return cache.ActivityItem{}, false + } + return m.items[m.selected], true +} + +func (m *Model) selectedKey() (string, string, bool) { + item, ok := m.SelectedItem() + if !ok { + return "", "", false + } + return item.ChannelID, item.TS, true +} + +func (m *Model) SelectedIndex() int { return m.selected } + +func (m *Model) MoveDown() { + if m.selected < len(m.items)-1 { + m.selected++ + m.dirty() + } +} + +func (m *Model) MoveUp() { + if m.selected > 0 { + m.selected-- + m.dirty() + } +} + +func (m *Model) GoToTop() { + if m.selected != 0 { + m.selected = 0 + m.dirty() + } +} + +func (m *Model) GoToBottom() { + if n := len(m.items); n > 0 && m.selected != n-1 { + m.selected = n - 1 + m.dirty() + } +} + +func (m *Model) ScrollUp(n int) { + if n <= 0 { + return + } + m.yOffset -= n + if m.yOffset < 0 { + m.yOffset = 0 + } + m.hasSnapped = false + m.dirty() +} + +func (m *Model) ScrollDown(n int) { + if n <= 0 { + return + } + m.yOffset += n + m.hasSnapped = false + m.dirty() +} + +func (m *Model) ClickAt(rowY int) bool { + if rowY < 0 { + return false + } + absLine := m.yOffset + rowY + if absLine < 0 { + return false + } + if absLine%cardStride >= cardContentLines { + return false + } + idx := absLine / cardStride + if idx < 0 || idx >= len(m.items) { + return false + } + if m.selected != idx { + m.selected = idx + m.dirty() + } + return true +} + +func (m *Model) UnreadCount() int { + n := 0 + for _, item := range m.items { + if item.Unread { + n++ + } + } + return n +} + +func (m *Model) clampSelection() { + if m.selected < 0 { + m.selected = 0 + } + if n := len(m.items); n == 0 { + m.selected = 0 + } else if m.selected >= n { + m.selected = n - 1 + } +} + +func (m *Model) View(height, width int) string { + if width < 1 { + width = 1 + } + if height < 1 { + height = 1 + } + if len(m.items) == 0 { + empty := mutedStyle().Render("no activity") + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, empty) + } + lines := m.renderRows(width) + if !m.hasSnapped || m.snappedSelection != m.selected { + m.snapToSelected(height, len(lines)) + m.snappedSelection = m.selected + m.hasSnapped = true + } + maxOffset := len(lines) - height + if maxOffset < 0 { + maxOffset = 0 + } + if m.yOffset > maxOffset { + m.yOffset = maxOffset + } + if m.yOffset < 0 { + m.yOffset = 0 + } + end := m.yOffset + height + if end > len(lines) { + end = len(lines) + } + visible := lines[m.yOffset:end] + if pad := height - len(visible); pad > 0 { + filler := blankLine(width) + out := make([]string, 0, height) + out = append(out, visible...) + for i := 0; i < pad; i++ { + out = append(out, filler) + } + visible = out + } + return strings.Join(visible, "\n") +} + +func (m *Model) snapToSelected(height, totalLines int) { + start := m.selected * cardStride + end := start + cardContentLines + if end > m.yOffset+height { + m.yOffset = end - height + } + if start < m.yOffset { + m.yOffset = start + } + if m.yOffset < 0 { + m.yOffset = 0 + } + maxOffset := totalLines - height + if maxOffset < 0 { + maxOffset = 0 + } + if m.yOffset > maxOffset { + m.yOffset = maxOffset + } +} + +func (m *Model) renderRows(width int) []string { + separator := blankLine(width) + var lines []string + for i, item := range m.items { + if i > 0 { + lines = append(lines, separator) + } + lines = append(lines, m.renderCard(item, width, i == m.selected)...) + } + return lines +} + +func blankLine(width int) string { + return lipgloss.NewStyle().Width(width).Render("") +} + +func (m *Model) renderCard(item cache.ActivityItem, width int, selected bool) []string { + contentWidth := width - 1 + if contentWidth < 1 { + contentWidth = 1 + } + + header := m.renderHeader(item, contentWidth) + preview := m.renderPreview(item, contentWidth) + footer := m.renderFooter(item, contentWidth) + + borderStyle := borderInvisStyle() + fill := borderFillStyle().Width(contentWidth) + if selected { + borderStyle = borderSelectStyle(m.focused) + fill = lipgloss.NewStyle().Background(styles.SelectionTintColor(m.focused)).Width(contentWidth) + } + + headerOut := borderStyle.Render(fill.Render(header)) + previewOut := borderStyle.Render(fill.Render(preview)) + footerOut := borderStyle.Render(fill.Foreground(styles.TextMuted).Render(footer)) + return []string{headerOut, previewOut, footerOut} +} + +func (m *Model) renderHeader(item cache.ActivityItem, width int) string { + glyph := channelGlyph(item.ChannelType) + kind := kindLabel(item.Kind) + header := kind + " " + mutedStyle().Render("·") + " " + glyph + channelNameStyle().Render(item.ChannelName) + if item.Unread { + header += " " + unreadDotStyle().Render("●") + } + return clipToWidth(header, width) +} + +func (m *Model) renderPreview(item cache.ActivityItem, width int) string { + preview := messages.RenderSlackMarkdown(item.Text, m.userNames, m.channelNames) + preview = strings.ReplaceAll(preview, "\n", " ") + previewMax := width - 2 + if previewMax < 0 { + previewMax = 0 + } + return clipToWidth(" "+truncate.StringWithTail(preview, uint(previewMax), "…"), width) +} + +func (m *Model) renderFooter(item cache.ActivityItem, width int) string { + actor := m.resolveUser(item.UserID) + footer := " " + actor + " · " + formatRelTime(item.TS) + return clipToWidth(footer, width) +} + +func channelGlyph(channelType string) string { + switch channelType { + case "private": + return lipgloss.NewStyle().Foreground(styles.Warning).Render("◆ ") + case "dm", "group_dm": + return lipgloss.NewStyle().Foreground(styles.TextMuted).Render("● ") + default: + return "# " + } +} + +func kindLabel(kind string) string { + switch kind { + case "mention": + return "Mention" + case "thread_reply": + return "Thread reply" + case "unread": + return "Unread" + default: + return "Activity" + } +} + +func (m *Model) resolveUser(uid string) string { + if uid == "" { + return "" + } + if uid == m.selfUserID { + return "me" + } + if name, ok := m.userNames[uid]; ok && name != "" { + return name + } + return uid +} + +func formatRelTime(ts string) string { + if ts == "" { + return "" + } + secStr := ts + if dot := strings.IndexByte(ts, '.'); dot >= 0 { + secStr = ts[:dot] + } + sec, err := strconv.ParseInt(secStr, 10, 64) + if err != nil { + return "" + } + d := time.Since(time.Unix(sec, 0)) + switch { + case d < time.Minute: + return "now" + case d < time.Hour: + return strconv.Itoa(int(d/time.Minute)) + "m ago" + case d < 24*time.Hour: + return strconv.Itoa(int(d/time.Hour)) + "h ago" + default: + return strconv.Itoa(int(d/(24*time.Hour))) + "d ago" + } +} + +func clipToWidth(s string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(s) <= width { + return s + } + return truncate.StringWithTail(s, uint(width), "…") +} + +func stringMapsEqual(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, va := range a { + if vb, ok := b[k]; !ok || vb != va { + return false + } + } + return true +} diff --git a/internal/ui/activityview/model_test.go b/internal/ui/activityview/model_test.go new file mode 100644 index 0000000..15cc629 --- /dev/null +++ b/internal/ui/activityview/model_test.go @@ -0,0 +1,33 @@ +package activityview + +import ( + "strings" + "testing" + + "github.com/gammons/slk/internal/cache" +) + +func TestViewEmptyMentionsNoActivity(t *testing.T) { + m := New(nil, "") + out := m.View(5, 30) + if !strings.Contains(strings.ToLower(out), "no activity") { + t.Fatalf("empty view should mention no activity, got:\n%s", out) + } +} + +func TestClickAndUnreadCount(t *testing.T) { + m := New(nil, "") + m.SetItems([]cache.ActivityItem{ + {Kind: "mention", ChannelID: "C1", TS: "1.0", Text: "a", Unread: true}, + {Kind: "unread", ChannelID: "C2", TS: "2.0", Text: "b", Unread: false}, + }) + if m.UnreadCount() != 1 { + t.Fatalf("UnreadCount = %d, want 1", m.UnreadCount()) + } + if !m.ClickAt(4) { + t.Fatal("expected click on second card row to select an item") + } + if got := m.SelectedIndex(); got != 1 { + t.Fatalf("SelectedIndex = %d, want 1", got) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 27dfeb3..5ce6c66 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -26,6 +26,7 @@ import ( "github.com/gammons/slk/internal/emoji" imgpkg "github.com/gammons/slk/internal/image" "github.com/gammons/slk/internal/slack/mrkdwn" + "github.com/gammons/slk/internal/ui/activityview" "github.com/gammons/slk/internal/ui/channelfinder" "github.com/gammons/slk/internal/ui/channelpicker" "github.com/gammons/slk/internal/ui/compose" @@ -83,12 +84,14 @@ type editState struct { // View identifies which "page" the message pane is displaying. The default // is ViewChannels (a channel's message history); ViewThreads swaps the -// pane's contents for the involved-threads list. +// pane's contents for the involved-threads list; ViewActivity swaps it for +// the activity list. type View int const ( ViewChannels View = iota ViewThreads + ViewActivity ) const ( @@ -166,6 +169,10 @@ type ( // synthetic Threads sidebar row. The App switches the message pane to // the threads-list view and (re)fetches the involved-threads list. ThreadsViewActivatedMsg struct{} + // ActivityViewActivatedMsg is dispatched when the user picks the + // synthetic Activity sidebar row. The App switches the message pane to + // the activity-list view and (re)fetches the activity list. + ActivityViewActivatedMsg struct{} // ThreadsListLoadedMsg carries a freshly loaded list of involved-thread // summaries for the named workspace. The App ignores it if it doesn't // match the active team. @@ -178,12 +185,24 @@ type ( // when false (Task 10 wires the renderer). SubscriptionsAvailable bool } + // ActivityListLoadedMsg carries a freshly loaded list of activity items + // for the named workspace. The App ignores it if it doesn't match the + // active team. + ActivityListLoadedMsg struct { + TeamID string + Items []cache.ActivityItem + } // ThreadsListDirtyMsg is dispatched when something that could affect // the involved-threads list has changed (new message, mention, etc.) // and the list should be refetched. Ignored if not the active team. ThreadsListDirtyMsg struct { TeamID string } + // ActivityListDirtyMsg is dispatched when something that could affect + // the activity list has changed and the list should be refetched. + ActivityListDirtyMsg struct { + TeamID string + } ConnectionStateMsg struct { State int // 0=connecting, 1=connected, 2=disconnected } @@ -646,6 +665,10 @@ type ThreadReplySendFunc func(channelID, threadTS, text string) tea.Msg // Returns the resulting tea.Msg (typically ThreadsListLoadedMsg). type ThreadsListFetchFunc func(teamID string) tea.Msg +// ActivityListFetchFunc loads the activity list for a workspace. +// Returns the resulting tea.Msg (typically ActivityListLoadedMsg). +type ActivityListFetchFunc func(teamID string) tea.Msg + type ReactionAddFunc func(channelID, messageTS, emoji string) error type ReactionRemoveFunc func(channelID, messageTS, emoji string) error @@ -713,6 +736,7 @@ type App struct { threadPanel *thread.Model threadCompose compose.Model threadsView threadsview.Model + activityView activityview.Model // State mode Mode @@ -794,6 +818,7 @@ type App struct { threadReplySender ThreadReplySendFunc channelJoiner JoinChannelFunc threadsListFetcher ThreadsListFetchFunc + activityListFetcher ActivityListFetchFunc // channelLastReadFetcher returns the parent channel's last_read_ts // so the thread panel can render a "── new ──" boundary. Optional — // when nil, the thread panel renders without an unread boundary. @@ -1020,6 +1045,7 @@ func NewApp() *App { threadPanel: thread.New(), threadCompose: compose.New("thread"), threadsView: threadsview.New(nil, ""), + activityView: activityview.New(nil, ""), reactionPicker: reactionpicker.New(), confirmPrompt: confirmprompt.New(), mode: ModeNormal, @@ -1051,6 +1077,11 @@ func NewApp() *App { Name: "Threads", Type: "threads", Joined: true, + }, { + ID: channelfinder.ActivityViewID, + Name: "Activity", + Type: "activity", + Joined: true, }}) // Seed the statusbar hint with the configured help key label so it // stays accurate if the binding is ever changed. @@ -1160,6 +1191,12 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.threadsView.MoveDown() } cmds = append(cmds, a.openSelectedThreadCmd(true)) + } else if a.view == ViewActivity { + if up { + a.activityView.MoveUp() + } else { + a.activityView.MoveDown() + } } else { if up { a.messagepane.MoveUp() @@ -1253,6 +1290,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } break } + if a.view == ViewActivity { + panel, _, py, ok := a.panelAt(msg.X, msg.Y) + if ok && panel == PanelMessages && py >= 0 { + a.activityView.ClickAt(py) + } + break + } panel, px, py, ok := a.panelAt(msg.X, msg.Y) if ok && panel == PanelMessages && py >= 0 { // Hit-test reactions and inline images first: a click @@ -1559,9 +1603,10 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } a.cancelEdit() - // Picking a channel always exits the Threads view. + // Picking a channel always exits synthetic list views. a.view = ViewChannels a.sidebar.SetThreadsActive(false) + a.sidebar.SetActivityActive(false) a.lastOpenedChannelID = "" a.lastOpenedThreadTS = "" // Close thread panel when switching channels @@ -1932,6 +1977,10 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, c) } } + if a.activeTeamID != "" { + team := a.activeTeamID + cmds = append(cmds, func() tea.Msg { return ActivityListDirtyMsg{TeamID: team} }) + } case SendMessageMsg: // Mark in-flight regardless of whether a sender is wired — @@ -2124,6 +2173,30 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { batch = append(batch, func() tea.Msg { return fetcher(chID, threadTS) }) return a, tea.Batch(batch...) + case ThreadOpenedMsg: + a.threadVisible = true + a.statusbar.SetInThread(true) + a.focusedPanel = PanelThread + a.threadPanel.SetThread(msg.ParentMsg, nil, msg.ChannelID, msg.ThreadTS) + a.threadCompose.SetChannel("thread") + a.applyThreadUnreadBoundary(msg.ChannelID) + if a.threadFetcher != nil { + fetcher := a.threadFetcher + chID := msg.ChannelID + ts := msg.ThreadTS + var batch []tea.Cmd + if a.threadCacheReader != nil { + if cached := a.threadCacheReader(chID, ts); len(cached) > 1 { + replies := cached[1:] + batch = append(batch, func() tea.Msg { + return ThreadRepliesLoadedMsg{ThreadTS: ts, Replies: replies} + }) + } + } + batch = append(batch, func() tea.Msg { return fetcher(chID, ts) }) + return a, tea.Batch(batch...) + } + case ThreadRepliesLoadedMsg: if a.threadVisible && msg.ThreadTS == a.threadPanel.ThreadTS() { channelID := a.threadPanel.ChannelID() @@ -2166,6 +2239,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ThreadsViewActivatedMsg: a.view = ViewThreads a.sidebar.SetThreadsActive(true) + a.sidebar.SetActivityActive(false) a.focusedPanel = PanelMessages if a.threadsListFetcher != nil && a.activeTeamID != "" { fetcher := a.threadsListFetcher @@ -2178,6 +2252,17 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } + case ActivityViewActivatedMsg: + a.view = ViewActivity + a.sidebar.SetThreadsActive(false) + a.sidebar.SetActivityActive(true) + a.focusedPanel = PanelMessages + if a.activityListFetcher != nil && a.activeTeamID != "" { + fetcher := a.activityListFetcher + team := a.activeTeamID + cmds = append(cmds, func() tea.Msg { return fetcher(team) }) + } + case ThreadsListLoadedMsg: if msg.TeamID == a.activeTeamID { a.threadsView.SetSummaries(msg.Summaries) @@ -2193,6 +2278,12 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + case ActivityListLoadedMsg: + if msg.TeamID == a.activeTeamID { + a.activityView.SetItems(msg.Items) + a.sidebar.SetActivityUnreadCount(a.activityView.UnreadCount()) + } + case ThreadsListDirtyMsg: if msg.TeamID == a.activeTeamID && a.threadsListFetcher != nil { fetcher := a.threadsListFetcher @@ -2200,6 +2291,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, func() tea.Msg { return fetcher(team) }) } + case ActivityListDirtyMsg: + if msg.TeamID == a.activeTeamID && a.activityListFetcher != nil { + fetcher := a.activityListFetcher + team := a.activeTeamID + cmds = append(cmds, func() tea.Msg { return fetcher(team) }) + } + case SendThreadReplyMsg: a.markSelfSendInFlight(msg.ChannelID) // Instant-display: append an optimistic placeholder to the @@ -2385,8 +2483,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // at all. a.view = ViewChannels a.sidebar.SetThreadsActive(false) + a.sidebar.SetActivityActive(false) a.threadsView.SetSummaries(nil) a.sidebar.SetThreadsUnreadCount(0) + a.activityView.SetItems(nil) + a.sidebar.SetActivityUnreadCount(0) a.lastOpenedChannelID = "" a.lastOpenedThreadTS = "" a.CloseThread() @@ -2449,20 +2550,30 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.messagepane.SetLoading(false) a.messagepane.SetMessages(nil) } - // Kick off an initial threads-list fetch so the sidebar Threads - // row badge populates before the user opens the view. + // Kick off initial synthetic-view fetches so sidebar badges populate + // before the user opens those views. if a.threadsListFetcher != nil { fetcher := a.threadsListFetcher team := msg.TeamID cmds = append(cmds, func() tea.Msg { return fetcher(team) }) } + if a.activityListFetcher != nil { + fetcher := a.activityListFetcher + team := msg.TeamID + cmds = append(cmds, func() tea.Msg { return fetcher(team) }) + } case ReadStateChangedMsg: // Persistent read state changed in the cache. Invalidate the // sidebar and refresh the workspace rail so both re-read // from the DB. a.notifyReadStateChanged() - return a, nil + if msg.WorkspaceID == a.activeTeamID && a.activityListFetcher != nil { + fetcher := a.activityListFetcher + team := a.activeTeamID + cmds = append(cmds, func() tea.Msg { return fetcher(team) }) + } + return a, tea.Batch(cmds...) case ConversationOpenedMsg: if msg.TeamID == a.activeTeamID { @@ -2515,8 +2626,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.bootstrapActiveClaimed = true a.view = ViewChannels a.sidebar.SetThreadsActive(false) + a.sidebar.SetActivityActive(false) a.threadsView.SetSummaries(nil) a.sidebar.SetThreadsUnreadCount(0) + a.activityView.SetItems(nil) + a.sidebar.SetActivityUnreadCount(0) a.lastOpenedChannelID = "" a.lastOpenedThreadTS = "" // Apply the resolved theme for the initial active workspace. @@ -2568,6 +2682,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { team := msg.TeamID cmds = append(cmds, func() tea.Msg { return fetcher(team) }) } + if a.activityListFetcher != nil { + fetcher := a.activityListFetcher + team := msg.TeamID + cmds = append(cmds, func() tea.Msg { return fetcher(team) }) + } case CustomEmojisLoadedMsg: if msg.TeamID == a.activeTeamID { @@ -3303,12 +3422,15 @@ func (a *App) handleChannelFinderMode(msg tea.KeyMsg) tea.Cmd { if result != nil { a.channelFinder.Close() a.SetMode(ModeNormal) - // Synthetic destinations (e.g. Threads view) live alongside + // Synthetic destinations (e.g. Threads or Activity view) live alongside // channels in the finder but route to a view activation rather // than a channel switch. if result.Type == "threads" { return func() tea.Msg { return ThreadsViewActivatedMsg{} } } + if result.Type == "activity" { + return func() tea.Msg { return ActivityViewActivatedMsg{} } + } // Already-joined: switch immediately. Not joined: kick off a join // command; ChannelJoinedMsg will fold the channel into the sidebar // and switch to it. @@ -3844,6 +3966,10 @@ func (a *App) handleDown() tea.Cmd { // don't fire one conversations.replies call per row. return a.openSelectedThreadCmd(true) } + if a.view == ViewActivity { + a.activityView.MoveDown() + return nil + } a.messagepane.MoveDown() case PanelThread: a.threadPanel.MoveDown() @@ -3861,6 +3987,10 @@ func (a *App) handleUp() tea.Cmd { // k: same debounce as j — see handleDown. return a.openSelectedThreadCmd(true) } + if a.view == ViewActivity { + a.activityView.MoveUp() + return nil + } a.messagepane.MoveUp() // If at top, fetch older messages if a.messagepane.AtTop() && !a.fetchingOlder && a.olderMessagesFetcher != nil { @@ -3897,6 +4027,10 @@ func (a *App) handleGoToBottom() tea.Cmd { // G is a one-shot jump — fire the fetch immediately. return a.openSelectedThreadCmd(false) } + if a.view == ViewActivity { + a.activityView.GoToBottom() + return nil + } a.messagepane.GoToBottom() case PanelThread: a.threadPanel.GoToBottom() @@ -3994,6 +4128,16 @@ func (a *App) scrollFocusedPanel(delta int) { a.threadsView.MoveDown() } } + } else if a.view == ViewActivity { + if delta < 0 { + for i := 0; i < steps; i++ { + a.activityView.MoveUp() + } + } else { + for i := 0; i < steps; i++ { + a.activityView.MoveDown() + } + } } else { if delta < 0 { for i := 0; i < steps; i++ { @@ -4035,6 +4179,9 @@ func (a *App) handleEnter() tea.Cmd { if a.sidebar.IsThreadsSelected() { return func() tea.Msg { return ThreadsViewActivatedMsg{} } } + if a.sidebar.IsActivitySelected() { + return func() tea.Msg { return ActivityViewActivatedMsg{} } + } // A section header? Toggle its collapse state and stay in // place. Section headers are also navigable via j/k so the // user can expand/collapse the firehose Channels section @@ -4076,6 +4223,36 @@ func (a *App) handleEnter() tea.Cmd { a.focusedPanel = PanelThread return cmd } + if a.focusedPanel == PanelMessages && a.view == ViewActivity { + item, ok := a.activityView.SelectedItem() + if !ok { + return nil + } + if item.ChannelID == "" { + return nil + } + a.sidebar.SelectByID(item.ChannelID) + if item.Kind == "thread_reply" && item.ThreadTS != "" { + return tea.Sequence( + func() tea.Msg { + return ChannelSelectedMsg{ID: item.ChannelID, Name: item.ChannelName, Type: item.ChannelType} + }, + func() tea.Msg { + parent := messages.MessageItem{ + TS: item.ThreadTS, + UserID: item.UserID, + UserName: a.userNameFor(item.UserID), + Text: item.Text, + ThreadTS: item.ThreadTS, + } + return ThreadOpenedMsg{ChannelID: item.ChannelID, ThreadTS: item.ThreadTS, ParentMsg: parent} + }, + ) + } + return func() tea.Msg { + return ChannelSelectedMsg{ID: item.ChannelID, Name: item.ChannelName, Type: item.ChannelType} + } + } if a.focusedPanel == PanelMessages { msg, ok := a.messagepane.SelectedMessage() @@ -4477,6 +4654,7 @@ func (a *App) SetChannels(items []sidebar.ChannelItem) { a.messagepane.SetChannelNames(names) a.threadPanel.SetChannelNames(names) a.threadsView.SetChannelNames(names) + a.activityView.SetChannelNames(names) } // SetChannelFetcher sets the callback used to load messages when a channel is selected. @@ -4625,6 +4803,12 @@ func (a *App) SetThreadsListFetcher(f ThreadsListFetchFunc) { a.threadsListFetcher = f } +// SetActivityListFetcher wires the function that loads the activity list +// for a workspace. Called by main.go. +func (a *App) SetActivityListFetcher(f ActivityListFetchFunc) { + a.activityListFetcher = f +} + func (a *App) SetChannelFinderItems(items []channelfinder.Item) { a.channelFinder.SetItems(items) } @@ -4866,6 +5050,7 @@ func openInSystemViewerCmd(path string) tea.Cmd { func (a *App) SetUserNames(names map[string]string) { a.userNames = names a.threadsView.SetUserNames(names) + a.activityView.SetUserNames(names) a.messagepane.SetUserNames(names) a.threadPanel.SetUserNames(names) @@ -4945,6 +5130,7 @@ func (a *App) SetPermalinkFetcher(fn PermalinkFetchFunc) { func (a *App) SetCurrentUserID(userID string) { a.currentUserID = userID a.threadsView.SetSelfUserID(userID) + a.activityView.SetSelfUserID(userID) } // SetNowTimestampFormatter wires the formatter used to render the @@ -5460,6 +5646,29 @@ func (a *App) View() tea.View { c.store(out, tvVersion, msgWidth, contentHeight, msgLayoutKey) } panels = append(panels, a.panelCacheMsgPanel.output) + } else if a.view == ViewActivity { + a.activityView.SetUserNames(a.userNames) + a.activityView.SetSelfUserID(a.currentUserID) + avVersion := a.activityView.Version() + if c := &a.panelCacheMsgPanel; !c.hit(avVersion, msgWidth, contentHeight, msgLayoutKey) { + msgBorderStyle := styles.UnfocusedBorder.Width(msgWidth) + if msgFocused { + msgBorderStyle = styles.FocusedBorder.Width(msgWidth) + } + msgContentHeight := contentHeight - 2 + a.layoutMsgHeight = msgContentHeight + if msgContentHeight < 3 { + msgContentHeight = 3 + } + avView := a.activityView.View(msgContentHeight, msgWidth-2) + avView = messages.ReapplyBgAfterResets(avView, messages.BgANSI()) + out := exactSize( + msgBorderStyle.Render(avView), + msgWidth+msgBorder, contentHeight, + ) + c.store(out, avVersion, msgWidth, contentHeight, msgLayoutKey) + } + panels = append(panels, a.panelCacheMsgPanel.output) } else { // Channel view: split into cached top region + fresh bottom region. composeView := a.compose.View(msgWidth-2, composeFocused) diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 8037165..75fd7b9 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -725,6 +725,53 @@ func TestApp_ChannelFinderThreadsRowActivatesThreadsView(t *testing.T) { } } +func TestApp_ChannelFinderActivityRowActivatesActivityView(t *testing.T) { + app := NewApp() + app.activeTeamID = "T1" + app.channelFinder.Open() + app.SetMode(ModeChannelFinder) + _ = app.handleChannelFinderMode(tea.KeyPressMsg{Code: tea.KeyDown}) + cmd := app.handleChannelFinderMode(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected a tea.Cmd from selecting the Activity row, got nil") + } + msg := cmd() + if _, ok := msg.(ActivityViewActivatedMsg); !ok { + t.Errorf("Enter on synthetic Activity row dispatched %T, want ActivityViewActivatedMsg", msg) + } +} + +func TestApp_ActivityViewActivationAndLoad(t *testing.T) { + app := NewApp() + app.activeTeamID = "T1" + _, _ = app.Update(ActivityViewActivatedMsg{}) + if app.view != ViewActivity { + t.Fatalf("after activation view = %v, want ViewActivity", app.view) + } + items := []cache.ActivityItem{{Kind: "mention", ChannelID: "C1", TS: "1.0", Text: "hey", Unread: true}} + _, _ = app.Update(ActivityListLoadedMsg{TeamID: "T1", Items: items}) + if app.sidebar.ActivityUnreadCount() != 1 { + t.Fatalf("ActivityUnreadCount = %d, want 1", app.sidebar.ActivityUnreadCount()) + } +} + +func TestApp_HandleEnterOnActivityRowActivatesView(t *testing.T) { + app := NewApp() + app.activeTeamID = "T1" + app.sidebar.SelectActivityRow() + if !app.sidebar.IsActivitySelected() { + t.Fatalf("precondition: sidebar should select Activity row") + } + cmd := app.handleEnter() + if cmd == nil { + t.Fatal("expected a tea.Cmd, got nil") + } + msg := cmd() + if _, ok := msg.(ActivityViewActivatedMsg); !ok { + t.Errorf("expected ActivityViewActivatedMsg, got %T", msg) + } +} + // TestApp_ClickOnThreadInThreadsViewOpensIt guards Bug B: a left-click // on a thread card in the threads-list view must select that card AND // open the corresponding thread. Before the fix, the messages-pane diff --git a/internal/ui/channelfinder/model.go b/internal/ui/channelfinder/model.go index 9c79e78..d6796e9 100644 --- a/internal/ui/channelfinder/model.go +++ b/internal/ui/channelfinder/model.go @@ -23,11 +23,16 @@ var nonJoinedColor = lipgloss.Color("#5a5a5a") // threads-list view instead of switching channels. const ThreadsViewID = "__slk_view_threads" +// ActivityViewID is the sentinel ID used for the synthetic "Activity" entry. +// Callers detect this in a ChannelResult (Type=="activity") to activate the +// activity view instead of switching channels. +const ActivityViewID = "__slk_view_activity" + // ChannelResult is returned when the user selects a channel. type ChannelResult struct { ID string Name string - Type string // channel, dm, group_dm, private, threads + Type string // channel, dm, group_dm, private, threads, activity Joined bool // false => caller should join the channel before opening it } @@ -35,7 +40,7 @@ type ChannelResult struct { type Item struct { ID string Name string - Type string // channel, dm, group_dm, private, threads + Type string // channel, dm, group_dm, private, threads, activity Presence string // for DMs: active, away Joined bool // true if the user is already a member; false for browseable public channels // LastVisited is the unix timestamp (seconds) of the user's most @@ -401,6 +406,9 @@ func (m *Model) lessNoQuery(ai, bi int) bool { if a.Synthetic != b.Synthetic { return a.Synthetic } + if a.Synthetic && b.Synthetic { + return ai < bi + } if a.Joined != b.Joined { return a.Joined } diff --git a/internal/ui/channelfinder/model_test.go b/internal/ui/channelfinder/model_test.go index 0202c0f..25a6560 100644 --- a/internal/ui/channelfinder/model_test.go +++ b/internal/ui/channelfinder/model_test.go @@ -825,3 +825,20 @@ func TestSyntheticItemMatchesByName(t *testing.T) { m.items[m.filtered[0]].Name) } } + +func TestSyntheticActivityRowSelectable(t *testing.T) { + m := New() + m.SetSyntheticItems([]Item{ + {ID: ThreadsViewID, Name: "Threads", Type: "threads", Joined: true}, + {ID: ActivityViewID, Name: "Activity", Type: "activity", Joined: true}, + }) + m.Open() + m.HandleKey("down") + r := m.HandleKey("enter") + if r == nil { + t.Fatal("expected selection result") + } + if r.Type != "activity" || r.ID != ActivityViewID { + t.Fatalf("want activity/%s, got %q/%q", ActivityViewID, r.Type, r.ID) + } +} diff --git a/internal/ui/sidebar/collapse_test.go b/internal/ui/sidebar/collapse_test.go index 4e0b633..4bf7268 100644 --- a/internal/ui/sidebar/collapse_test.go +++ b/internal/ui/sidebar/collapse_test.go @@ -32,7 +32,8 @@ func TestToggleCollapse_OnSelectedHeader(t *testing.T) { {ID: "C1", Name: "general", Type: "channel"}, {ID: "D1", Name: "alice", Type: "dm"}, }) - // Cursor: Threads → Direct Messages header. Toggle it: should collapse. + // Cursor: Threads → Activity → Direct Messages header. Toggle it: should collapse. + m.MoveDown() m.MoveDown() name, ok := m.IsSectionHeaderSelected() if !ok || name != "Direct Messages" { @@ -176,6 +177,7 @@ func TestToggleCollapse_PreservesCursorOnHeader(t *testing.T) { {ID: "C1", Name: "general", Type: "channel"}, {ID: "D1", Name: "alice", Type: "dm"}, }) + m.MoveDown() // Activity m.MoveDown() // onto DM header if name, _ := m.IsSectionHeaderSelected(); name != "Direct Messages" { t.Fatalf("precondition: expected DM header, got %q", name) diff --git a/internal/ui/sidebar/model.go b/internal/ui/sidebar/model.go index 2199b41..8a57489 100644 --- a/internal/ui/sidebar/model.go +++ b/internal/ui/sidebar/model.go @@ -154,6 +154,7 @@ type navKind int const ( navThreads navKind = iota + navActivity navHeader navChannel ) @@ -216,6 +217,9 @@ type Model struct { // is on a different row), the synthetic Threads row renders with // the same orange "active" indicator used for active channels. threadsActive bool + // activityActive reports that the Activity view is the currently + // displayed view in the message pane. + activityActive bool nowFn func() time.Time // snappedSelection lets View() avoid snapping yOffset back to the @@ -246,6 +250,8 @@ type Model struct { // the cursor sits on it, SelectedItem/SelectedID return zero / empty // and the App layer activates the threads view instead. threadsUnread int + // Synthetic "Activity" row state. + activityUnread int // focused tracks whether this panel currently has user focus. When // false, the cursor "▌" glyph dims from Accent to TextMuted (via @@ -438,6 +444,16 @@ func (m *Model) SetThreadsActive(active bool) { m.dirty() } +// SetActivityActive marks the synthetic "Activity" row as the active +// destination in the message pane. +func (m *Model) SetActivityActive(active bool) { + if m.activityActive == active { + return + } + m.activityActive = active + m.dirty() +} + // Version returns a counter that increments any time the View() output could // change. Callers can compare against a previously-seen version to know // whether to recompute downstream layout / wrapping. @@ -473,6 +489,15 @@ func (m *Model) IsThreadsSelected() bool { return m.nav[m.cursor].kind == navThreads } +// IsActivitySelected reports whether the synthetic "Activity" row is the +// selected entry. +func (m *Model) IsActivitySelected() bool { + if m.cursor < 0 || m.cursor >= len(m.nav) { + return false + } + return m.nav[m.cursor].kind == navActivity +} + // SelectThreadsRow moves the cursor to the synthetic Threads row. func (m *Model) SelectThreadsRow() { for i, n := range m.nav { @@ -486,6 +511,19 @@ func (m *Model) SelectThreadsRow() { } } +// SelectActivityRow moves the cursor to the synthetic Activity row. +func (m *Model) SelectActivityRow() { + for i, n := range m.nav { + if n.kind == navActivity { + if m.cursor != i { + m.cursor = i + m.dirty() + } + return + } + } +} + // IsSectionHeaderSelected reports whether the cursor is on a section // header. When ok is true, name is the section name (e.g. "Channels", // "Direct Messages", or a custom section name). @@ -568,6 +606,21 @@ func (m *Model) SetThreadsUnreadCount(n int) { // ThreadsUnreadCount returns the current Threads-row unread badge count. func (m *Model) ThreadsUnreadCount() int { return m.threadsUnread } +// SetActivityUnreadCount updates the badge count shown next to the Activity row. +func (m *Model) SetActivityUnreadCount(n int) { + if n < 0 { + n = 0 + } + if m.activityUnread != n { + m.activityUnread = n + m.cacheValid = false + m.dirty() + } +} + +// ActivityUnreadCount returns the current Activity-row unread badge count. +func (m *Model) ActivityUnreadCount() int { return m.activityUnread } + // SetItems replaces the sidebar's channel list. It does NOT reset the // cursor to the Threads row — SetItems is called on every routine // refresh (presence updates, unread changes, channel-list resync, etc.) @@ -844,6 +897,8 @@ func (m *Model) currentCursorKey() (cursorKey, bool) { switch n.kind { case navThreads: return cursorKey{kind: navThreads}, true + case navActivity: + return cursorKey{kind: navActivity}, true case navHeader: return cursorKey{kind: navHeader, header: n.header}, true case navChannel: @@ -870,7 +925,7 @@ func (m *Model) rebuildNav() { } nav := make([]navItem, 0, 1+len(sectionOrder)) - nav = append(nav, navItem{kind: navThreads}) + nav = append(nav, navItem{kind: navThreads}, navItem{kind: navActivity}) for _, name := range sectionOrder { nav = append(nav, navItem{kind: navHeader, header: name}) if m.IsCollapsed(name) { @@ -901,6 +956,9 @@ func (m *Model) rebuildNavPreserveCursor() { case key.kind == navThreads && n.kind == navThreads: m.cursor = i return + case key.kind == navActivity && n.kind == navActivity: + m.cursor = i + return case key.kind == navHeader && n.kind == navHeader && n.header == key.header: m.cursor = i return @@ -972,6 +1030,8 @@ type renderRow struct { // the `active` variant whenever m.threadsActive is true (mirroring // the channelID-based check used for channels). isThreadsRow bool + // isActivityRow flags the synthetic Activity row. + isActivityRow bool } // buildCache rebuilds m.cacheRows for the given width. Expensive; runs only @@ -1002,10 +1062,13 @@ func (m *Model) buildCache(width int) { headerNavIdx := map[string]int{} channelNavIdx := map[int]int{} // filter idx -> nav idx threadsIdx := -1 + activityIdx := -1 for i, n := range m.nav { switch n.kind { case navThreads: threadsIdx = i + case navActivity: + activityIdx = i case navHeader: headerNavIdx[n.header] = i case navChannel: @@ -1088,8 +1151,40 @@ func (m *Model) buildCache(width int) { navIdx: threadsIdx, isThreadsRow: true, }) - // Blank separator between the Threads row and the first section (or below - // the Threads row when there are no channels at all). + + activityLabel := " ⚡ Activity" + activityCursor := cursorSelected + "⚡ Activity" + activityActiveLabel := activeBorder + "⚡ Activity" + if m.activityUnread > 0 { + badge := " " + dotStyle.Render("•"+fmt.Sprintf("%d", m.activityUnread)) + activityLabel += badge + activityCursor += badge + activityActiveLabel += badge + } + activityAttrs := bgAnsi + if m.activityUnread > 0 { + activityAttrs += "\x1b[1m" + } + activityLabel = messages.ReapplyBgAfterResets(activityLabel, activityAttrs) + activityCursor = messages.ReapplyBgAfterResets(activityCursor, activityAttrs) + activityActiveLabel = messages.ReapplyBgAfterResets(activityActiveLabel, activityAttrs) + activityBaseStyle := styles.ChannelNormal + if m.activityUnread > 0 { + activityBaseStyle = styles.ChannelUnread + } + activityNormal := activityBaseStyle.Width(width - 2).Render(activityLabel) + activitySelectedRow := styles.ChannelSelected.Width(width - 2).Render(activityCursor) + activityActiveRow := styles.ChannelSelected.Width(width - 2).Render(activityActiveLabel) + m.cacheRows = append(m.cacheRows, renderRow{ + normal: activityNormal, + selected: activitySelectedRow, + active: activityActiveRow, + height: 1, + navIdx: activityIdx, + isActivityRow: true, + }) + // Blank separator between the synthetic rows and the first section (or below + // them when there are no channels at all). m.cacheRows = append(m.cacheRows, renderRow{height: 1, navIdx: -1}) // Pre-build the per-section channel rows so we can flatten with @@ -1425,6 +1520,8 @@ func (m *Model) View(height, width int) string { // Threads view is the currently displayed view; mark the // Threads row as active with the same orange indicator. visible = append(visible, r.active) + case r.isActivityRow && m.activityActive && r.active != "": + visible = append(visible, r.active) case r.normal == "": // Inter-section blank row -- emit a width-sized themed blank so // the panel background remains continuous. @@ -1462,8 +1559,8 @@ func (m *Model) ClickAt(y int) (ChannelItem, bool) { } n := m.nav[r.navIdx] if n.kind != navChannel { - // Threads row or section header — nothing to return; caller - // inspects IsThreadsSelected / IsSectionHeaderSelected. + // Threads row or Activity row or section header — nothing to return; caller + // inspects IsThreadsSelected / IsActivitySelected / IsSectionHeaderSelected. return ChannelItem{}, false } if n.fi < 0 || n.fi >= len(m.filtered) { diff --git a/internal/ui/sidebar/model_test.go b/internal/ui/sidebar/model_test.go index 4074494..d187af3 100644 --- a/internal/ui/sidebar/model_test.go +++ b/internal/ui/sidebar/model_test.go @@ -40,7 +40,8 @@ func TestSidebarNavigation(t *testing.T) { // Expand the Channels section so j/k can reach the channel rows. m.ToggleCollapse("Channels") - // Nav order: Threads → "Channels" header → C1 → C2 → C3. + // Nav order: Threads → Activity → "Channels" header → C1 → C2 → C3. + m.MoveDown() // Activity m.MoveDown() // onto the "Channels" section header if name, ok := m.IsSectionHeaderSelected(); !ok || name != "Channels" { t.Errorf("expected Channels header selected, got name=%q ok=%v", name, ok) @@ -84,6 +85,7 @@ func TestThreadsItem_MoveDownLeavesIt(t *testing.T) { {ID: "C2", Name: "design", Type: "channel"}, }) m.ToggleCollapse("Channels") + m.MoveDown() // Activity m.MoveDown() // header m.MoveDown() // first channel if m.IsThreadsSelected() { @@ -698,3 +700,16 @@ func TestView_MutedChannelNoDot(t *testing.T) { t.Errorf("muted channel should not show a dot. Output:\n%s", out) } } + +func TestSelectActivityRow(t *testing.T) { + m := New(nil) + m.SelectActivityRow() + if !m.IsActivitySelected() { + t.Fatal("expected Activity row to be selected") + } + m.SetActivityUnreadCount(2) + out := m.View(10, 30) + if !strings.Contains(out, "Activity") || !strings.Contains(out, "•2") { + t.Fatalf("activity row should render label and badge, got:\n%s", out) + } +} diff --git a/internal/ui/sidebar/section_nav_test.go b/internal/ui/sidebar/section_nav_test.go index bd5c083..b333c23 100644 --- a/internal/ui/sidebar/section_nav_test.go +++ b/internal/ui/sidebar/section_nav_test.go @@ -23,7 +23,8 @@ func TestRenderedSelectionMatchesNavigation(t *testing.T) { // default-collapsed "Channels" section would otherwise hide // "general" from the rendered output. m.ToggleCollapse("Channels") - // Step off the synthetic Threads row. + // Step off the synthetic Threads and Activity rows. + m.MoveDown() m.MoveDown() // Expected nav stops include section headers; the test asserts the