feat(desktop): add Replies tab to user profile screen

Adds a dedicated "Replies" tab between Notes and Reads on the desktop
profile screen so the reply-context rendering can be eyeballed on a
specific user's profile without scroll-hunting for an organic reply.

- DesktopProfileFeedFilter gains a repliesOnly: Boolean = false ctor
  param. Default keeps Notes-tab behavior unchanged; when true, the
  predicate becomes `event is TextNoteEvent && !note.isNewThread()`
  (excludes reposts and chat-message kinds in one check).
- UserProfileScreen: second DesktopFeedViewModel for the replies feed,
  new tab at index 1, body branch mirroring the Notes Loading/Empty/
  Error/Loaded states. Reads/Gallery/Highlights indices shift by 1.

NIP-22 kind 1111 deferred — most replies today are kind 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-06-06 15:27:10 +03:00
co-authored by Claude Opus 4.7
parent ad8556a82c
commit a5ac5c855e
3 changed files with 303 additions and 6 deletions
@@ -192,16 +192,28 @@ class DesktopThreadFilter(
/**
* Profile feed: text notes + reposts by a specific pubkey.
*
* When [repliesOnly] is true, the filter switches to "Replies" mode:
* only the pubkey's NIP-10 reply posts (kind 1 with a parent tag) — no
* reposts, no top-level notes, no channel/live messages.
*/
class DesktopProfileFeedFilter(
private val pubkey: HexKey,
private val cache: DesktopLocalCache,
private val repliesOnly: Boolean = false,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "profile-$pubkey"
override fun feedKey(): String = if (repliesOnly) "profile-$pubkey-replies" else "profile-$pubkey"
private fun isProfileNote(note: Note): Boolean {
val event = note.event ?: return false
return note.author?.pubkeyHex == pubkey && isFeedNote(event)
if (note.author?.pubkeyHex != pubkey) return false
return if (repliesOnly) {
// event is TextNoteEvent excludes reposts AND chat-message types
// that !isNewThread() would otherwise let through.
event is TextNoteEvent && !note.isNewThread()
} else {
isFeedNote(event)
}
}
override fun feed(): List<Note> =
@@ -204,6 +204,26 @@ fun UserProfileScreen(
} else {
kotlinx.collections.immutable.persistentListOf()
}
// User's replies — separate VM, same cache. Predicate inside the filter.
val repliesViewModel =
remember(pubKeyHex) {
DesktopFeedViewModel(
DesktopProfileFeedFilter(pubKeyHex, localCache, repliesOnly = true),
localCache,
)
}
DisposableEffect(repliesViewModel) {
onDispose { repliesViewModel.destroy() }
}
val repliesFeedState by repliesViewModel.feedState.feedContent.collectAsState()
val repliesLoadedNotes =
if (repliesFeedState is FeedState.Loaded) {
val loaded by (repliesFeedState as FeedState.Loaded).feed.collectAsState()
loaded.list
} else {
kotlinx.collections.immutable.persistentListOf()
}
var retryTrigger by remember { mutableStateOf(0) }
// Subscribe to profile user's text notes (kind 1) — populates cache for DesktopFeedViewModel
@@ -831,15 +851,18 @@ fun UserProfileScreen(
Text("Notes", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
Text("Replies", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) {
Text(
"Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}",
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) {
Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) {
Text("Gallery", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) {
Tab(selected = selectedTab == 4, onClick = { selectedTab = 4 }) {
Text(
"Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}",
modifier = Modifier.padding(12.dp),
@@ -942,6 +965,96 @@ fun UserProfileScreen(
}
1 -> {
when (repliesFeedState) {
is FeedState.Loading -> {
item(key = "replies-loading") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.material3.CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
Text(
"Loading replies...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
is FeedState.Empty -> {
item(key = "replies-empty") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No replies yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
is FeedState.FeedError -> {
item(key = "replies-error") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Failed to load replies",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
(repliesFeedState as FeedState.FeedError).errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = { retryTrigger++ }) {
Text("Retry")
}
}
}
}
}
is FeedState.Loaded -> {
items(repliesLoadedNotes, key = { "reply-${it.idHex}" }) { note ->
FeedNoteCard(
note = note,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index ->
lightboxState = LightboxState(urls, index)
},
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
}
}
}
}
2 -> {
if (articleEvents.isEmpty()) {
item(key = "no-articles") {
Box(
@@ -973,7 +1086,7 @@ fun UserProfileScreen(
}
}
2 -> {
3 -> {
item(key = "gallery") {
GalleryTab(
pictureEvents = pictureEvents,
@@ -983,7 +1096,7 @@ fun UserProfileScreen(
}
}
3 -> {
4 -> {
if (highlightEvents.isEmpty()) {
item(key = "no-highlights") {
Box(
@@ -0,0 +1,172 @@
---
title: Desktop Profile — Replies Tab
type: feat
status: active
date: 2026-06-05
---
# Desktop Profile — Replies Tab
## Enhancement Summary (deepen-plan, 2026-06-05)
Source-verified facts (carried into the final design):
- **`isNewThread()` semantics** (`commons/.../model/Note.kt:775-783`): returns
true when `(event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.size == 0)`
AND `event !is ChannelMessageEvent` AND `event !is LiveActivitiesChatMessageEvent`.
Important edge case: `!isNewThread()` would include channel/live messages,
so the replies-filter predicate must also constrain by event type.
- **Replies predicate (final):** `author == pubkey && event is TextNoteEvent && !isNewThread()`.
Using `event is TextNoteEvent` rather than `isFeedNote()` excludes reposts
AND channel messages in one shot. NIP-22 kind 1111 deferred.
- **Notes predicate (final):** add `note.isNewThread()` to existing
`author == pubkey && isFeedNote(event)`. Safe because `isFeedNote()`
restricts to kind 1/6/16 — none of which are channel/live messages, so
the `isNewThread()` channel-message exclusion clause never fires here.
- **`DesktopProfileFeedFilter` shape** (`DesktopFeedFilters.kt:140-163`):
`AdditiveFeedFilter<Note>` with `feed()`, `applyFilter(newItems)`,
`sort(items)`, `limit()` overrides. The replies filter must override all
four to match the framework contract — covered in Phase 1.
- **`DesktopFeedViewModel` lifecycle** (`UserProfileScreen.kt:153-163`):
remembered keyed on `pubKeyHex`; disposed via `DisposableEffect`. The
replies VM follows identical pattern.
- **Tab structure** (`UserProfileScreen.kt:714-735`): `PrimaryTabRow` with
`selectedTab` state at line 198. Tab body branches at line 738 via
`when (selectedTab)`. Inserting at index 1 requires shifting Reads (1→2),
Gallery (2→3), Highlights (3→4) — touched in two places (tabs + body).
- **Relay subscription** (`UserProfileScreen.kt:174-195`): already pulls
kind 1/6/16 via `FilterBuilders.textNotesFromAuthors`. Covers NIP-10
replies. No subscription change needed.
## Overview
Add a "Replies" tab to the desktop profile screen alongside the existing
Notes tab, so the reply-context rendering from the prior PR can be tested
on profile feeds. Scope is intentionally narrow: **purely additive**, the
existing Notes tab is unchanged.
## Problem Statement
Reply-context rendering (just shipped) is wired into every screen that
funnels through `FeedNoteCard` — profile included. But the only profile
feed currently is "Notes" which mixes everything, so a user testing the
feature has to scroll to find an organic reply. A dedicated Replies tab
is the straightforward QA affordance.
## Proposed Solution
1. **Extend `DesktopProfileFeedFilter`** (existing) with a `repliesOnly: Boolean = false`
constructor parameter. When `false` (default): keeps existing predicate
exactly — Notes tab behavior unchanged. When `true`: predicate becomes
`author == pubkey && event is TextNoteEvent && !note.isNewThread()`.
2. **Wire a second `DesktopFeedViewModel`** in `UserProfileScreen.kt` with
`repliesOnly = true`.
3. **Insert "Replies" tab at index 1** between Notes and Reads; shift Reads
to 2, Gallery to 3, Highlights to 4.
4. **Render** the replies feed using the same `FeedNoteCard` pipeline — reply
context engages automatically.
No relay-subscription change: existing `textNotesFromAuthors` already pulls
kinds 1/6/16 from the user, which covers all NIP-10 replies. NIP-22 kind
1111 deferred — most replies today are kind 1.
**Out of scope (deliberately):**
- Splitting Notes/Replies in the existing Notes tab — separate concern,
potential UX regression for users who like the mixed feed, not
load-bearing for this QA goal.
- Count badge on the Replies tab ("Replies (N)") — premature; Reads/Highlights
count their statically-loaded events, replies are dynamic via FeedViewModel.
- Behavior changes to global / following / bookmark feeds.
## Survey Matrix
| Component | Status | Location | Action |
|---|---|---|---|
| `Note.isNewThread()` | ✅ Reuse | `commons/.../model/Note.kt:775` | Canonical reply-vs-root check |
| `DesktopProfileFeedFilter` | 📦 Extend | `desktopApp/.../feeds/DesktopFeedFilters.kt:140-163` | Add `repliesOnly: Boolean = false` ctor param + branched predicate |
| `DesktopFeedViewModel` | ✅ Reuse | existing | Instantiate a second VM with `repliesOnly = true` |
| Profile relay subscription | ✅ Reuse | `UserProfileScreen.kt:174-195` | Already pulls kind 1; covers NIP-10 replies |
| `PrimaryTabRow` + tab indices | 📦 Extend | `UserProfileScreen.kt:714-735` | Insert "Replies" tab; shift Reads/Gallery/Highlights indices |
| Tab body render | 📦 Extend | `UserProfileScreen.kt:738+` | Add `when (selectedTab) { 1 -> ... }` branch mirroring index 0 |
| Android equivalent | 📖 Reference | `amethyst/.../profile/conversations/dal/UserProfileConversationsFeedFilter.kt` | Pattern only (`acceptableEvent` with `!isNewThread()`) |
## Phases
### Phase 1 — Filter + Profile screen wiring
Files:
- `desktopApp/.../feeds/DesktopFeedFilters.kt`:
- Add `repliesOnly: Boolean = false` constructor parameter to
`DesktopProfileFeedFilter`.
- Update `feedKey()` to include the mode so the two filter instances
don't collide cache-wise: `"profile-$pubkey${if (repliesOnly) "-replies" else ""}"`.
- Branch the predicate inside `isProfileNote`:
- `repliesOnly = false` (default): existing predicate, unchanged.
- `repliesOnly = true`: `author == pubkey && event is TextNoteEvent && !note.isNewThread()`.
- The `event is TextNoteEvent` check (rather than `isFeedNote()`)
excludes reposts AND `ChannelMessageEvent` / `LiveActivitiesChatMessageEvent`
(which `!isNewThread()` would otherwise let through).
- `desktopApp/.../ui/UserProfileScreen.kt`:
- Add a `repliesViewModel = remember(pubKeyHex) { DesktopFeedViewModel(DesktopProfileFeedFilter(pubKeyHex, localCache, repliesOnly = true), localCache) }`
next to the existing `profileViewModel` (line 154). Add a matching
`DisposableEffect` to destroy it.
- Collect its feed state into `repliesFeedState` + derive
`repliesLoadedNotes` mirroring the existing Notes setup (lines 164-171).
- Insert a "Replies" tab at index 1 in the `PrimaryTabRow` (lines 714-735).
Shift Reads → 2, Gallery → 3, Highlights → 4. (Updates needed in tab
declarations AND in the `when (selectedTab)` body branches.)
- Add a `1 -> { ... }` body branch that mirrors the Notes branch
(`Loading` / `Empty` / `FeedError` / `Loaded`) but uses `repliesFeedState`
and `repliesLoadedNotes`. Empty-state copy: "No replies yet".
Verify (single command — Phases 1 and 2 must build together since they're
cross-file):
- `./gradlew :desktopApp:compileKotlin`
- `./gradlew spotlessApply`
Manual sanity:
- Open a profile with mixed posts. Notes tab = unchanged (still shows
everything as before). Replies tab = only the user's reply posts, each
rendered with the embedded parent card + "Replying to @X" label.
### Phase 2 — (none)
Folded into Phase 1 per the review pass — separate phases for a single
cross-file edit + format was ceremonial.
## Acceptance Criteria
- [ ] Profile screen shows a "Replies" tab between Notes and Reads.
- [ ] Notes tab behavior is UNCHANGED — still shows whatever it did before.
- [ ] Replies tab shows ONLY the user's reply posts (kind 1 with parent tags).
- [ ] Each reply in the Replies tab renders with the embedded parent + "Replying to @X" label (validates the prior PR end-to-end on profiles).
- [ ] Tab switching is responsive (no scroll-position weirdness, no flash).
- [ ] Cross-module compiles green; spotless clean.
## Testing
Manual UI:
1. `./gradlew :desktopApp:run` from the worktree.
2. Open the profile of an account that posts a mix of root notes and replies (your own account works).
3. Notes tab → only top-level posts visible.
4. Replies tab → only replies visible, each with parent embed + label.
5. Confirm reposts appear in Notes, not Replies.
6. Confirm switching tabs is smooth.
## Dependencies & Risks
- **NIP-22 (kind 1111) replies skipped in v1.** Most replies are kind 1.
Adding kind 1111 needs `isFeedNote()` extension or new helper, plus possibly
extending the relay subscription. Defer.
- **Existing Notes tab will lose replies** — intended behavior change.
Anyone relying on seeing replies in the Notes feed will find them in the new
Replies tab. Document in PR body.
## Unanswered Questions
- Manual scan needed during testing: is the second `DesktopFeedViewModel`'s
cache-scan overhead noticeable on profile open? Existing pattern already
does one full scan per profile; this doubles it. If it shows up in latency,
fallback is derive-from-loaded-list at render time.
- NIP-22 kind 1111 inclusion — deferred. Add in a follow-up if QA confirms
enough kind 1111 traffic on the relays used.