add concord reactions and edits
This commit is contained in:
@@ -222,6 +222,9 @@ object ConcordTag {
|
||||
/** `["q", "<rumor id>", "", "<author>"]` — NIP-C7 inline quote. */
|
||||
const val QUOTE = "q"
|
||||
|
||||
/** `["e", "<rumor id>"]` — the message a reaction, edit or delete targets (CORD-03 §2.3–2.5). */
|
||||
const val E = "e"
|
||||
|
||||
/** `["vsk", "<n>"]` — Control edition entity type. */
|
||||
const val VSK = "vsk"
|
||||
|
||||
@@ -249,6 +252,6 @@ object ConcordTag {
|
||||
/** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */
|
||||
const val INVITE = "invite"
|
||||
|
||||
/** `["k", "3313"]` — the one deliberate outer-tag exception on a Direct Invite wrap. */
|
||||
/** `["k", "<kind>"]` — the target's kind on a reaction, the invite hint on a `3313` wrap. */
|
||||
const val K = "k"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.Filter
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.PublicKey
|
||||
@@ -283,14 +284,9 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
?: CommunityState(membership, null, emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* A Channel's messages, oldest first. Reads the local cache; the live subscription is what
|
||||
* keeps it current.
|
||||
*/
|
||||
/** A Channel's messages, oldest first, with reactions and edits folded on top. */
|
||||
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
|
||||
store?.cachedRumors(channelIdHex).orEmpty()
|
||||
.mapNotNull { it.toConcordMessage() }
|
||||
.sortedBy { it.timestampMs }
|
||||
store?.cachedRumors(channelIdHex).orEmpty().toChannelMessages()
|
||||
|
||||
/** A Channel's unread badge. See [ConcordChannel.unreadCount] for what it does and does not survive. */
|
||||
fun unreadCount(channelIdHex: String): Int = unread[channelIdHex] ?: 0
|
||||
@@ -303,34 +299,79 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to a Channel (CORD-03 §3).
|
||||
* Sends a message to a Channel (CORD-03 §3), and returns it as a reader will see it.
|
||||
*
|
||||
* The send *is* [PlaneKey.rumor] plus [PlaneKey.wrap], with nothing in between: the rumor carries
|
||||
* the `channel`/`epoch` binding stamped from the Channel's own key, and the wrap is signed by that
|
||||
* key's stream half, so a message cannot be built for a coordinate it will not verify at.
|
||||
*
|
||||
* The message is cached locally *before* it is published, so it is visible even if every relay is
|
||||
* unreachable and so the subscription's echo of the wrap lands on the same `d` slot instead of
|
||||
* duplicating it.
|
||||
*
|
||||
* Returns the message as a reader will see it. Throws when the Channel is hidden behind a key we
|
||||
* were never granted — a Private Channel is listable without being writable.
|
||||
* Throws when the Channel is hidden behind a key we were never granted — a Private Channel is
|
||||
* listable without being writable.
|
||||
*/
|
||||
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage {
|
||||
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage =
|
||||
publish(channelPlane(channelIdHex), ConcordKind.MESSAGE, content)
|
||||
.toConcordMessage()
|
||||
?: throw IllegalStateException("Concord: could not read back the message just sent")
|
||||
|
||||
/**
|
||||
* Reacts to a message (CORD-03 §2.3). The `e` tag names the target's *rumor* id, and `p`/`k`
|
||||
* carry its author and kind, which is what lets a reader group it without a lookup.
|
||||
*/
|
||||
suspend fun sendChannelReaction(
|
||||
channelIdHex: String,
|
||||
targetIdHex: String,
|
||||
targetAuthorHex: String,
|
||||
reaction: String,
|
||||
) {
|
||||
publish(
|
||||
plane = channelPlane(channelIdHex),
|
||||
kind = ConcordKind.REACTION,
|
||||
content = reaction,
|
||||
extraTags = listOf(
|
||||
Tag.event(EventId.parse(targetIdHex)),
|
||||
Tag.publicKey(PublicKey.parse(targetAuthorHex)),
|
||||
Tag.custom(ConcordTag.K, listOf(ConcordKind.MESSAGE.toString())),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one of our own messages (CORD-03 §2.5). The `e` tag names the message being edited;
|
||||
* the edit rides the same Channel plane, so it reaches exactly the readers the original did.
|
||||
*/
|
||||
suspend fun sendChannelEdit(channelIdHex: String, targetIdHex: String, content: String) {
|
||||
publish(
|
||||
plane = channelPlane(channelIdHex),
|
||||
kind = ConcordKind.EDIT,
|
||||
content = content,
|
||||
extraTags = listOf(Tag.event(EventId.parse(targetIdHex))),
|
||||
)
|
||||
}
|
||||
|
||||
private fun channelPlane(channelIdHex: String): Plane = planes.values.firstOrNull {
|
||||
it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true)
|
||||
} ?: throw IllegalArgumentException("That Channel is not one we hold a key for")
|
||||
|
||||
/**
|
||||
* The whole write path: build the rumor from the Channel's own key, wrap it under that key's
|
||||
* stream half, cache it locally, then publish to the Community's own relays.
|
||||
*
|
||||
* The rumor is cached *before* it is published, so it is visible even if every relay is
|
||||
* unreachable and so the subscription's echo of the wrap lands on the same `d` slot instead of
|
||||
* duplicating it. The relays are always the Community's, never the app's defaults.
|
||||
*/
|
||||
private suspend fun publish(
|
||||
plane: Plane,
|
||||
kind: Int,
|
||||
content: String,
|
||||
extraTags: List<Tag> = emptyList(),
|
||||
): UnsignedEvent {
|
||||
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
||||
val author =
|
||||
nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val plane = planes.values.firstOrNull {
|
||||
it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true)
|
||||
} ?: throw IllegalArgumentException("That Channel is not one we hold a key for")
|
||||
|
||||
|
||||
val rumor = plane.key.rumor(
|
||||
author = author,
|
||||
kind = ConcordKind.MESSAGE.toUShort(),
|
||||
kind = kind.toUShort(),
|
||||
content = content,
|
||||
createdAt = Clock.System.now(),
|
||||
extraTags = extraTags,
|
||||
)
|
||||
val wrap = plane.key.wrap(rumor, nostr.signer)
|
||||
|
||||
@@ -349,10 +390,9 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
event = wrap,
|
||||
target = SendEventTarget.to(relays),
|
||||
ackPolicy = AckPolicy.none()
|
||||
).failed.forEach { (relay, reason) -> println("Concord: $relay refused a message: $reason") }
|
||||
).failed.forEach { (relay, reason) -> println("Concord: $relay refused a write: $reason") }
|
||||
|
||||
return rumor.toConcordMessage()
|
||||
?: throw IllegalStateException("Concord: could not read back the message just sent")
|
||||
return rumor
|
||||
}
|
||||
|
||||
/** Records a badge change and re-publishes [communities] so a badge drawn from it moves. */
|
||||
@@ -565,7 +605,7 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
val relays = invite.relaySet(fallbackRelays)
|
||||
val problems =
|
||||
invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
|
||||
|
||||
|
||||
return InvitePreview(
|
||||
link = link,
|
||||
invite = invite,
|
||||
|
||||
@@ -141,6 +141,12 @@ data class CommunityState(
|
||||
val channels: List<ConcordChannel>,
|
||||
)
|
||||
|
||||
/** One emoji and everyone who used it on a message (CORD-03 §2.3), grouped for display. */
|
||||
data class ConcordReaction(
|
||||
val emoji: String,
|
||||
val authors: List<String>,
|
||||
)
|
||||
|
||||
/** A Chat-plane message, already unwrapped and signature-checked. */
|
||||
data class ConcordMessage(
|
||||
val idHex: String,
|
||||
@@ -149,6 +155,9 @@ data class ConcordMessage(
|
||||
val createdAt: Instant,
|
||||
/** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */
|
||||
val timestampMs: Long,
|
||||
val reactions: List<ConcordReaction> = emptyList(),
|
||||
/** True once the author's own Edit (kind `3302`) has replaced [content]. */
|
||||
val edited: Boolean = false,
|
||||
)
|
||||
|
||||
/** A redeemed-in-part invite, ready for the UI to preview before anything joins (CORD-05 §1). */
|
||||
@@ -187,3 +196,51 @@ internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
|
||||
timestampMs = concordTimestampMs(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects one Channel's cached rumors into the timeline: its messages, with reactions and edits
|
||||
* folded on top.
|
||||
*
|
||||
* Every one of these kinds names its target by the target's *rumor* id, never the outer wrap's —
|
||||
* that id differs on every re-wrap (CORD-03 §2). A reaction for a message we no longer hold is
|
||||
* dropped along with it. An Edit counts only from the message's own author: the `e` tag is a claim,
|
||||
* and authorship is what the seal actually proves.
|
||||
*/
|
||||
internal fun List<UnsignedEvent>.toChannelMessages(): List<ConcordMessage> {
|
||||
val messages = filter { it.isKind(ConcordKind.MESSAGE) }
|
||||
.mapNotNull { it.toConcordMessage() }
|
||||
.sortedBy { it.timestampMs }
|
||||
|
||||
val reactions = filter { it.isKind(ConcordKind.REACTION) }
|
||||
.mapNotNull { rumor -> rumor.targetHex()?.let { it to rumor } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, rumors) ->
|
||||
rumors.groupBy { it.content() }
|
||||
.map { (emoji, reactors) ->
|
||||
ConcordReaction(
|
||||
emoji = emoji.ifEmpty { "+" },
|
||||
authors = reactors.map { it.author().toHex() }.distinct(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val edits = filter { it.isKind(ConcordKind.EDIT) }
|
||||
.mapNotNull { rumor -> rumor.targetHex()?.let { it to rumor } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, candidates) -> candidates.maxBy { it.concordTimestampMs() } }
|
||||
|
||||
return messages.map { message ->
|
||||
val edit = edits[message.idHex]?.takeIf { it.author().toHex() == message.author }
|
||||
message.copy(
|
||||
content = edit?.content() ?: message.content,
|
||||
edited = edit != null,
|
||||
reactions = reactions[message.idHex].orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun UnsignedEvent.isKind(expected: Int): Boolean = kind().asU16() == expected.toUShort()
|
||||
|
||||
/** The target of a reaction, edit or delete: the first `e` tag (CORD-03 §2.3–2.5). */
|
||||
private fun UnsignedEvent.targetHex(): String? =
|
||||
tags().toVec().firstOrNull { it.kind() == ConcordTag.E }?.content()
|
||||
|
||||
@@ -50,6 +50,21 @@ class ConcordRepository(
|
||||
suspend fun sendMessage(channelIdHex: String, content: String): ConcordMessage? =
|
||||
attempt { concord.sendChannelMessage(channelIdHex, content) }
|
||||
|
||||
/** Reacts to one message; `targetAuthorHex` is the message's author, which rides the `p` tag. */
|
||||
suspend fun sendReaction(
|
||||
channelIdHex: String,
|
||||
targetIdHex: String,
|
||||
targetAuthorHex: String,
|
||||
reaction: String,
|
||||
) {
|
||||
attempt { concord.sendChannelReaction(channelIdHex, targetIdHex, targetAuthorHex, reaction) }
|
||||
}
|
||||
|
||||
/** Replaces one of our own messages with [content]. */
|
||||
suspend fun editMessage(channelIdHex: String, targetIdHex: String, content: String) {
|
||||
attempt { concord.sendChannelEdit(channelIdHex, targetIdHex, content) }
|
||||
}
|
||||
|
||||
/** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
|
||||
fun markChannelRead(channelIdHex: String) = concord.markChannelRead(channelIdHex)
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ class ChannelScreenViewModel(
|
||||
reloadJob?.cancel()
|
||||
reloadJob = viewModelScope.launch {
|
||||
val loaded = concordRepository.channelMessages(channelId)
|
||||
if (messages.map { it.idHex } != loaded.map { it.idHex }) {
|
||||
// The whole list, not just its ids: a reaction or an edit changes a message without
|
||||
// changing which messages there are.
|
||||
if (messages != loaded) {
|
||||
messages.clear()
|
||||
messages.addAll(loaded)
|
||||
}
|
||||
@@ -66,4 +68,16 @@ class ChannelScreenViewModel(
|
||||
if (text.isBlank()) return
|
||||
viewModelScope.launch { concordRepository.sendMessage(channelId, text) }
|
||||
}
|
||||
|
||||
fun sendReaction(message: ConcordMessage, reaction: String) {
|
||||
viewModelScope.launch {
|
||||
concordRepository.sendReaction(channelId, message.idHex, message.author, reaction)
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces one of our own messages; [targetIdHex] is the message being edited. */
|
||||
fun editMessage(targetIdHex: String, text: String) {
|
||||
if (text.isBlank()) return
|
||||
viewModelScope.launch { concordRepository.editMessage(channelId, targetIdHex, text) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user