From a5ac5c855e94da9b3ed2ec8cd50a61bbddbe34d3 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 5 Jun 2026 11:43:04 +0300 Subject: [PATCH] feat(desktop): add Replies tab to user profile screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../desktop/feeds/DesktopFeedFilters.kt | 16 +- .../amethyst/desktop/ui/UserProfileScreen.kt | 121 +++++++++++- ...06-05-feat-profile-replies-section-plan.md | 172 ++++++++++++++++++ 3 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 docs/plans/2026-06-05-feat-profile-replies-section-plan.md diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index b34fe2ebe7..c569982529 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -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() { - 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 = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index c15366a5d5..a5288551ea 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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( diff --git a/docs/plans/2026-06-05-feat-profile-replies-section-plan.md b/docs/plans/2026-06-05-feat-profile-replies-section-plan.md new file mode 100644 index 0000000000..61fb3d0504 --- /dev/null +++ b/docs/plans/2026-06-05-feat-profile-replies-section-plan.md @@ -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` 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.