feat(chat): dual-mode replies (inline + minichat) — foundation, Concord composer, shared chip

Adds the two-way reply model: an INLINE reply stays in the timeline (native chat
message referencing its parent), a MINICHAT reply is a kind-1111 NIP-22 comment
pulled into a thread opened from the parent — matching Armada.

Foundation (quartz/commons):
- ReplyMode enum (INLINE default, MINICHAT).
- ChannelChat.inlineReply / ConcordActions.buildChannelInlineReply restore the
  kind-9 q-tag quote path alongside the existing kind-1111 ChannelChat.reply.
- ChannelFeedFilter excludes kind-1111 CommentEvents from the chat timeline (they
  belong in the minichat), so inline replies stay and thread replies move aside.
- observeNoteMinichatReplyCount: local count of a message's kind-1111 replies.

Concord composer + send (amethyst):
- ConcordNewMessageViewModel gains a replyMode state + toggle; the composer shows a
  "In chat" / "In thread" toggle beside the reply preview.
- Account.sendConcordChannelMessage routes MINICHAT to kind-1111, INLINE to kind-9
  quote, fresh post to kind-9 message.

Shared row chip (all chat types):
- ChatMessageCompose's action row shows an "N replies" chip when a message has
  kind-1111 thread replies; tapping opens the thread. Wired to the thread view for
  now; a chat-styled minichat screen and NIP-28/NIP-29 loading follow.

Plan: amethyst/plans/2026-07-12-dual-reply-minichat.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-12 20:40:29 +00:00
parent 8065b045e4
commit 025d63672f
12 changed files with 394 additions and 9 deletions
@@ -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":[<visible
message ids>]}` (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.
@@ -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
@@ -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<Int> {
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,
@@ -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,
@@ -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) {
@@ -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<Note?>(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()
}
}
@@ -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<Note> = 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<Note> = sort(channel.notes.filterIntoSet { _, it -> isTimelineMessage(it) })
override fun applyFilter(newItems: Set<Note>): Set<Note> =
newItems
.filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) }
.filter { channel.notes.containsKey(it.idHex) && isTimelineMessage(it) }
.toSet()
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
+8
View File
@@ -349,6 +349,14 @@
<string name="concord_view_grouped">By community</string>
<string name="concord_view_inline_desc">Show each channel as its own conversation, mixed in with your chats.</string>
<string name="concord_view_grouped_desc">Collapse each community\'s channels into a single row, placed at its newest message.</string>
<!-- Reply-mode toggle in the chat composer: reply inline in the timeline vs pull it aside into a thread. -->
<string name="chat_reply_in_chat">In chat</string>
<string name="chat_reply_in_thread">In thread</string>
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
<plurals name="chat_minichat_reply_count">
<item quantity="one">%1$d reply</item>
<item quantity="other">%1$d replies</item>
</plurals>
<string name="chats_history_proto_nip17">encrypted</string>
<string name="chats_history_proto_nip04">legacy</string>
<string name="chats_reply_searching_history">Looking for the original message…</string>
@@ -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,
@@ -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,
}
@@ -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].
@@ -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 {