feat(reads): Phase 2 — editor, drafts, publish, highlights

- Create DraftLongTextNoteEvent (kind 30024) in quartz with EventFactory registration
- Create LongFormPublishAction for signing + publishing kind 30023 events
- Create split-pane ArticleEditorScreen with live markdown preview
- Create MarkdownToolbar (bold, italic, heading, link, image, code, quote)
- Create MetadataPanel (title, summary, banner, tags, slug)
- Create DesktopDraftStore with JSON index + .md files, atomic writes, slug sanitization
- Create DraftsScreen with draft list, delete, new draft button
- Create HighlightCreator dialog for kind 9802 highlight creation
- Add Drafts nav rail entry and Editor/Drafts screen routing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-24 06:25:39 +02:00
parent 5ef36363cd
commit 4fcbffdb3b
9 changed files with 1376 additions and 0 deletions
@@ -0,0 +1,161 @@
/*
* 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.quartz.nip23LongContent
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
import com.vitorpamplona.quartz.nip19Bech32.addressHints
import com.vitorpamplona.quartz.nip19Bech32.addressIds
import com.vitorpamplona.quartz.nip19Bech32.eventHints
import com.vitorpamplona.quartz.nip19Bech32.eventIds
import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints
import com.vitorpamplona.quartz.nip19Bech32.pubKeys
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* NIP-23 Draft Long-form Content (kind 30024).
* Same structure as LongTextNoteEvent (kind 30023) but for unpublished drafts.
*/
@Immutable
class DraftLongTextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
AddressableEvent,
EventHintProvider,
PubKeyHintProvider,
AddressHintProvider,
PublishedAtProvider,
RootScope,
SearchableEvent {
override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content
override fun eventHints(): List<EventIdHint> {
val qHints = tags.mapNotNull(QTag::parseEventAsHint)
val nip19Hints = citedNIP19().eventHints()
return qHints + nip19Hints
}
override fun linkedEventIds(): List<HexKey> {
val qHints = tags.mapNotNull(QTag::parseEventId)
val nip19Hints = citedNIP19().eventIds()
return qHints + nip19Hints
}
override fun addressHints(): List<AddressHint> {
val qHints = tags.mapNotNull(QTag::parseAddressAsHint)
val nip19Hints = citedNIP19().addressHints()
return qHints + nip19Hints
}
override fun linkedAddressIds(): List<String> {
val qHints = tags.mapNotNull(QTag::parseAddressId)
val nip19Hints = citedNIP19().addressIds()
return qHints + nip19Hints
}
override fun pubKeyHints(): List<PubKeyHint> {
val pHints = tags.mapNotNull(PTag::parseAsHint)
val nip19Hints = citedNIP19().pubKeyHints()
return pHints + nip19Hints
}
override fun linkedPubKeys(): List<HexKey> {
val pHints = tags.mapNotNull(PTag::parseKey)
val nip19Hints = citedNIP19().pubKeys()
return pHints + nip19Hints
}
override fun dTag() = tags.dTag()
override fun address() = Address(kind, pubKey, dTag())
override fun addressTag() = Address.assemble(kind, pubKey, dTag())
fun topics() = hashtags()
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse)
override fun publishedAt(): Long? {
val publishedAt = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
if (publishedAt == null) return null
return if (publishedAt <= createdAt) publishedAt else null
}
companion object {
const val KIND = 30024
@OptIn(ExperimentalUuidApi::class)
fun build(
description: String,
title: String,
summary: String? = null,
image: String? = null,
publishedAt: Long? = null,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<DraftLongTextNoteEvent>.() -> Unit = {},
) = eventTemplate(KIND, description, createdAt) {
dTag(dTag)
alt("Draft blog post: $title")
addUnique(TitleTag.assemble(title))
summary?.let { addUnique(SummaryTag.assemble(it)) }
image?.let { addUnique(ImageTag.assemble(it)) }
publishedAt?.let { addUnique(PublishedAtTag.assemble(it)) }
initializer()
}
}
}
@@ -58,6 +58,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.DraftLongTextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
@@ -272,6 +273,7 @@ class EventFactory {
LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
DraftLongTextNoteEvent.KIND -> DraftLongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig)
MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig)
MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig)