diff --git a/nostr/src/commonMain/kotlin/work/socialhub/planetlink/nostr/action/NostrCommentUpdateStream.kt b/nostr/src/commonMain/kotlin/work/socialhub/planetlink/nostr/action/NostrCommentUpdateStream.kt index 6530527..6fb92fd 100644 --- a/nostr/src/commonMain/kotlin/work/socialhub/planetlink/nostr/action/NostrCommentUpdateStream.kt +++ b/nostr/src/commonMain/kotlin/work/socialhub/planetlink/nostr/action/NostrCommentUpdateStream.kt @@ -19,6 +19,7 @@ import work.socialhub.planetlink.action.callback.comment.UpdateCommentCallback import work.socialhub.planetlink.action.callback.lifecycle.ConnectCallback import work.socialhub.planetlink.action.callback.lifecycle.DisconnectCallback import work.socialhub.planetlink.action.callback.lifecycle.ErrorCallback +import work.socialhub.planetlink.model.common.AttributedString import work.socialhub.planetlink.define.ServiceType import work.socialhub.planetlink.model.Comment import work.socialhub.planetlink.model.CommentUpdateStream @@ -222,6 +223,9 @@ internal class NostrCommentUpdateStream( } if (quotedEventId == note.event.id) { sharedComment = NostrMapper.comment(note, service, userMe) + text = text?.displayText?.let { content -> + AttributedString.plain(NostrMapper.stripQuoteReference(content, note.event.id)) + } return true } return (sharedComment as? NostrComment)?.applyNote(note) == true diff --git a/nostr/src/commonMain/kotlin/work/socialhub/planetlink/nostr/action/NostrMapper.kt b/nostr/src/commonMain/kotlin/work/socialhub/planetlink/nostr/action/NostrMapper.kt index 3d2f200..e8e2b13 100644 --- a/nostr/src/commonMain/kotlin/work/socialhub/planetlink/nostr/action/NostrMapper.kt +++ b/nostr/src/commonMain/kotlin/work/socialhub/planetlink/nostr/action/NostrMapper.kt @@ -5,6 +5,7 @@ import work.socialhub.knostr.social.model.NostrNote import work.socialhub.knostr.social.model.NostrReaction import work.socialhub.knostr.social.model.NostrRelationship import work.socialhub.knostr.social.model.NostrUser as KnostrUser +import work.socialhub.knostr.util.Nip21 import work.socialhub.planetlink.define.MediaType import work.socialhub.planetlink.model.Comment import work.socialhub.planetlink.model.ID @@ -25,6 +26,9 @@ import work.socialhub.planetlink.nostr.model.NostrUser /** Nostr エンティティのマッピング */ object NostrMapper { + private val NOSTR_EVENT_REFERENCE = + Regex("nostr:(?:note|nevent)1[ac-hj-np-z02-9]+", RegexOption.IGNORE_CASE) + /** ユーザーマッピング */ fun user( knostrUser: KnostrUser, @@ -184,7 +188,7 @@ object NostrMapper { .map { it[1].lowercase() } .toSet() - val attributed = AttributedString.plain(note.content) + val attributed = AttributedString.plain(displayContent(note)) val validated = attributed.elements.map { elem -> if (elem.kind == AttributedKind.HASH_TAG && elem is AttributedItem) { @@ -199,4 +203,24 @@ object NostrMapper { } return AttributedString(validated) } + + private fun displayContent(note: NostrNote): String { + val quotedEventId = note.quotedNote?.event?.id ?: return note.content + return stripQuoteReference(note.content, quotedEventId) + } + + internal fun stripQuoteReference( + content: String, + quotedEventId: String, + ): String { + val stripped = NOSTR_EVENT_REFERENCE.replace(content) { match -> + val referencedEventId = Nip21.extractEventIds(match.value.lowercase()).singleOrNull() + if (referencedEventId.equals(quotedEventId, ignoreCase = true)) { + "" + } else { + match.value + } + } + return if (stripped == content) content else stripped.trimEnd() + } } diff --git a/nostr/src/commonTest/kotlin/work/socialhub/planetlink/nostr/action/NostrMapperTest.kt b/nostr/src/commonTest/kotlin/work/socialhub/planetlink/nostr/action/NostrMapperTest.kt new file mode 100644 index 0000000..78fa854 --- /dev/null +++ b/nostr/src/commonTest/kotlin/work/socialhub/planetlink/nostr/action/NostrMapperTest.kt @@ -0,0 +1,153 @@ +package work.socialhub.planetlink.nostr.action + +import work.socialhub.knostr.entity.NostrEvent +import work.socialhub.knostr.social.model.NostrNote +import work.socialhub.knostr.util.Bech32 +import work.socialhub.knostr.util.Hex +import work.socialhub.planetlink.model.Account +import work.socialhub.planetlink.model.Service +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class NostrMapperTest { + + @Test + fun removesResolvedQuoteReferenceFromDisplayText() { + val quotedEventId = "22".repeat(32) + val quotedNote = note( + eventId = quotedEventId, + content = "Quoted content", + ) + val reference = nevent(quotedEventId) + val source = note( + eventId = "11".repeat(32), + content = "Additional comment\n\nnostr:$reference", + ).apply { + this.quotedEventId = quotedEventId + this.quotedNote = quotedNote + } + + val comment = NostrMapper.comment(source, service()) + + assertEquals("Additional comment", comment.text?.displayText) + assertNotNull(comment.sharedComment) + } + + @Test + fun preservesReferenceToAnotherEvent() { + val quotedEventId = "22".repeat(32) + val otherEventId = "33".repeat(32) + val otherReference = nevent(otherEventId) + val source = note( + eventId = "11".repeat(32), + content = "See also nostr:$otherReference", + ).apply { + this.quotedEventId = quotedEventId + this.quotedNote = note(quotedEventId, "Quoted content") + } + + val comment = NostrMapper.comment(source, service()) + + assertEquals("See also nostr:$otherReference", comment.text?.displayText) + } + + @Test + fun removesResolvedNote1QuoteReferenceFromDisplayText() { + val quotedEventId = "22".repeat(32) + val quotedNote = note( + eventId = quotedEventId, + content = "Quoted content", + ) + val reference = note1(quotedEventId) + val source = note( + eventId = "11".repeat(32), + content = "Additional comment\n\nnostr:$reference", + ).apply { + this.quotedEventId = quotedEventId + this.quotedNote = quotedNote + } + + val comment = NostrMapper.comment(source, service()) + + assertEquals("Additional comment", comment.text?.displayText) + assertNotNull(comment.sharedComment) + } + + @Test + fun removesResolvedUppercaseQuoteReferenceFromDisplayText() { + val quotedEventId = "22".repeat(32) + val quotedNote = note( + eventId = quotedEventId, + content = "Quoted content", + ) + val reference = nevent(quotedEventId).uppercase() + val source = note( + eventId = "11".repeat(32), + content = "Additional comment\n\nnostr:$reference", + ).apply { + this.quotedEventId = quotedEventId + this.quotedNote = quotedNote + } + + val comment = NostrMapper.comment(source, service()) + + assertEquals("Additional comment", comment.text?.displayText) + assertNotNull(comment.sharedComment) + } + + @Test + fun preservesQuoteReferenceUntilQuoteIsResolved() { + val quotedEventId = "22".repeat(32) + val reference = nevent(quotedEventId) + val source = note( + eventId = "11".repeat(32), + content = "Additional comment\n\nnostr:$reference", + ).apply { + this.quotedEventId = quotedEventId + } + + val comment = NostrMapper.comment(source, service()) + + assertEquals( + "Additional comment\n\nnostr:$reference", + comment.text?.displayText, + ) + } + + private fun note( + eventId: String, + content: String, + ): NostrNote { + return NostrNote().apply { + event = NostrEvent( + id = eventId, + pubkey = "44".repeat(32), + createdAt = 1_000, + kind = 1, + tags = emptyList(), + content = content, + sig = "55".repeat(64), + ) + this.content = content + createdAt = event.createdAt + noteId = Bech32.encode("note", Hex.decode(eventId)) + } + } + + private fun nevent(eventId: String): String { + val tlv = byteArrayOf(0, 32) + Hex.decode(eventId) + return Bech32.encode("nevent", tlv) + } + + private fun note1(eventId: String): String { + return Bech32.encode("note", Hex.decode(eventId)) + } + + private fun service(): Service { + val account = Account() + return Service("nostr", account).also { + account.service = it + } + } +}