mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
co-authored by
Claude Opus 4.7
parent
ad8556a82c
commit
a5ac5c855e
+14
-2
@@ -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> =
|
||||
|
||||
+117
-4
@@ -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.
|
||||
Reference in New Issue
Block a user