Skip to content

feat(activity): Activity feed view (mentions, threads, reactions, DMs) - #109

Open
genki-miyachi wants to merge 6 commits into
gammons:mainfrom
genki-miyachi:feat/activity-feed
Open

genki-miyachi wants to merge 6 commits into
gammons:mainfrom
genki-miyachi:feat/activity-feed

Conversation

@genki-miyachi

Copy link
Copy Markdown
Contributor

What

Adds a Slack-native Activity view — the desktop client's 🔔 Activity tab — as a dedicated list in the message pane, mirroring the existing Threads view (⚑ Threads). It's the "catch up on what pinged me" view a few people asked for in #12.

How

Sourced from Slack's internal activity.feed endpoint, reached with the same xoxc browser auth slk already uses for client.counts, subscriptions.thread.getView, etc. (via postForm). It surfaces exactly what the desktop Activity tab does:

type shown as
at_user / at_channel / at_everyone / at_user_group / keyword mentions
thread_v2 thread replies
message_reaction reactions to your messages
dm / bot_dm_bundle DMs

activity.feed takes a server-side unread_only flag, so the read/unread filter is done by Slack rather than client-side.

UX

  • Entry: a 🔔 Activity sidebar row (below ⚑ Threads), a ctrl+a toggle (the terminal-friendly stand-in for the desktop's A), and an :activity command.
  • u toggles the unread-only filter (re-fetches).
  • Enter opens the underlying message/thread in its channel, reusing the existing channel-open / permalink-jump / thread-open paths.
  • Rows resolve channel + user names from the same caches the Threads view uses; relative time from feed_ts; unread items are bold.

Design notes

  • Mirrors the Threads view at every wiring site (ViewActivity alongside ViewThreads: view enum, app field/init, msgs, a reducer, the render branch, nav/scroll, the sidebar synthetic row).
  • The client flattens each raw item to a small ActivityItem{Key,Type,IsUnread,ChannelID,TS,ThreadTS,AuthorID,Reaction,…} so the UI never touches raw JSON.
  • Lenient parsing: items are decoded individually — a single item with an unexpected field type (Slack's internal endpoints occasionally do this) is skipped, not fatal to the page. A failed fetch returns nil so the existing feed + badge are kept rather than blanked.
  • Message-body preview (messages.list) and activity.markRead are intentionally left as follow-ups; opening an item already marks the underlying conversation read.

Testing

  • internal/slack/activity_test.go parses a sanitized real activity.feed response fixture (all item types) and asserts per-type extraction, plus a regression test that one malformed item is skipped without failing the page.
  • go build ./..., go vet ./..., go test ./... all pass.
  • Driven end-to-end on a live multi-workspace session (fetch → render → u filter → Enter to open). Easy to try: gh pr checkout, build, run against your own workspace.

Happy to rebind any keys (ctrl+a / u) — those were just what was free. Relates to #12.

(Full disclosure: built with heavy AI assistance — frontier model, high effort — and reviewed + tested on my end, per the contributing notes.)

🤖 Generated with Claude Code

@gammons

gammons commented Jul 27, 2026

Copy link
Copy Markdown
Owner

hi @genki-miyachi thanks for this! I love the idea of having the activity feed in the app. one note when testing, it looks like maybe we're not showing all the data.
image

if I compare it to the actual activity feed, there's a lot of data about what the activity was, e.g. an emoji added, a reply, etc.
image

genki-miyachi added a commit to genki-miyachi/slk that referenced this pull request Jul 29, 2026
Addresses the review note on gammons#109 that the feed showed only metadata, not
"what the activity was". activity.feed returns refs only; each loaded page
now triggers a second messages.list fetch to hydrate the message bodies.

Rows are now two-line cards mirroring the desktop Activity feed:
  <author>  Mention/Thread/Reacted in #channel            <time> [●]
    <message body preview>

- client: GetActivityMessages(refs) via messages.list, keyed by
  ActivityMsgKey(channel, ts); parsed leniently per channel.
- Hydrate wired through ActivityService; ActivityListLoadedMsg kicks the
  body fetch, ActivityBodiesLoadedMsg (team-guarded) installs them.
- view: author-first cards with a natural-language context label, the
  hydrated body on line 2 (empty until hydration lands), reaction emoji
  prefixed; author falls back to the hydrated message's user for
  thread_v2/dm refs that carry no author on the feed item.
- Extract slack markup stripping into internal/slackfmt.StripMarkup (no
  length cap; callers clip to width) so the view doesn't depend on the
  notification formatter; notify.StripSlackMarkup now delegates and caps
  by rune (fixing a latent mid-rune byte-truncation of multibyte bodies).

Relates to gammons#109.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
genki-miyachi added a commit to genki-miyachi/slk that referenced this pull request Jul 29, 2026
… switch

- Clear hydrated bodies on workspace switch/reset (SetBodies(nil) alongside
  the existing SetItems(nil)) so team A's bodies can't linger under team B.
- Tests: parseActivityMessages (fixture, lenient — empty-ts skipped,
  malformed channel skipped, ok:false wrapped), ActivityMsgKey no-collision,
  activityRefs dedup/skip-empty/grouping, slackfmt.StripMarkup (resolves +
  strips, no truncation, UTF-8 safe on multibyte), and the activity view's
  card invariants: renderCard always exactly cardStride lines with no
  embedded newline (incl. newline/empty/CJK/absent-body bodies), renderRows
  stride, contextVerb/contextLabel, author fallback to hydrated body, and
  ClickAt mapping both card lines to one item.

Relates to gammons#109.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
genki-miyachi added a commit to genki-miyachi/slk that referenced this pull request Jul 29, 2026
Relates to gammons#109.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
genki-miyachi and others added 5 commits July 29, 2026 19:17
… DMs)

Adds a Slack-native "Activity" view -- the desktop client's Activity tab --
as a dedicated list in the message pane, mirroring the existing Threads
view. Backed by Slack's internal activity.feed endpoint, reached with the
same xoxc browser auth slk already uses for client.counts,
subscriptions.thread.getView, etc.

Surfaces what notified you: @mentions (at_user/at_channel/at_everyone/
at_user_group/keyword), thread replies (thread_v2), reactions to your
messages (message_reaction), and DMs (dm/bot_dm_bundle). The endpoint's
server-side unread_only flag drives an in-view unread/read filter.

Entry points: an Activity sidebar row (below Threads), a ctrl+a toggle
(the terminal stand-in for the desktop's Cmd+Shift+A), and an :activity
command. `u` toggles the unread-only filter; Enter opens the underlying
message/thread in its channel.

Parsing is per-item and lenient -- a single item with an unexpected field
type is skipped rather than failing the page -- and a failed fetch keeps
the existing feed instead of blanking it.

Relates to gammons#12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the review note on gammons#109 that the feed showed only metadata, not
"what the activity was". activity.feed returns refs only; each loaded page
now triggers a second messages.list fetch to hydrate the message bodies.

Rows are now two-line cards mirroring the desktop Activity feed:
  <author>  Mention/Thread/Reacted in #channel            <time> [●]
    <message body preview>

- client: GetActivityMessages(refs) via messages.list, keyed by
  ActivityMsgKey(channel, ts); parsed leniently per channel.
- Hydrate wired through ActivityService; ActivityListLoadedMsg kicks the
  body fetch, ActivityBodiesLoadedMsg (team-guarded) installs them.
- view: author-first cards with a natural-language context label, the
  hydrated body on line 2 (empty until hydration lands), reaction emoji
  prefixed; author falls back to the hydrated message's user for
  thread_v2/dm refs that carry no author on the feed item.
- Extract slack markup stripping into internal/slackfmt.StripMarkup (no
  length cap; callers clip to width) so the view doesn't depend on the
  notification formatter; notify.StripSlackMarkup now delegates and caps
  by rune (fixing a latent mid-rune byte-truncation of multibyte bodies).

Relates to gammons#109.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… switch

- Clear hydrated bodies on workspace switch/reset (SetBodies(nil) alongside
  the existing SetItems(nil)) so team A's bodies can't linger under team B.
- Tests: parseActivityMessages (fixture, lenient — empty-ts skipped,
  malformed channel skipped, ok:false wrapped), ActivityMsgKey no-collision,
  activityRefs dedup/skip-empty/grouping, slackfmt.StripMarkup (resolves +
  strips, no truncation, UTF-8 safe on multibyte), and the activity view's
  card invariants: renderCard always exactly cardStride lines with no
  embedded newline (incl. newline/empty/CJK/absent-body bodies), renderRows
  stride, contextVerb/contextLabel, author fallback to hydrated body, and
  ClickAt mapping both card lines to one item.

Relates to gammons#109.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Relates to gammons#109.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guards the resolution behavior moved into slackfmt when reconciling the
Activity hydration with gammons#96's notify usergroup support: bare <!subteam^ID>
resolves via the usergroup map, labeled forms use the embedded label, and
an unresolved bare token falls back to "@group".
@genki-miyachi

Copy link
Copy Markdown
Contributor Author

@gammons
Addressed the feedback — rows now show the message body, not just metadata. Each item is a two-line card:

@ Alex Rivera  Mention in #announcements         5h ago
   @channel heads up — the release notes are up…
☺ Sam Chen  Reacted in #dev-backend              6h ago
  :tada:  shipped it, thanks!
⚑ Jordan Lee  Thread in #design                  1d ago
  can we get a second pass on the new icons?

activity.feed only returns refs, so each page now does a second messages.list call to hydrate the bodies.

Also rebased onto main to resolve the conflict with the recent notify/usergroups changes — the markup stripping now lives in slackfmt and both notify and the activity view share it. Tests added, build/vet/test green, verified live. Let me know what you think of the layout.

@gammons

gammons commented Aug 10, 2026

Copy link
Copy Markdown
Owner

hi @genki-miyachi thanks for that. there are a couple of merge conflicts now. but I think this is ready to get merged in.

# Conflicts:
#	internal/ui/app.go
#	internal/ui/keys.go
#	internal/ui/reducer_channels.go
@genki-miyachi

Copy link
Copy Markdown
Contributor Author

@gammons Conflicts resolved — merged latest main in.

Only real overlap was the keymap: #110's a/A stay as-is for unread-channel nav, Activity keeps
ctrl+a, and u is scoped to the Activity view so they don't collide. Also dropped a gofmt-only
reformat this branch was carrying in reducer_channels.go, so that file's diff is down to one line.

build / vet / test -race / lint all green, CI passing, and I re-ran the whole flow by hand.
Ready to merge whenever 🙏

@davidnagli

Copy link
Copy Markdown

Hi +1 for this feature, it's the one blocker for me to be able to use this package. I spend 99% of my time in the activity tab and no tool has good keyboard shortcuts so slk will solve a ton of problems for me!

Any progress / blockers on getitng this merged? I'm happy to help if needed

@gammons

gammons commented Sep 3, 2026

Copy link
Copy Markdown
Owner

@genki-miyachi — apologies, this went stale on my end, not yours. You did two rounds of requested revisions and I told you it was ready, then let it sit. That's on me.

I've now reviewed this side by side with #169, which implements the same feature, and this is the one I'm merging. The deciding factor is that this hydrates message bodies via messages.list and renders the actual text. #169 renders metadata only ("direct message", "reply from X") — which is precisely the version I sent back in July with those two screenshots. You fixed that; it's the difference between the feature being useful and not.

A few other things I checked and liked:

  • 2 requests, lazily on view-open only. No polling, nothing at boot, +60KB binary. Cold start and RSS unaffected.
  • internal/ui/activityview/ mirrors internal/ui/threadsview/ method-for-method, and reducer_activity.go registers like reducer_threads.go. Fits the existing shape rather than inventing a new one.
  • The 563-line sanitized capture covering 7 item types is the right way to fixture an undocumented endpoint, and you handle bot_dm_bundle correctly (payload at bundle_info.payload.message, not item.message). feat(activity): Slack Activity inbox #169 misses that one and silently drops every bot DM.
  • Per-item lenient parsing (activity.go:157-168) — one malformed entry is skipped, not fatal. That's the right posture for a private endpoint and I'd like it to be the house pattern.
  • The slackfmt extraction is motivated by the feature rather than smuggled in, and it fixes a real UTF-8 bug in notifier.go on the way (text[:100] on a rune boundary).

Four things and then I merge:

  1. The conflict is small — just keys.go and mode_normal.go against 6647ce4 (the new d download binding), caused by your gofmt realignment of the KeyMap struct meeting my new field. Re-insert DownloadFile and its switch case. Note chore(ci): fix flaky membership test and enforce gofmt #171 (landing shortly) reformats the tree and enforces gofmt in CI, so rebase after that and the realignment noise disappears from your diff entirely.

  2. router.Active() should be router.ByID(teamIDStr) in both Activity closures in cmd/slk/main.go. As written, a workspace switch mid-flight fetches the wrong workspace's feed and labels it with the requested team ID. The reducer's m.TeamID != a.activeTeamID guard discards it so nothing corrupts, but it's a latent mislabel and a wasted call. router.ByID already exists at main.go:267.

  3. activityNextCursor is stored but never read (app.go:255-258, reducer_activity.go:100). Either wire a "load more" or drop the field — dead state on a view that's otherwise capped at one page.

  4. case "channel" reads item.message (activity.go:189-190), but that type puts its ref at bundle_info.payload.channel_entry.latest_message. Those rows render with an empty ChannelID and no-op on Enter. Not in your fixture, so the tests can't see it.

Two follow-ups I'd like but won't hold this for — @agustif got both right in #169 and I'll credit them there:

  • The sidebar badge can come free from the activity_v2 block in the client.counts call we already make, which means it's correct at cold start. Right now it reads 0 until you open the view, which is the weakest part of the UX here.
  • ActivityListLoadedMsg should carry a generation counter so rapid u toggling can't land responses out of order.

Thanks for your patience with this one. Ping me when it's rebased.

Closes #12.

@gammons gammons mentioned this pull request Sep 3, 2026
4 tasks
@gammons gammons added the needs minor changes Approved in principle; small fixes requested label Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

in progress needs minor changes Approved in principle; small fixes requested

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants