Skip to content
Merged
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
13 changes: 13 additions & 0 deletions cmd/slk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1207,6 +1207,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 {
Expand Down
174 changes: 174 additions & 0 deletions internal/cache/activity.go
Original file line number Diff line number Diff line change
@@ -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
}()
}
81 changes: 81 additions & 0 deletions internal/cache/activity_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading