mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
+30
@@ -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,
|
||||
|
||||
+54
@@ -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,
|
||||
|
||||
+55
@@ -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) {
|
||||
|
||||
+19
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
+36
@@ -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,
|
||||
}
|
||||
+26
@@ -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].
|
||||
|
||||
+20
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user