diff --git a/amethyst/plans/2026-07-12-dual-reply-minichat.md b/amethyst/plans/2026-07-12-dual-reply-minichat.md new file mode 100644 index 0000000000..7416c2becd --- /dev/null +++ b/amethyst/plans/2026-07-12-dual-reply-minichat.md @@ -0,0 +1,113 @@ +# Dual-mode replies: inline + "minichat" threads across all chats + +## Goal + +Give every Amethyst chat two ways to reply, chosen at send time: + +- **Inline reply** — a normal chat message that references its parent and stays in + the main timeline (today's behavior). On the wire this is the chat protocol's + native reply: NIP-C7 kind-9 with a `q` quote (Concord), kind-42 reply (NIP-28), + kind-9 `+h` reply (NIP-29), kind-14 reply (NIP-17 DM). +- **Minichat reply** — a **kind-1111 NIP-22 `CommentEvent`** rooted at the parent + message. It is pulled *out* of the main timeline and shown in a separate + **minichat** ("chat within a chat") opened from the parent. This matches Soapbox + Armada exactly (kind-9 `q` = inline quote, kind-1111 = thread). + +The rule is uniform and protocol-agnostic: **any kind-1111 whose root is a chat +message opens as that message's minichat.** So the same treatment automatically +covers Concord kind-9, NIP-28 kind-42, NIP-29 kind-9, and (later) NIP-17 kind-14 — +wherever a 1111 lands on a chat message. + +## Reuse survey (what already exists — do NOT rebuild) + +| Need | Reuse | +|---|---| +| kind-1111 reply builder (NIP-22 `K/E/P`+`k/e/p`) | `quartz/.../nip22Comments/CommentEvent.replyBuilder`; Concord's `ChannelChat.reply` already uses it | +| 1111 → parent wiring | `LocalCache.computeReplyTo` (CommentEvent branch) → `parentNote.replies`; minichat content = `note.replies.filter { it.event is CommentEvent }` | +| "N replies" chip | `observeNoteReplyCount(note, avm)` (EventObservers.kt) — already used by `RelayGroupThreadsScreen` | +| Shared per-row action strip | `ChatMessageCompose.NormalChatNote` `detailRow` `Row` — one place, every chat type | +| Thread rendering | `threadview/ThreadFeedView` + `ThreadAssembler.findThreadFor`; NIP-29 `RelayGroupThreadsScreen` as the chat-adjacent precedent | +| Per-message 1111 REQ (public chats) | `FilterRepliesAndReactionsToNotes` (kinds incl 1111, `#e`) via `EventFinder`; `RelayGroupThreadFeedFilterAssembler` (compose-scoped `#h`+1111 sub) | +| Composer reply state + "replying-to" preview | `*NewMessageViewModel.replyTo` + `chats/utils/DisplayReplyingToNote` | +| NIP-22 comment composer | `note/nip22Comments/CommentPostViewModel` (full-featured) | + +Concord already delivers kind-1111 replies through the existing channel-plane +subscription (they're wrapped like every other rumor), so **no new subscription is +needed for Concord** — only the timeline split, the chip, the minichat screen, and +the composer picker. + +## Design + +### 1. Wire model (settled — matches Armada) +- Inline reply → native chat reply event, native reply tags, stays in timeline. +- Minichat reply → kind-1111 `CommentEvent`: uppercase `K/E/P` at the immutable + thread root (the chat message), lowercase `k/e/p` at the immediate parent, plus + whatever binding the plane requires (Concord: `channel`/`epoch`). One level: + replying inside a minichat roots the new 1111 at the **same** root message + (parent = the message being answered, root = the minichat root), rendered flat — + so minichat messages don't spawn sub-threads. (The wire still permits nesting; + we render flat.) + +### 2. Timeline vs minichat split (rendering) +- **Main feed** excludes kind-1111 comments whose root is a chat message — they + live in the minichat, not as flat siblings. Implemented in the shared + `ChannelFeedFilter` / `ChatroomFeedFilter` by dropping `CommentEvent`s that root + onto a message already in the feed (keep everything else). +- Each root message row shows an **"N replies" chip** (from `observeNoteReplyCount` + restricted to CommentEvent replies) in the `detailRow` strip; tap → minichat route. + +### 3. Minichat screen +- A thread screen keyed by the **root message id** (+ the channel/room key needed to + re-derive the plane / re-subscribe). Renders the root message pinned at top, then + its kind-1111 replies as a flat mini-timeline (reuse `ChatroomMessageCompose`), with + its own composer that always sends kind-1111 rooted at this message. +- Back it with `ThreadFeedView`/`ThreadAssembler` where possible; for Concord, feed + it from `rootNote.replies` (already populated) + a lifecycle sub that keeps the + plane live. + +### 4. Composer mode picker +- Add `replyMode: ReplyMode {INLINE, MINICHAT}` next to `replyTo` in each + `*NewMessageViewModel` (Concord `ConcordNewMessageViewModel`, DM + `ChatNewMessageViewModel`, channels `ChannelNewMessageViewModel`). +- Render a small toggle beside `DisplayReplyingToNote` ("Reply in chat" ⇄ "Reply in + thread"). Default = INLINE (least surprise; user opts into pulling it aside). +- Send branch: `MINICHAT` routes to the kind-1111 builder + (`CommentEvent.replyBuilder` / Concord `buildChannelReply`), `INLINE` keeps the + native reply builder. + +### 5. Subscriptions +- **Concord**: none new (1111 arrives via the channel plane). Just ensure the + timeline filter and minichat read `rootNote.replies`. +- **NIP-28 / NIP-29 (phase 2)**: add a compose-scoped assembler (clone + `RelayGroupThreadFeedFilterAssembler`) that REQs `{kinds:[1111], "#e":[]}` (and `#E`) off the feed's current message-id set (from + `FeedContentState`). Reuse the same minichat screen/row. +- **NIP-17 DM (phase 3, later)**: kind-1111 replies must be gift-wrapped like the + kind-14s; deferred — needs an encrypted-comment path, more design. + +## Phasing + +1. **Phase 1 — Concord, full UX + all shared pieces.** ReplyMode enum + composer + toggle; timeline split (drop chat-rooted 1111s); "N replies" chip in the shared + `detailRow`; minichat route + screen; Concord send branch. Delivers the complete + dual-mode experience for Concord and builds every shared component. +2. **Phase 2 — public chats.** Per-message 1111 subscription for NIP-28 + NIP-29; + reuse the Phase-1 chip/screen/composer. NIP-29 already has a thread screen to + reconcile with. +3. **Phase 3 — DMs.** Gift-wrapped kind-1111 minichat for NIP-17. Deferred. + +## Decisions (settled) +- **Default mode** when tapping reply: **INLINE**. User opts into MINICHAT via the toggle. +- **Minichat depth**: **flat, one level**. Replying inside a minichat roots at the + same message; no sub-threads. +- **Scope now**: **Phase 1 + 2 together** — Concord AND public chats (NIP-28/NIP-29). + DMs (phase 3) still deferred. +- **Screen styling**: **chat-styled bubbles** (reuse `ChatroomMessageCompose`) so the + minichat reads as "a chat within a chat". + +## Verification +- quartz/commons unit tests for the reply-mode builders + the timeline-filter split + (a chat-rooted 1111 is excluded from the feed but present in `rootNote.replies`). +- On-device: in Concord, reply inline (stays in timeline) and reply-in-thread (opens + minichat); confirm Armada shows our minichat replies as a thread and its threads + open as our minichat; confirm the "N replies" chip count. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c0a1d68dcc..4e8c799aff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache @@ -1962,6 +1963,7 @@ class Account( channelIdHex: String, text: String, replyTo: Note? = null, + replyMode: ReplyMode = ReplyMode.INLINE, ): Boolean { if (!isWriteable()) return false val session = concordSessions.sessionFor(communityId) ?: return false @@ -1970,10 +1972,15 @@ class Account( val parent = replyTo?.event val wrap = - if (parent != null) { - ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now()) - } else { - ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now()) + when { + // A minichat reply is a kind-1111 thread comment; an inline reply is a kind-9 + // message quoting the parent; a fresh post is a plain kind-9 message. + parent != null && replyMode == ReplyMode.MINICHAT -> + ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now()) + parent != null -> + ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now()) + else -> + ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now()) } publishConcordWrap(entry, wrap) return true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index c0ac72aa1e..29136e808b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity @@ -214,6 +215,35 @@ fun observeNoteReplyCount( return flow.collectAsStateWithLifecycle(note.replies.size) } +/** + * Count of a chat message's **minichat** replies — its kind-1111 [CommentEvent] + * children only (inline quote-replies are ordinary kind-9/42 messages and are not + * counted here). Drives the "N replies" chip that opens the minichat. + * + * Local-only: it reads the reply index the cache already holds, and does NOT open a + * per-note relay subscription (that would be one wasted REQ per visible row, and in + * Concord the kind-1111 replies arrive over the channel plane anyway). The replies for + * public chats are loaded once, feed-wide, by the chat screen's minichat subscription. + */ +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeNoteMinichatReplyCount( + note: Note, + accountViewModel: AccountViewModel, +): State { + val flow = + remember(note) { + note + .flow() + .replies.stateFlow + .sample(200) + .mapLatest { it.note.replies.count { reply -> reply.event is CommentEvent } } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(note.replies.count { it.event is CommentEvent }) +} + @Composable fun observeNoteReactions( note: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 30ca93806a..d76b6087d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -21,13 +21,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -42,8 +48,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterStart import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -96,6 +106,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as MaterialSymbolIcon @Composable fun ChatroomMessageCompose( @@ -280,6 +291,8 @@ fun NormalChatNote( ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + MinichatReplyChip(note, accountViewModel, nav) + val geo = remember(note) { note.event?.geoHashOrScope() } if (geo != null) { Spacer(StdHorzSpacer) @@ -384,6 +397,47 @@ private fun MessageBubbleLines( } } +/** + * A chip on a chat message's action row showing how many kind-1111 thread ("minichat") + * replies it has; tapping opens that thread. Shown only when there is at least one — an + * inline reply is an ordinary message and isn't counted. Shared across every chat type + * (Concord, NIP-28, NIP-29, DMs), since they all render through this row. + */ +@Composable +private fun MinichatReplyChip( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val count by observeNoteMinichatReplyCount(note, accountViewModel) + if (count > 0) { + Spacer(StdHorzSpacer) + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable { nav.nav(Route.Note(note.idHex)) }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) { + MaterialSymbolIcon( + symbol = MaterialSymbols.Forum, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(13.dp), + ) + Text( + text = pluralStringResource(R.plurals.chat_minichat_reply_count, count, count), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + @Composable fun RenderReplyRow( note: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 4a73b11278..1bc7facd2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -21,15 +21,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar @@ -39,12 +45,15 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -146,6 +155,48 @@ fun ConcordChannelScreen( } } +/** + * A small segmented toggle shown above the composer while a reply is pending: send it + * as an inline message in the timeline, or pull the conversation aside into a minichat + * thread. Inline is the default; the user opts into the thread. + */ +@Composable +private fun ReplyModeToggle( + mode: ReplyMode, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + val minichat = mode == ReplyMode.MINICHAT + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable(onClick = onToggle), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) { + SymbolIcon( + symbol = if (minichat) MaterialSymbols.Forum else MaterialSymbols.Chat, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(14.dp), + ) + Text( + text = stringRes(if (minichat) com.vitorpamplona.amethyst.R.string.chat_reply_in_thread else com.vitorpamplona.amethyst.R.string.chat_reply_in_chat), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + @Composable private fun ConcordMessageComposer( newMessageModel: ConcordNewMessageViewModel, @@ -163,6 +214,10 @@ private fun ConcordMessageComposer( newMessageModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { newMessageModel.clearReply() } + ReplyModeToggle( + mode = newMessageModel.replyMode.value, + onToggle = { newMessageModel.toggleReplyMode() }, + ) } Column(modifier = EditFieldModifier) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index 0269a87713..bc22137a99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -55,6 +56,10 @@ open class ConcordNewMessageViewModel : ViewModel() { val message = TextFieldState() val replyTo = mutableStateOf(null) + // How the pending reply is delivered: INLINE stays in the timeline (kind-9 quote), + // MINICHAT pulls it into a thread (kind-1111). Only meaningful while replyTo is set. + val replyMode = mutableStateOf(ReplyMode.INLINE) + var userSuggestions: UserSuggestionState? = null open fun init(accountVM: AccountViewModel) { @@ -96,10 +101,22 @@ open class ConcordNewMessageViewModel : ViewModel() { fun reply(note: Note) { replyTo.value = note + replyMode.value = ReplyMode.INLINE + } + + /** Reply to [note] directly in a minichat thread (used from the minichat screen / long-press). */ + fun replyInMinichat(note: Note) { + replyTo.value = note + replyMode.value = ReplyMode.MINICHAT + } + + fun toggleReplyMode() { + replyMode.value = if (replyMode.value == ReplyMode.INLINE) ReplyMode.MINICHAT else ReplyMode.INLINE } fun clearReply() { replyTo.value = null + replyMode.value = ReplyMode.INLINE } fun editFromDraft(draftMessage: String) { @@ -134,10 +151,10 @@ open class ConcordNewMessageViewModel : ViewModel() { if (text.isEmpty()) return val parent = replyTo.value - account.sendConcordChannelMessage(community, channel, text, parent) + account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value) message.clearText() - replyTo.value = null + clearReply() userSuggestions?.reset() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index 521b12ae59..a4efd8291f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.quartz.nip22Comments.CommentEvent class ChannelFeedFilter( val channel: Channel, @@ -36,12 +37,16 @@ class ChannelFeedFilter( override fun changesFlow() = channel.changesFlow() - // returns the last Note of each user. - override fun feed(): List = sort(channel.notes.filterIntoSet { _, it -> account.isAcceptable(it) }) + // A kind-1111 comment is a *minichat* reply — it lives in the thread opened from its + // root message, not as a flat sibling in the main timeline (an inline reply is a normal + // kind-9/42 message and stays). Everything else the channel gathered is a timeline message. + private fun isTimelineMessage(note: Note): Boolean = note.event !is CommentEvent && account.isAcceptable(note) + + override fun feed(): List = sort(channel.notes.filterIntoSet { _, it -> isTimelineMessage(it) }) override fun applyFilter(newItems: Set): Set = newItems - .filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) } + .filter { channel.notes.containsKey(it.idHex) && isTimelineMessage(it) } .toSet() override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c0559e4be3..3309ae1179 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -349,6 +349,14 @@ By community Show each channel as its own conversation, mixed in with your chats. Collapse each community\'s channels into a single row, placed at its newest message. + + In chat + In thread + + + %1$d reply + %1$d replies + encrypted legacy Looking for the original message… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 6ee5ed6dd7..ecee9a7b9b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -135,6 +135,20 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } + /** Builds an encrypted-seal inline quote-reply wrap (kind-9 message quoting [parent] via `q`) on the [channel] plane. */ + suspend fun buildChannelInlineReply( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + parent: Event, + text: String, + createdAt: Long, + ): Event { + val rumor = ChannelChat.inlineReply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + /** Builds an encrypted-seal thread-reply wrap (kind-1111 NIP-22 comment on [parent]) on the [channel] plane. */ suspend fun buildChannelReply( authorSigner: NostrSigner, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ReplyMode.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ReplyMode.kt new file mode 100644 index 0000000000..65106e421a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ReplyMode.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels + +/** + * How a chat reply is delivered, chosen by the user at send time. + * + * - [INLINE] — a normal chat message that references its parent and stays in the + * main timeline (the chat protocol's native reply: NIP-C7 kind-9 `q` quote, + * kind-42 reply, kind-9 `+h` reply, kind-14 reply). The default. + * - [MINICHAT] — a kind-1111 NIP-22 comment rooted at the parent message, pulled + * out of the timeline into a "chat within a chat" (minichat) opened from the + * parent. Wire-compatible with Soapbox Armada's thread replies. + */ +enum class ReplyMode { + INLINE, + MINICHAT, +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index acc65d34f4..419469d4b1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -64,6 +64,32 @@ object ChannelChat { }, ) + /** + * Builds an unsigned kind-9 **inline quote-reply** to [parentId]: a normal + * channel [message] that quotes the parent via a `q` tag (NIP-C7) and credits + * its author with a `p` tag. Unlike [reply] (a kind-1111 thread comment pulled + * into a minichat), an inline quote stays in the main chat timeline — the two + * reply modes the composer offers. Matches Armada, where a kind-9 `q` is an + * inline quote deliberately kept out of threads. + */ + fun inlineReply( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + text: String, + parentId: HexKey, + parentAuthor: HexKey, + createdAt: Long, + ): Event = + message( + authorPubKey = authorPubKey, + channelId = channelId, + epoch = epoch, + text = text, + createdAt = createdAt, + extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)), + ) + /** * Builds an unsigned kind-1111 **thread reply** ([CommentEvent], NIP-22) to * [parent], bound to [channelId]/[epoch]. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt index ac202549c6..e2c554bf64 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -79,6 +79,26 @@ class ChannelChatEndToEndTest { assertEquals(0L, ChannelChat.epochOf(rumor)) } + @Test + fun inlineReplyIsAKind9QuoteWhileThreadReplyIsAKind1111Comment() { + val author = KeyPair().pubKey.toHexKey() + val parent = + ChannelChat.message(authorPubKey = author, channelId = channelIdHex, epoch = 0L, text = "root", createdAt = 1L) + + // Inline quote-reply: a normal kind-9 message, quoting the parent via `q`, still channel-bound. + val inline = ChannelChat.inlineReply(author, channelIdHex, 0L, "inline", parent.id, parent.pubKey, 2L) + assertEquals(9, inline.kind) + assertEquals(parent.id, inline.tags.first { it[0] == "q" }[1]) + assertTrue(ChannelChat.isBoundTo(inline, channelIdHex, 0L)) + + // Thread reply: a kind-1111 NIP-22 comment, uppercase `E` root + lowercase `e` parent, channel-bound. + val thread = ChannelChat.reply(author, channelIdHex, 0L, "thread", parent, 3L) + assertEquals(1111, thread.kind) + assertEquals(parent.id, thread.tags.first { it[0] == "E" }[1]) + assertEquals(parent.id, thread.tags.first { it[0] == "e" }[1]) + assertTrue(ChannelChat.isBoundTo(thread, channelIdHex, 0L)) + } + @Test fun nonMembersCannotDeriveThePlane() = runTest {