Merge branch 'main' into chess-enhancements-and-bug-fixes
# Conflicts: # commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt
This commit is contained in:
+6
-6
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.chess
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -49,7 +49,7 @@ enum class RelayFetchStatus {
|
||||
/**
|
||||
* One-shot relay fetch helper for chess events.
|
||||
*
|
||||
* Follows the existing INostrClient + IRequestListener + Channel pattern
|
||||
* Follows the existing INostrClient + SubscriptionListener + Channel pattern
|
||||
* from quartz (see NostrClientSingleDownloadExt.kt).
|
||||
*
|
||||
* Each fetch opens a subscription, collects events until EOSE from all relays,
|
||||
@@ -62,7 +62,7 @@ class ChessRelayFetchHelper(
|
||||
/**
|
||||
* Fetch events matching filters from relays, waiting for EOSE.
|
||||
*
|
||||
* @param filters Map of relay → filter list (same format as INostrClient.openReqSubscription)
|
||||
* @param filters Map of relay → filter list (same format as INostrClient.subscribe)
|
||||
* @param timeoutMs Max time to wait for relays to respond (default from ChessConfig)
|
||||
* @param onProgress Optional callback for progress updates per relay
|
||||
* @return Deduplicated list of events received before timeout/EOSE
|
||||
@@ -88,7 +88,7 @@ class ChessRelayFetchHelper(
|
||||
}
|
||||
|
||||
val listener =
|
||||
object : IRequestListener {
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
@@ -114,7 +114,7 @@ class ChessRelayFetchHelper(
|
||||
}
|
||||
}
|
||||
|
||||
client.openReqSubscription(subId, filters, listener)
|
||||
client.subscribe(subId, filters, listener)
|
||||
val eoseResult = withTimeoutOrNull(timeoutMs) { allEose.await() }
|
||||
|
||||
// Mark timed-out relays
|
||||
@@ -127,7 +127,7 @@ class ChessRelayFetchHelper(
|
||||
}
|
||||
}
|
||||
|
||||
client.close(subId)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
return events.values.toList()
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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.compose.article
|
||||
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
|
||||
@Composable
|
||||
fun ArticleHeader(
|
||||
title: String,
|
||||
authorName: String?,
|
||||
authorPicture: String?,
|
||||
publishedAt: String?,
|
||||
readingTimeMinutes: Int?,
|
||||
bannerUrl: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
onAuthorClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Banner image
|
||||
if (!bannerUrl.isNullOrBlank() &&
|
||||
(bannerUrl.startsWith("https://") || bannerUrl.startsWith("http://"))
|
||||
) {
|
||||
AsyncImage(
|
||||
model = bannerUrl,
|
||||
contentDescription = "Article banner",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().height(300.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(
|
||||
text = title,
|
||||
style =
|
||||
MaterialTheme.typography.headlineLarge.copy(
|
||||
fontSize = 34.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Author + metadata row
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!authorPicture.isNullOrBlank() &&
|
||||
(authorPicture.startsWith("https://") || authorPicture.startsWith("http://"))
|
||||
) {
|
||||
AsyncImage(
|
||||
model = authorPicture,
|
||||
contentDescription = "Author",
|
||||
modifier = Modifier.size(40.dp).clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
|
||||
Column {
|
||||
if (!authorName.isNullOrBlank()) {
|
||||
Text(
|
||||
text = authorName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
)
|
||||
}
|
||||
|
||||
val metaParts = mutableListOf<String>()
|
||||
readingTimeMinutes?.let { metaParts.add("$it min read") }
|
||||
publishedAt?.let { metaParts.add(it) }
|
||||
|
||||
if (metaParts.isNotEmpty()) {
|
||||
Text(
|
||||
text = metaParts.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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.compose.article
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val HEADING_REGEX = Regex("^(#{1,6})\\s+(.+)")
|
||||
private val TRAILING_HASHES_REGEX = Regex("#+$")
|
||||
|
||||
data class TocEntry(
|
||||
val level: Int,
|
||||
val text: String,
|
||||
val index: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Extracts table of contents entries from markdown content.
|
||||
* Parses ATX headings (# H1, ## H2, etc.), skipping code blocks.
|
||||
*/
|
||||
fun extractTableOfContents(markdown: String): List<TocEntry> {
|
||||
val entries = mutableListOf<TocEntry>()
|
||||
var inCodeBlock = false
|
||||
var headingIndex = 0
|
||||
|
||||
markdown.lines().forEach { line ->
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.startsWith("```")) {
|
||||
inCodeBlock = !inCodeBlock
|
||||
return@forEach
|
||||
}
|
||||
if (inCodeBlock) return@forEach
|
||||
|
||||
val match = HEADING_REGEX.find(trimmed)
|
||||
if (match != null) {
|
||||
val level = match.groupValues[1].length
|
||||
val text =
|
||||
match.groupValues[2]
|
||||
.trim()
|
||||
.replace(TRAILING_HASHES_REGEX, "")
|
||||
.trim()
|
||||
if (text.isNotEmpty() && level <= 3) {
|
||||
entries.add(TocEntry(level = level, text = text, index = headingIndex))
|
||||
}
|
||||
headingIndex++
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TableOfContents(
|
||||
entries: List<TocEntry>,
|
||||
activeEntryIndex: Int?,
|
||||
onEntryClick: (TocEntry) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.width(240.dp)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(vertical = 16.dp),
|
||||
) {
|
||||
entries.forEach { entry ->
|
||||
val isActive = entry.index == activeEntryIndex
|
||||
val accentColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
Text(
|
||||
text = entry.text,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
|
||||
color =
|
||||
if (isActive) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable { onEntryClick(entry) }
|
||||
.padding(
|
||||
start = ((entry.level - 1) * 16).dp,
|
||||
top = 4.dp,
|
||||
bottom = 4.dp,
|
||||
end = 8.dp,
|
||||
).then(
|
||||
if (isActive) {
|
||||
Modifier.drawBehind {
|
||||
drawLine(
|
||||
color = accentColor,
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(0f, size.height),
|
||||
strokeWidth = 3.dp.toPx(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* 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.compose.editor
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
|
||||
/**
|
||||
* State holder for a markdown editor with selection-aware formatting.
|
||||
*
|
||||
* Solves the focus/selection bug: toolbar buttons steal focus from TextField,
|
||||
* collapsing the selection. We cache the last known selection on every value
|
||||
* change, and toolbar operations use the cached selection.
|
||||
*/
|
||||
class MarkdownEditorState(
|
||||
initial: String = "",
|
||||
) {
|
||||
var value by mutableStateOf(TextFieldValue(initial))
|
||||
private set
|
||||
|
||||
/** Cached selection — updated on every onValueChange, survives focus loss. */
|
||||
var lastSelection: TextRange = TextRange.Zero
|
||||
private set
|
||||
|
||||
fun onValueChange(newValue: TextFieldValue) {
|
||||
value = newValue
|
||||
// Only cache non-zero selections (focus loss sends collapsed range)
|
||||
if (newValue.selection.length > 0 || lastSelection == TextRange.Zero) {
|
||||
lastSelection = newValue.selection
|
||||
}
|
||||
// Also cache cursor position when no selection
|
||||
if (newValue.selection.collapsed) {
|
||||
lastSelection = newValue.selection
|
||||
}
|
||||
}
|
||||
|
||||
fun loadContent(content: String) {
|
||||
value = TextFieldValue(content)
|
||||
lastSelection = TextRange.Zero
|
||||
}
|
||||
|
||||
val text: String get() = value.text
|
||||
|
||||
// --- Active state detection (uses current value.selection for display) ---
|
||||
|
||||
val isBold: Boolean
|
||||
get() = isWrapped("**", "**")
|
||||
|
||||
val isItalic: Boolean
|
||||
get() = isWrappedItalic()
|
||||
|
||||
val isStrikethrough: Boolean
|
||||
get() = isWrapped("~~", "~~")
|
||||
|
||||
val isInlineCode: Boolean
|
||||
get() = isWrapped("`", "`")
|
||||
|
||||
val isBlockquote: Boolean
|
||||
get() = isLinePrefix("> ")
|
||||
|
||||
val isUnorderedList: Boolean
|
||||
get() = isLinePrefix("- ")
|
||||
|
||||
val isOrderedList: Boolean
|
||||
get() {
|
||||
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
|
||||
val line = text.substring(lineStart)
|
||||
return line.matches(Regex("^\\d+\\.\\s.*"))
|
||||
}
|
||||
|
||||
val isTaskList: Boolean
|
||||
get() = isLinePrefix("- [ ] ") || isLinePrefix("- [x] ")
|
||||
|
||||
val headingLevel: Int?
|
||||
get() {
|
||||
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
|
||||
val line = text.substring(lineStart)
|
||||
return when {
|
||||
line.startsWith("### ") -> 3
|
||||
line.startsWith("## ") -> 2
|
||||
line.startsWith("# ") -> 1
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Formatting operations (use lastSelection to survive focus loss) ---
|
||||
|
||||
fun toggleBold() {
|
||||
applyToggleWrap("**", "**")
|
||||
}
|
||||
|
||||
fun toggleItalic() {
|
||||
applyToggleWrapItalic()
|
||||
}
|
||||
|
||||
fun toggleStrikethrough() {
|
||||
applyToggleWrap("~~", "~~")
|
||||
}
|
||||
|
||||
fun toggleInlineCode() {
|
||||
applyToggleWrap("`", "`")
|
||||
}
|
||||
|
||||
fun setHeading(level: Int?) {
|
||||
val sel = lastSelection
|
||||
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
|
||||
val line = text.substring(lineStart)
|
||||
|
||||
// Remove existing heading prefix
|
||||
val stripped =
|
||||
when {
|
||||
line.startsWith("### ") -> line.removePrefix("### ")
|
||||
line.startsWith("## ") -> line.removePrefix("## ")
|
||||
line.startsWith("# ") -> line.removePrefix("# ")
|
||||
else -> line
|
||||
}
|
||||
val oldPrefixLen =
|
||||
when {
|
||||
line.startsWith("### ") -> 4
|
||||
line.startsWith("## ") -> 3
|
||||
line.startsWith("# ") -> 2
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val newPrefix =
|
||||
when (level) {
|
||||
1 -> "# "
|
||||
2 -> "## "
|
||||
3 -> "### "
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val lineEnd = text.indexOf('\n', lineStart).let { if (it == -1) text.length else it }
|
||||
val newText = text.substring(0, lineStart) + newPrefix + stripped + text.substring(lineEnd)
|
||||
val shift = newPrefix.length - oldPrefixLen
|
||||
|
||||
value =
|
||||
TextFieldValue(
|
||||
text = newText,
|
||||
selection = TextRange((sel.min + shift).coerceAtLeast(lineStart), (sel.max + shift).coerceAtLeast(lineStart)),
|
||||
)
|
||||
lastSelection = value.selection
|
||||
}
|
||||
|
||||
fun toggleBlockquote() {
|
||||
applyToggleLinePrefix("> ")
|
||||
}
|
||||
|
||||
fun toggleUnorderedList() {
|
||||
applyToggleLinePrefix("- ")
|
||||
}
|
||||
|
||||
fun toggleOrderedList() {
|
||||
val sel = lastSelection
|
||||
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
|
||||
val line = text.substring(lineStart)
|
||||
|
||||
if (line.matches(Regex("^\\d+\\.\\s.*"))) {
|
||||
// Remove ordered list prefix
|
||||
val prefixEnd = line.indexOf(". ") + 2
|
||||
val newText = text.substring(0, lineStart) + line.substring(prefixEnd) + text.substring(lineStart + line.indexOf('\n').let { if (it == -1) line.length else it })
|
||||
value =
|
||||
TextFieldValue(
|
||||
text = text.substring(0, lineStart) + line.substring(prefixEnd),
|
||||
selection = TextRange((sel.min - prefixEnd).coerceAtLeast(lineStart)),
|
||||
)
|
||||
} else {
|
||||
applyToggleLinePrefix("1. ")
|
||||
}
|
||||
lastSelection = value.selection
|
||||
}
|
||||
|
||||
fun toggleTaskList() {
|
||||
val sel = lastSelection
|
||||
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
|
||||
|
||||
when {
|
||||
text.startsWith("- [ ] ", lineStart) -> {
|
||||
// Remove task list prefix
|
||||
val newText = text.substring(0, lineStart) + text.substring(lineStart + 6)
|
||||
val shift = 6
|
||||
value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart)))
|
||||
lastSelection = value.selection
|
||||
}
|
||||
|
||||
text.startsWith("- [x] ", lineStart) -> {
|
||||
val newText = text.substring(0, lineStart) + text.substring(lineStart + 6)
|
||||
val shift = 6
|
||||
value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart)))
|
||||
lastSelection = value.selection
|
||||
}
|
||||
|
||||
else -> {
|
||||
applyToggleLinePrefix("- [ ] ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleCodeBlock() {
|
||||
applyToggleWrap("```\n", "\n```")
|
||||
}
|
||||
|
||||
fun insertHorizontalRule() {
|
||||
val sel = lastSelection
|
||||
val insert = "\n---\n"
|
||||
val newText = text.substring(0, sel.min) + insert + text.substring(sel.max)
|
||||
value = TextFieldValue(text = newText, selection = TextRange(sel.min + insert.length))
|
||||
lastSelection = value.selection
|
||||
}
|
||||
|
||||
fun insertLink() {
|
||||
val sel = lastSelection
|
||||
val selected = text.substring(sel.min, sel.max)
|
||||
|
||||
if (selected.isNotEmpty()) {
|
||||
val newText = text.substring(0, sel.min) + "[$selected](url)" + text.substring(sel.max)
|
||||
val urlStart = sel.min + selected.length + 3
|
||||
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
|
||||
} else {
|
||||
val newText = text.substring(0, sel.min) + "[](url)" + text.substring(sel.min)
|
||||
value = TextFieldValue(text = newText, selection = TextRange(sel.min + 1))
|
||||
}
|
||||
lastSelection = value.selection
|
||||
}
|
||||
|
||||
fun insertImage() {
|
||||
val sel = lastSelection
|
||||
val selected = text.substring(sel.min, sel.max)
|
||||
|
||||
if (selected.isNotEmpty()) {
|
||||
val newText = text.substring(0, sel.min) + "" + text.substring(sel.max)
|
||||
val urlStart = sel.min + selected.length + 4
|
||||
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
|
||||
} else {
|
||||
val newText = text.substring(0, sel.min) + "" + text.substring(sel.min)
|
||||
val urlStart = sel.min + 7
|
||||
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
|
||||
}
|
||||
lastSelection = value.selection
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private fun isWrapped(
|
||||
prefix: String,
|
||||
suffix: String,
|
||||
): Boolean {
|
||||
val sel = value.selection
|
||||
val start = sel.min
|
||||
val end = sel.max
|
||||
return start >= prefix.length &&
|
||||
end + suffix.length <= text.length &&
|
||||
text.substring(start - prefix.length, start) == prefix &&
|
||||
text.substring(end, end + suffix.length) == suffix
|
||||
}
|
||||
|
||||
private fun isWrappedItalic(): Boolean {
|
||||
val sel = value.selection
|
||||
val start = sel.min
|
||||
val end = sel.max
|
||||
if (start < 1 || end + 1 > text.length) return false
|
||||
if (text[start - 1] != '*' || text[end] != '*') return false
|
||||
val hasBoldBefore = start >= 2 && text[start - 2] == '*'
|
||||
val hasBoldAfter = end + 1 < text.length && text[end + 1] == '*'
|
||||
return !hasBoldBefore && !hasBoldAfter
|
||||
}
|
||||
|
||||
private fun isLinePrefix(prefix: String): Boolean {
|
||||
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
|
||||
return text.startsWith(prefix, lineStart)
|
||||
}
|
||||
|
||||
private fun applyToggleWrap(
|
||||
prefix: String,
|
||||
suffix: String,
|
||||
) {
|
||||
val sel = lastSelection
|
||||
val start = sel.min
|
||||
val end = sel.max
|
||||
|
||||
val wrapped =
|
||||
start >= prefix.length &&
|
||||
end + suffix.length <= text.length &&
|
||||
text.substring(start - prefix.length, start) == prefix &&
|
||||
text.substring(end, end + suffix.length) == suffix
|
||||
|
||||
value =
|
||||
if (wrapped) {
|
||||
val newText =
|
||||
text.substring(0, start - prefix.length) +
|
||||
text.substring(start, end) +
|
||||
text.substring(end + suffix.length)
|
||||
TextFieldValue(newText, TextRange(start - prefix.length, end - prefix.length))
|
||||
} else if (start == end) {
|
||||
val newText = text.substring(0, start) + prefix + suffix + text.substring(start)
|
||||
TextFieldValue(newText, TextRange(start + prefix.length))
|
||||
} else {
|
||||
val newText = text.substring(0, start) + prefix + text.substring(start, end) + suffix + text.substring(end)
|
||||
TextFieldValue(newText, TextRange(start + prefix.length, end + prefix.length))
|
||||
}
|
||||
lastSelection = value.selection
|
||||
}
|
||||
|
||||
private fun applyToggleWrapItalic() {
|
||||
val sel = lastSelection
|
||||
val start = sel.min
|
||||
val end = sel.max
|
||||
|
||||
val isItalic =
|
||||
start >= 1 &&
|
||||
end + 1 <= text.length &&
|
||||
text[start - 1] == '*' &&
|
||||
text[end] == '*' &&
|
||||
!(start >= 2 && text[start - 2] == '*') &&
|
||||
!(end + 1 < text.length && text[end + 1] == '*')
|
||||
|
||||
if (isItalic) {
|
||||
val newText = text.substring(0, start - 1) + text.substring(start, end) + text.substring(end + 1)
|
||||
value = TextFieldValue(newText, TextRange(start - 1, end - 1))
|
||||
} else {
|
||||
applyToggleWrap("*", "*")
|
||||
return
|
||||
}
|
||||
lastSelection = value.selection
|
||||
}
|
||||
|
||||
private fun applyToggleLinePrefix(prefix: String) {
|
||||
val sel = lastSelection
|
||||
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
|
||||
|
||||
value =
|
||||
if (text.startsWith(prefix, lineStart)) {
|
||||
val newText = text.substring(0, lineStart) + text.substring(lineStart + prefix.length)
|
||||
val shift = prefix.length
|
||||
TextFieldValue(newText, TextRange((sel.min - shift).coerceAtLeast(lineStart), (sel.max - shift).coerceAtLeast(lineStart)))
|
||||
} else {
|
||||
val newText = text.substring(0, lineStart) + prefix + text.substring(lineStart)
|
||||
TextFieldValue(newText, TextRange(sel.min + prefix.length, sel.max + prefix.length))
|
||||
}
|
||||
lastSelection = value.selection
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* 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.compose.editor
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.FormatBold
|
||||
import androidx.compose.material.icons.filled.FormatItalic
|
||||
import androidx.compose.material.icons.filled.FormatListNumbered
|
||||
import androidx.compose.material.icons.filled.FormatQuote
|
||||
import androidx.compose.material.icons.filled.FormatStrikethrough
|
||||
import androidx.compose.material.icons.filled.HorizontalRule
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Markdown toolbar with Material icons, grouped formatting buttons, and active state.
|
||||
* Uses [MarkdownEditorState] for selection-aware toggle behavior.
|
||||
*
|
||||
* Buttons use `focusProperties { canFocus = false }` to prevent stealing focus
|
||||
* from the editor TextField, preserving the user's text selection.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownToolbar(
|
||||
state: MarkdownEditorState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// --- Headings ---
|
||||
ToolbarButton(label = "H1", active = state.headingLevel == 1) { state.setHeading(if (state.headingLevel == 1) null else 1) }
|
||||
ToolbarButton(label = "H2", active = state.headingLevel == 2) { state.setHeading(if (state.headingLevel == 2) null else 2) }
|
||||
ToolbarButton(label = "H3", active = state.headingLevel == 3) { state.setHeading(if (state.headingLevel == 3) null else 3) }
|
||||
|
||||
Separator()
|
||||
|
||||
// --- Inline formatting ---
|
||||
ToolbarIconButton(Icons.Default.FormatBold, "Bold", state.isBold) { state.toggleBold() }
|
||||
ToolbarIconButton(Icons.Default.FormatItalic, "Italic", state.isItalic) { state.toggleItalic() }
|
||||
ToolbarIconButton(Icons.Default.FormatStrikethrough, "Strikethrough", state.isStrikethrough) { state.toggleStrikethrough() }
|
||||
ToolbarIconButton(Icons.Default.Code, "Inline code", state.isInlineCode) { state.toggleInlineCode() }
|
||||
|
||||
Separator()
|
||||
|
||||
// --- Lists ---
|
||||
ToolbarIconButton(Icons.AutoMirrored.Filled.FormatListBulleted, "Bullet list", state.isUnorderedList) { state.toggleUnorderedList() }
|
||||
ToolbarIconButton(Icons.Default.FormatListNumbered, "Numbered list", state.isOrderedList) { state.toggleOrderedList() }
|
||||
ToolbarIconButton(Icons.Default.Checklist, "Task list", state.isTaskList) { state.toggleTaskList() }
|
||||
|
||||
Separator()
|
||||
|
||||
// --- Block elements ---
|
||||
ToolbarIconButton(Icons.Default.FormatQuote, "Blockquote", state.isBlockquote) { state.toggleBlockquote() }
|
||||
ToolbarButton(label = "```", active = false) { state.toggleCodeBlock() }
|
||||
ToolbarIconButton(Icons.Default.HorizontalRule, "Horizontal rule", false) { state.insertHorizontalRule() }
|
||||
|
||||
Separator()
|
||||
|
||||
// --- Insert ---
|
||||
ToolbarIconButton(Icons.Default.Link, "Link", false) { state.insertLink() }
|
||||
ToolbarIconButton(Icons.Default.Image, "Image", false) { state.insertImage() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Separator() {
|
||||
VerticalDivider(
|
||||
modifier = Modifier.height(24.dp).padding(horizontal = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolbarIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val containerColor =
|
||||
if (active) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val contentColor =
|
||||
if (active) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
SmallFloatingActionButton(
|
||||
onClick = onClick,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(32.dp)
|
||||
.focusProperties { canFocus = false },
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolbarButton(
|
||||
label: String,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val containerColor =
|
||||
if (active) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val contentColor =
|
||||
if (active) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
SmallFloatingActionButton(
|
||||
onClick = onClick,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(32.dp)
|
||||
.focusProperties { canFocus = false },
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 11.sp,
|
||||
color = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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.compose.editor
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Form fields for article metadata: title, summary, banner, tags, slug.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MetadataPanel(
|
||||
title: String,
|
||||
onTitleChange: (String) -> Unit,
|
||||
summary: String,
|
||||
onSummaryChange: (String) -> Unit,
|
||||
bannerUrl: String,
|
||||
onBannerUrlChange: (String) -> Unit,
|
||||
tags: List<String>,
|
||||
onTagsChange: (List<String>) -> Unit,
|
||||
slug: String,
|
||||
onSlugChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var tagInput by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { if (it.length <= 256) onTitleChange(it) },
|
||||
label = { Text("Title") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
supportingText = { Text("${title.length}/256") },
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = summary,
|
||||
onValueChange = { if (it.length <= 1024) onSummaryChange(it) },
|
||||
label = { Text("Summary") },
|
||||
maxLines = 3,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
supportingText = { Text("${summary.length}/1024") },
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = bannerUrl,
|
||||
onValueChange = onBannerUrlChange,
|
||||
label = { Text("Banner Image URL") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// Tags chip input
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = tagInput,
|
||||
onValueChange = { tagInput = it },
|
||||
label = { Text("Add tag (Enter to add)") },
|
||||
singleLine = true,
|
||||
modifier =
|
||||
Modifier.weight(1f).onKeyEvent { event ->
|
||||
if (event.key == Key.Enter && tagInput.isNotBlank()) {
|
||||
val newTag = tagInput.trim().lowercase()
|
||||
if (newTag !in tags) {
|
||||
onTagsChange(tags + newTag)
|
||||
}
|
||||
tagInput = ""
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (tags.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
tags.forEach { tag ->
|
||||
AssistChip(
|
||||
onClick = { onTagsChange(tags - tag) },
|
||||
label = { Text(tag) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove $tag",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = slug,
|
||||
onValueChange = onSlugChange,
|
||||
label = { Text("Slug (d-tag)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
supportingText = { Text("Used as the unique identifier for this article") },
|
||||
)
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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.compose.markdown
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
|
||||
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
|
||||
import com.halilibo.richtext.markdown.BasicMarkdown
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material3.RichText
|
||||
|
||||
private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning", "highlight")
|
||||
|
||||
/**
|
||||
* Escapes markdown special characters inside highlighted text so it doesn't
|
||||
* break the markdown parser when wrapped in a link.
|
||||
*/
|
||||
private fun escapeMarkdownInLink(text: String): String =
|
||||
text
|
||||
.replace("[", "\\[")
|
||||
.replace("]", "\\]")
|
||||
.replace("(", "\\(")
|
||||
.replace(")", "\\)")
|
||||
|
||||
@Composable
|
||||
fun RenderMarkdown(
|
||||
content: String,
|
||||
onLinkClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
fontScale: Float = 1.0f,
|
||||
highlightedTexts: List<String> = emptyList(),
|
||||
) {
|
||||
val processedContent =
|
||||
remember(content, highlightedTexts) {
|
||||
if (highlightedTexts.isEmpty()) {
|
||||
content
|
||||
} else {
|
||||
var result = content
|
||||
highlightedTexts.sortedByDescending { it.length }.forEachIndexed { index, text ->
|
||||
val idx = result.indexOf(text)
|
||||
if (idx >= 0) {
|
||||
val escaped = escapeMarkdownInLink(text)
|
||||
result = result.replaceFirst(text, "[$escaped](highlight://$index)")
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
val astNode =
|
||||
remember(processedContent) {
|
||||
CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(processedContent)
|
||||
}
|
||||
|
||||
val uriHandler =
|
||||
remember(onLinkClick) {
|
||||
object : UriHandler {
|
||||
override fun openUri(uri: String) {
|
||||
val scheme = uri.substringBefore(":").lowercase()
|
||||
if (scheme in ALLOWED_SCHEMES) {
|
||||
onLinkClick(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val currentDensity = LocalDensity.current
|
||||
val scaledDensity =
|
||||
remember(fontScale, currentDensity) {
|
||||
if (fontScale == 1.0f) {
|
||||
currentDensity
|
||||
} else {
|
||||
Density(
|
||||
density = currentDensity.density * fontScale,
|
||||
fontScale = currentDensity.fontScale,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalUriHandler provides uriHandler,
|
||||
LocalDensity provides scaledDensity,
|
||||
) {
|
||||
RichText(
|
||||
modifier = modifier,
|
||||
style = RichTextStyle(),
|
||||
) {
|
||||
BasicMarkdown(astNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-10
@@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.StaticSubscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
@@ -122,14 +122,17 @@ object NostrConnectLoginUseCase {
|
||||
val deferred = CompletableDeferred<NostrConnectEvent>()
|
||||
|
||||
val subscription =
|
||||
client.req(
|
||||
relays = relays.toList(),
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(NostrConnectEvent.KIND),
|
||||
tags = mapOf("p" to listOf(ephemeralPubKey)),
|
||||
since = TimeUtils.now() - 60,
|
||||
),
|
||||
StaticSubscription(
|
||||
client,
|
||||
relays.associateWith {
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(NostrConnectEvent.KIND),
|
||||
tags = mapOf("p" to listOf(ephemeralPubKey)),
|
||||
since = TimeUtils.now() - 60,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { event ->
|
||||
if (event is NostrConnectEvent && !deferred.isCompleted) {
|
||||
deferred.complete(event)
|
||||
@@ -207,7 +210,7 @@ object NostrConnectLoginUseCase {
|
||||
remoteKey = connectData.signerPubkey,
|
||||
signer = ephemeralSigner,
|
||||
)
|
||||
client.send(ackEvent, relays)
|
||||
client.publish(ackEvent, relays)
|
||||
}
|
||||
|
||||
private fun generateSecret(): String {
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ class ThreadAssembler(
|
||||
?.getOrNull(1)
|
||||
if (markedAsRoot != null) {
|
||||
// Check to see if there is an error in the tag and the root has replies
|
||||
val rootNote = cache.getNoteIfExists(markedAsRoot) as? Note
|
||||
val rootNote = cache.getNoteIfExists(markedAsRoot)
|
||||
if (rootNote?.replyTo?.isEmpty() == true) {
|
||||
return cache.checkGetOrCreateNote(markedAsRoot)
|
||||
}
|
||||
|
||||
Vendored
+4
-4
@@ -57,7 +57,7 @@ interface ICacheProvider {
|
||||
* @param pubkey The user's public key in hex format
|
||||
* @return The User if exists in cache, null otherwise
|
||||
*/
|
||||
fun getUserIfExists(pubkey: HexKey): Any?
|
||||
fun getUserIfExists(pubkey: HexKey): User?
|
||||
|
||||
/**
|
||||
* Counts users matching a predicate.
|
||||
@@ -75,7 +75,7 @@ interface ICacheProvider {
|
||||
* @param hexKey The note's ID in hex format
|
||||
* @return The Note if exists in cache, null otherwise
|
||||
*/
|
||||
fun getNoteIfExists(hexKey: HexKey): Any?
|
||||
fun getNoteIfExists(hexKey: HexKey): Note?
|
||||
|
||||
/**
|
||||
* Gets an existing Note or creates a new one if it doesn't exist.
|
||||
@@ -123,7 +123,7 @@ interface ICacheProvider {
|
||||
fun findUsersStartingWith(
|
||||
prefix: String,
|
||||
limit: Int = 50,
|
||||
): List<Any> = emptyList()
|
||||
): List<User> = emptyList()
|
||||
|
||||
/**
|
||||
* Gets or creates a User by public key hex.
|
||||
@@ -132,7 +132,7 @@ interface ICacheProvider {
|
||||
* @param pubkey The user's public key in hex format
|
||||
* @return The User (existing or newly created)
|
||||
*/
|
||||
fun getOrCreateUser(pubkey: HexKey): Any?
|
||||
fun getOrCreateUser(pubkey: HexKey): User?
|
||||
|
||||
fun justConsumeMyOwnEvent(event: Event): Boolean
|
||||
}
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ class EphemeralChatListState(
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start")
|
||||
getEphemeralChatListFlow().collect { noteState ->
|
||||
Log.d("AccountRegisterObservers", "EphemeralChatList List for ${signer.pubKey}")
|
||||
Log.d("AccountRegisterObservers") { "EphemeralChatList List for ${signer.pubKey}" }
|
||||
(noteState.note.event as? EphemeralChatListEvent)?.let {
|
||||
settings.updateEphemeralChatListTo(it)
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.model.highlights
|
||||
|
||||
data class HighlightData(
|
||||
val id: String,
|
||||
val text: String,
|
||||
val note: String? = null,
|
||||
val articleAddressTag: String,
|
||||
val articleTitle: String? = null,
|
||||
val createdAt: Long,
|
||||
val published: Boolean = false,
|
||||
val eventId: String? = null,
|
||||
)
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.model.nip02FollowList
|
||||
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
|
||||
/**
|
||||
* Narrow repository interface for Kind3FollowListState's settings needs.
|
||||
* Follows the established pattern of EphemeralChatRepository / PublicChatListRepository.
|
||||
*/
|
||||
interface Kind3FollowListRepository {
|
||||
val backupContactList: ContactListEvent?
|
||||
|
||||
fun updateContactListTo(event: ContactListEvent)
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* 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.model.nip02FollowList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class Kind3FollowListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: ICacheProvider,
|
||||
val scope: CoroutineScope,
|
||||
val settings: Kind3FollowListRepository,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
|
||||
val user = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
|
||||
val note = cache.getOrCreateAddressableNote(getFollowListAddress())
|
||||
|
||||
fun getFollowListAddress() = ContactListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getFollowListFlow(): StateFlow<NoteState> = note.flow().metadata.stateFlow
|
||||
|
||||
fun getFollowListEvent(): ContactListEvent? = note.event as? ContactListEvent
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val innerFlow: Flow<Kind3Follows> =
|
||||
getFollowListFlow().transformLatest {
|
||||
emit(buildKind3Follows(it.note.event as? ContactListEvent ?: settings.backupContactList))
|
||||
}
|
||||
|
||||
val flow =
|
||||
innerFlow
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
// this has priority.
|
||||
buildKind3Follows(getFollowListEvent() ?: settings.backupContactList),
|
||||
)
|
||||
|
||||
// Creates a long-term reference for all follows of a user
|
||||
val userList =
|
||||
flow
|
||||
.map { kind3Follows ->
|
||||
kind3Follows.authors.mapNotNull {
|
||||
runCatching { cache.getOrCreateUser(it) }.getOrNull()
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
// this has priority.
|
||||
flow.value.authors.mapNotNull {
|
||||
runCatching { cache.getOrCreateUser(it) }.getOrNull()
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
This contains a big OR of everything the user wants to see in the a single feed.
|
||||
*/
|
||||
@Immutable
|
||||
class Kind3Follows(
|
||||
val authors: Set<HexKey> = emptySet(),
|
||||
val authorsPlusMe: Set<HexKey>,
|
||||
)
|
||||
|
||||
fun buildKind3Follows(latestContactList: ContactListEvent?): Kind3Follows {
|
||||
// makes sure the output include only valid p tags
|
||||
val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet()
|
||||
|
||||
return Kind3Follows(
|
||||
authors = verifiedFollowingUsers,
|
||||
authorsPlusMe = verifiedFollowingUsers + signer.pubKey,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun follow(users: List<User>): ContactListEvent {
|
||||
val contactList = getFollowListEvent()
|
||||
|
||||
val contacts =
|
||||
users.map {
|
||||
ContactTag(it.pubkeyHex, it.bestRelayHint(), null)
|
||||
}
|
||||
|
||||
return if (contactList != null) {
|
||||
ContactListEvent.followUsers(contactList, contacts, signer)
|
||||
} else {
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = contacts,
|
||||
relayUse = emptyMap(),
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun follow(user: User): ContactListEvent {
|
||||
val contactList = getFollowListEvent()
|
||||
|
||||
return if (contactList != null) {
|
||||
ContactListEvent.followUser(contactList, user.pubkeyHex, signer)
|
||||
} else {
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)),
|
||||
relayUse = emptyMap(),
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(user: User): ContactListEvent? {
|
||||
val contactList = getFollowListEvent()
|
||||
|
||||
return if (contactList != null && contactList.tags.isNotEmpty()) {
|
||||
ContactListEvent.unfollowUser(
|
||||
contactList,
|
||||
user.pubkeyHex,
|
||||
signer,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupContactList?.let {
|
||||
Log.d("AccountRegisterObservers") { "Loading saved ${it.tags.size} contacts" }
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
|
||||
}
|
||||
|
||||
// saves contact list for the next time.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "Kind 3 Collector Start")
|
||||
getFollowListFlow().collect {
|
||||
Log.d("AccountRegisterObservers") { "Updating Kind 3 ${signer.pubKey}" }
|
||||
(it.note.event as? ContactListEvent)?.let {
|
||||
settings.updateContactListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -49,11 +49,11 @@ sealed interface Nip05State {
|
||||
|
||||
fun reset() = verificationState.tryEmit(Nip05VerifState.NotStarted)
|
||||
|
||||
suspend fun checkAndUpdate(nip05Client: INip05Client) {
|
||||
suspend fun checkAndUpdate(nip05ClientBuilder: () -> INip05Client) {
|
||||
if (verificationState.value.isExpired()) {
|
||||
markAsVerifying()
|
||||
try {
|
||||
if (nip05Client.verify(nip05, hexKey)) {
|
||||
if (nip05ClientBuilder().verify(nip05, hexKey)) {
|
||||
markAsVerified()
|
||||
} else {
|
||||
markAsInvalid()
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.model.nip23LongContent
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Shared action for publishing long-form content (NIP-23 kind 30023).
|
||||
* Handles title, summary, image, tags, and d-tag for addressable events.
|
||||
*/
|
||||
object LongFormPublishAction {
|
||||
private const val MAX_CONTENT_BYTES = 100_000
|
||||
|
||||
/**
|
||||
* Publishes a long-form text note (NIP-23 kind 30023).
|
||||
*
|
||||
* @param title The article title
|
||||
* @param content The markdown body content
|
||||
* @param summary Optional article summary
|
||||
* @param image Optional banner image URL
|
||||
* @param tags List of hashtag topics
|
||||
* @param dTag Unique identifier for this addressable event (slug)
|
||||
* @param signer The NostrSigner to sign the event
|
||||
* @return Signed LongTextNoteEvent ready to broadcast
|
||||
* @throws IllegalStateException if signer is not writeable
|
||||
*/
|
||||
suspend fun publish(
|
||||
title: String,
|
||||
content: String,
|
||||
summary: String?,
|
||||
image: String?,
|
||||
tags: List<String>,
|
||||
dTag: String,
|
||||
signer: NostrSigner,
|
||||
): LongTextNoteEvent {
|
||||
if (!signer.isWriteable()) {
|
||||
throw IllegalStateException("Cannot publish: signer is not writeable")
|
||||
}
|
||||
|
||||
if (content.toByteArray().size > MAX_CONTENT_BYTES) {
|
||||
throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes")
|
||||
}
|
||||
|
||||
val template =
|
||||
LongTextNoteEvent.build(
|
||||
description = content,
|
||||
title = title,
|
||||
summary = summary,
|
||||
image = image,
|
||||
publishedAt = TimeUtils.now(),
|
||||
dTag = dTag,
|
||||
) {
|
||||
tags.forEach { hashtag(it) }
|
||||
}
|
||||
|
||||
return signer.sign(template)
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.model.nip23LongContent
|
||||
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Calculates estimated reading time for markdown content.
|
||||
* Uses 238 WPM for prose (Brysbaert 2019), 80 WPM for code blocks,
|
||||
* and Medium's image decay formula (12 sec first, -1 each, min 3).
|
||||
*/
|
||||
object ReadingTimeCalculator {
|
||||
private const val PROSE_WPM = 238.0
|
||||
private const val CODE_WPM = 80.0
|
||||
|
||||
private val WHITESPACE_REGEX = "\\s+".toRegex()
|
||||
private val IMAGE_REGEX = Regex("!\\[.*?]\\(.*?\\)")
|
||||
private val IMAGE_STRIP_REGEX = Regex("!\\[.*?]\\(.*?\\)")
|
||||
private val LINK_REGEX = Regex("\\[([^]]*)]\\([^)]*\\)")
|
||||
private val FORMATTING_REGEX = Regex("[*_~`#>]")
|
||||
private val LIST_MARKER_REGEX = Regex("^-\\s+|^\\d+\\.\\s+")
|
||||
private val HORIZONTAL_RULE_REGEX = Regex("^---+$|^\\*\\*\\*+$")
|
||||
|
||||
fun calculate(markdownContent: String): Int {
|
||||
var proseWords = 0
|
||||
var codeWords = 0
|
||||
var imageCount = 0
|
||||
var inCodeBlock = false
|
||||
|
||||
markdownContent.lines().forEach { line ->
|
||||
val trimmed = line.trim()
|
||||
|
||||
if (trimmed.startsWith("```")) {
|
||||
inCodeBlock = !inCodeBlock
|
||||
return@forEach
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeWords += trimmed.split(WHITESPACE_REGEX).count { it.isNotBlank() }
|
||||
return@forEach
|
||||
}
|
||||
|
||||
// Count images
|
||||
val imageMatches = IMAGE_REGEX.findAll(trimmed)
|
||||
imageCount += imageMatches.count()
|
||||
|
||||
// Strip markdown syntax for word counting
|
||||
val stripped =
|
||||
trimmed
|
||||
.replace(IMAGE_STRIP_REGEX, "") // images
|
||||
.replace(LINK_REGEX, "$1") // links -> text only
|
||||
.replace(FORMATTING_REGEX, "") // formatting
|
||||
.replace(LIST_MARKER_REGEX, "") // list markers
|
||||
.replace(HORIZONTAL_RULE_REGEX, "") // horizontal rules
|
||||
|
||||
proseWords += stripped.split(WHITESPACE_REGEX).count { it.isNotBlank() }
|
||||
}
|
||||
|
||||
// Medium's image time decay: 12 sec first, -1 each, min 3
|
||||
val imageSeconds = (0 until imageCount).sumOf { max(12 - it, 3) }
|
||||
|
||||
val totalMinutes = (proseWords / PROSE_WPM) + (codeWords / CODE_WPM) + (imageSeconds / 60.0)
|
||||
return max(1, ceil(totalMinutes).toInt())
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -129,7 +129,7 @@ class PublicChatListState(
|
||||
|
||||
init {
|
||||
settings.channelList()?.let { event ->
|
||||
Log.d("AccountRegisterObservers", "Loading saved channel list ${event.toJson()}")
|
||||
Log.d("AccountRegisterObservers") { "Loading saved channel list ${event.toJson()}" }
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
@@ -139,7 +139,7 @@ class PublicChatListState(
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "Channel List Collector Start")
|
||||
getChannelListFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Channel List for ${signer.pubKey}")
|
||||
Log.d("AccountRegisterObservers") { "Channel List for ${signer.pubKey}" }
|
||||
(it.note.event as? ChannelListEvent)?.let {
|
||||
settings.updateChannelListTo(it)
|
||||
}
|
||||
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* 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.model.nip51Lists
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@Stable
|
||||
class BookmarkListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: ICacheProvider,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
class BookmarkList(
|
||||
val public: List<Note> = emptyList(),
|
||||
val private: List<Note> = emptyList(),
|
||||
)
|
||||
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
|
||||
val bookmarkList = cache.getOrCreateAddressableNote(getBookmarkListAddress())
|
||||
|
||||
fun getBookmarkListAddress() = BookmarkListEvent.createBookmarkAddress(signer.pubKey)
|
||||
|
||||
fun getBookmarkListFlow(): StateFlow<NoteState> = bookmarkList.flow().metadata.stateFlow
|
||||
|
||||
fun getBookmarkList(): BookmarkListEvent? = bookmarkList.event as? BookmarkListEvent
|
||||
|
||||
fun publicBookmarks(note: Note): List<BookmarkIdTag> {
|
||||
val noteEvent = note.event as? BookmarkListEvent
|
||||
return noteEvent?.publicBookmarks() ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun privateBookmarks(note: Note): List<BookmarkIdTag> {
|
||||
val noteEvent = note.event as? BookmarkListEvent
|
||||
return noteEvent?.privateBookmarks(signer) ?: emptyList()
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val publicBookmarks: StateFlow<List<BookmarkIdTag>> =
|
||||
getBookmarkListFlow()
|
||||
.map { noteState ->
|
||||
publicBookmarks(noteState.note)
|
||||
}.onStart {
|
||||
emit(publicBookmarks(bookmarkList))
|
||||
}.debounce(100)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val privateBookmarks: StateFlow<List<BookmarkIdTag>> =
|
||||
getBookmarkListFlow()
|
||||
.map { noteState ->
|
||||
privateBookmarks(noteState.note)
|
||||
}.onStart {
|
||||
emit(privateBookmarks(bookmarkList))
|
||||
}.debounce(100)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
val publicBookmarkEventIdSet =
|
||||
publicBookmarks
|
||||
.map { bookmark ->
|
||||
bookmark
|
||||
.mapNotNull {
|
||||
if (it is EventBookmark) it.eventId else null
|
||||
}.toSet()
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
val publicBookmarkAddressIdSet =
|
||||
publicBookmarks
|
||||
.map { bookmark ->
|
||||
bookmark
|
||||
.mapNotNull {
|
||||
if (it is AddressBookmark) it.address else null
|
||||
}.toSet()
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
val privateBookmarkEventIdSet =
|
||||
privateBookmarks
|
||||
.map { bookmark ->
|
||||
bookmark
|
||||
.mapNotNull {
|
||||
if (it is EventBookmark) it.eventId else null
|
||||
}.toSet()
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
val privateBookmarkAddressIdSet =
|
||||
privateBookmarks
|
||||
.map { bookmark ->
|
||||
bookmark
|
||||
.mapNotNull {
|
||||
if (it is AddressBookmark) it.address else null
|
||||
}.toSet()
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
fun bookmarkList(
|
||||
privateBookmarks: List<BookmarkIdTag>,
|
||||
publicBookmarks: List<BookmarkIdTag>,
|
||||
): BookmarkList =
|
||||
BookmarkList(
|
||||
public =
|
||||
publicBookmarks
|
||||
.mapNotNull {
|
||||
when (it) {
|
||||
is EventBookmark -> cache.checkGetOrCreateNote(it.eventId)
|
||||
is AddressBookmark -> cache.getOrCreateAddressableNote(it.address)
|
||||
}
|
||||
}.reversed(),
|
||||
private =
|
||||
privateBookmarks
|
||||
.mapNotNull {
|
||||
when (it) {
|
||||
is EventBookmark -> cache.checkGetOrCreateNote(it.eventId)
|
||||
is AddressBookmark -> cache.getOrCreateAddressableNote(it.address)
|
||||
}
|
||||
}.reversed(),
|
||||
)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val bookmarks: StateFlow<BookmarkList> =
|
||||
combineTransform(privateBookmarks, publicBookmarks) { private, public ->
|
||||
emit(bookmarkList(private, public))
|
||||
}.onStart {
|
||||
emit(bookmarkList(privateBookmarks.value, publicBookmarks.value))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
BookmarkList(),
|
||||
)
|
||||
|
||||
fun isInPrivateBookmarks(note: Note): Boolean {
|
||||
if (!signer.isWriteable()) return false
|
||||
|
||||
return if (note is AddressableNote) {
|
||||
privateBookmarkAddressIdSet.value.contains(note.address)
|
||||
} else {
|
||||
privateBookmarkEventIdSet.value.contains(note.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
fun isInPublicBookmarks(note: Note): Boolean =
|
||||
if (note is AddressableNote) {
|
||||
publicBookmarkAddressIdSet.value.contains(note.address)
|
||||
} else {
|
||||
publicBookmarkEventIdSet.value.contains(note.idHex)
|
||||
}
|
||||
|
||||
suspend fun addBookmark(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): BookmarkListEvent {
|
||||
val bookmarkList = getBookmarkList()
|
||||
|
||||
return if (bookmarkList == null) {
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.create(
|
||||
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.create(
|
||||
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.add(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.add(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeBookmark(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): BookmarkListEvent? {
|
||||
val bookmarkList = getBookmarkList()
|
||||
|
||||
return if (bookmarkList != null) {
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.remove(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.remove(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeBookmark(note: Note): BookmarkListEvent? {
|
||||
val bookmarkList = getBookmarkList()
|
||||
|
||||
return if (bookmarkList != null) {
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.remove(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.remove(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.model.nip65RelayList
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
|
||||
/**
|
||||
* Narrow repository interface for Nip65RelayListState's settings needs.
|
||||
* Follows the established pattern of EphemeralChatRepository / PublicChatListRepository.
|
||||
*/
|
||||
interface Nip65RelayListRepository {
|
||||
val backupNIP65RelayList: AdvertisedRelayListEvent?
|
||||
|
||||
fun updateNIP65RelayList(event: AdvertisedRelayListEvent)
|
||||
|
||||
/** Default relay set when no NIP-65 list is available (write relays). */
|
||||
val defaultOutboxRelays: Set<NormalizedRelayUrl>
|
||||
|
||||
/** Default relay set when no NIP-65 list is available (read relays). */
|
||||
val defaultInboxRelays: Set<NormalizedRelayUrl>
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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.model.nip65RelayList
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class Nip65RelayListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: ICacheProvider,
|
||||
val scope: CoroutineScope,
|
||||
val settings: Nip65RelayListRepository,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
|
||||
val nip65ListNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress())
|
||||
|
||||
fun getNIP65RelayListAddress() = AdvertisedRelayListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getNIP65RelayListFlow(): StateFlow<NoteState> = nip65ListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getNIP65RelayList(): AdvertisedRelayListEvent? = nip65ListNote.event as? AdvertisedRelayListEvent
|
||||
|
||||
fun nip65Event(note: Note) = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList
|
||||
|
||||
fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: settings.defaultOutboxRelays
|
||||
|
||||
fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.readRelaysNorm()?.toSet() ?: settings.defaultInboxRelays
|
||||
|
||||
fun normalizeNIP65WriteRelayListNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: emptySet()
|
||||
|
||||
fun normalizeNIP65ReadRelayListNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.readRelaysNorm()?.toSet() ?: emptySet()
|
||||
|
||||
fun normalizeNIP65AllRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: settings.defaultOutboxRelays
|
||||
|
||||
fun normalizeNIP65AllRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: emptySet()
|
||||
|
||||
val outboxFlow =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65WriteRelayListWithBackup(it.note) }
|
||||
.onStart { emit(normalizeNIP65WriteRelayListWithBackup(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
val inboxFlow =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65ReadRelayListWithBackup(it.note) }
|
||||
.onStart { emit(normalizeNIP65ReadRelayListWithBackup(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
val outboxFlowNoDefaults =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65WriteRelayListNoDefaults(it.note) }
|
||||
.onStart { emit(normalizeNIP65WriteRelayListNoDefaults(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
val inboxFlowNoDefaults =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65ReadRelayListNoDefaults(it.note) }
|
||||
.onStart { emit(normalizeNIP65ReadRelayListNoDefaults(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
val allFlowNoDefaults =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65AllRelayListWithBackupNoDefaults(it.note) }
|
||||
.onStart { emit(normalizeNIP65AllRelayListWithBackupNoDefaults(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun saveRelayList(relays: List<AdvertisedRelayInfo>): AdvertisedRelayListEvent {
|
||||
val nip65RelayList = getNIP65RelayList()
|
||||
|
||||
return if (nip65RelayList != null) {
|
||||
AdvertisedRelayListEvent.replaceRelayListWith(
|
||||
earlierVersion = nip65RelayList,
|
||||
newRelays = relays,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
AdvertisedRelayListEvent.createFromScratch(
|
||||
relays = relays,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupNIP65RelayList?.let {
|
||||
Log.d("AccountRegisterObservers") { "Loading saved nip65 relay list ${it.toJson()}" }
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start")
|
||||
getNIP65RelayListFlow().collect {
|
||||
Log.d("AccountRegisterObservers") { "Updating NIP-65 List for ${signer.pubKey}" }
|
||||
(it.note.event as? AdvertisedRelayListEvent)?.let {
|
||||
settings.updateNIP65RelayList(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-5
@@ -20,7 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import java.util.SortedSet
|
||||
@@ -31,18 +33,27 @@ import java.util.concurrent.ConcurrentSkipListSet
|
||||
* that is updated every time a new event that matches
|
||||
* the filter is received, including addressables.
|
||||
*/
|
||||
class EventListMatchingFilter(
|
||||
class EventListMatchingFilter<T : Event>(
|
||||
private val filter: Filter,
|
||||
private val atOnce: (filter: Filter) -> SortedSet<Note>,
|
||||
private val update: (List<Event>) -> Unit,
|
||||
private val update: (List<T>) -> Unit,
|
||||
) : Observable {
|
||||
// Keeping this here blocks it from being cleared from memory
|
||||
var currentResults: ConcurrentSkipListSet<Note> = ConcurrentSkipListSet(CreatedAtIdHexComparator)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun new(
|
||||
event: Event,
|
||||
note: Note,
|
||||
) {
|
||||
if (event is AddressableEvent && note !is AddressableNote) {
|
||||
// event update
|
||||
if (currentResults.contains(note)) {
|
||||
update(currentResults.mapNotNull { it.event as? T })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (filter.match(event)) {
|
||||
currentResults.add(note)
|
||||
val limit = filter.limit
|
||||
@@ -50,18 +61,20 @@ class EventListMatchingFilter(
|
||||
currentResults.remove(currentResults.last())
|
||||
}
|
||||
|
||||
update(currentResults.mapNotNull { it.event })
|
||||
update(currentResults.mapNotNull { it.event as? T })
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun remove(note: Note) {
|
||||
if (currentResults.remove(note)) {
|
||||
update(currentResults.mapNotNull { it.event })
|
||||
update(currentResults.mapNotNull { it.event as? T })
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun init() {
|
||||
currentResults = ConcurrentSkipListSet(atOnce(filter))
|
||||
update(currentResults.mapNotNull { it.event })
|
||||
update(currentResults.mapNotNull { it.event as? T })
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -20,7 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import java.util.SortedSet
|
||||
@@ -43,6 +45,8 @@ class NoteListMatchingFilter(
|
||||
event: Event,
|
||||
note: Note,
|
||||
) {
|
||||
if (event is AddressableEvent && note !is AddressableNote) return
|
||||
|
||||
if (filter.match(event)) {
|
||||
if (currentResults.add(note)) {
|
||||
val limit = filter.limit
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.trustedAssertions
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.UserDependencies
|
||||
import com.vitorpamplona.amethyst.commons.relays.EOSERelayList
|
||||
import com.vitorpamplona.amethyst.commons.util.PlatformNumberFormatter
|
||||
import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
|
||||
+9
-3
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.commons.preview
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import java.net.URL
|
||||
import java.net.URI
|
||||
|
||||
@Immutable
|
||||
class UrlInfoItem(
|
||||
@@ -31,10 +31,16 @@ class UrlInfoItem(
|
||||
val image: String = "",
|
||||
val mimeType: String,
|
||||
) {
|
||||
val verifiedUrl = runCatching { URL(url) }.getOrNull()
|
||||
val verifiedUrl = runCatching { URI(url).toURL() }.getOrNull()
|
||||
val imageUrlFullPath =
|
||||
if (image.startsWith("/")) {
|
||||
URL(verifiedUrl, image).toString()
|
||||
runCatching {
|
||||
verifiedUrl
|
||||
?.toURI()
|
||||
?.resolve(image)
|
||||
?.toURL()
|
||||
?.toString()
|
||||
}.getOrNull() ?: image
|
||||
} else {
|
||||
image
|
||||
}
|
||||
|
||||
+3
-3
@@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -79,7 +79,7 @@ class FeedMetadataCoordinator(
|
||||
// Create listener to pass events to the callback
|
||||
val listener =
|
||||
if (onEvent != null) {
|
||||
object : IRequestListener {
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
@@ -93,7 +93,7 @@ class FeedMetadataCoordinator(
|
||||
null
|
||||
}
|
||||
|
||||
client.openReqSubscription(
|
||||
client.subscribe(
|
||||
subId = newSubId(),
|
||||
filters = filterMap,
|
||||
listener = listener,
|
||||
|
||||
+2
-1
@@ -20,14 +20,15 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.collections.forEach
|
||||
|
||||
/**
|
||||
* This allows composables to directly register their queries
|
||||
* to relays. There may be multiple duplications in these
|
||||
* subscriptions since we do not control when screens are removed.
|
||||
*/
|
||||
@Stable
|
||||
abstract class ComposeSubscriptionManager<T> :
|
||||
ComposeSubscriptionManagerControls,
|
||||
Subscribable<T> {
|
||||
|
||||
+2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -36,6 +37,7 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* also allows the subscription itself to change over time as a
|
||||
* flow, which trigger an update on the relay subscriptions
|
||||
*/
|
||||
@Stable
|
||||
abstract class MutableComposeSubscriptionManager<T : MutableQueryState>(
|
||||
val scope: CoroutineScope,
|
||||
) : ComposeSubscriptionManagerControls {
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.service.BundledUpdate
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -38,7 +38,7 @@ abstract class BaseEoseManager<T>(
|
||||
|
||||
fun getSubscription(subId: String) = orchestrator.getSub(subId)
|
||||
|
||||
fun requestNewSubscription(listener: IRequestListener) = orchestrator.requestNewSubscription(newSubId(), listener)
|
||||
fun requestNewSubscription(listener: SubscriptionListener) = orchestrator.requestNewSubscription(newSubId(), listener)
|
||||
|
||||
fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId)
|
||||
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -82,7 +82,7 @@ abstract class PerKeyEoseManager<T, K : Any>(
|
||||
*/
|
||||
open fun newSub(queryState: T): Subscription =
|
||||
requestNewSubscription(
|
||||
object : IRequestListener {
|
||||
object : SubscriptionListener {
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -66,7 +66,7 @@ abstract class SingleSubEoseManager<T>(
|
||||
|
||||
val sub =
|
||||
requestNewSubscription(
|
||||
object : IRequestListener {
|
||||
object : SubscriptionListener {
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
|
||||
+9
-4
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
|
||||
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
@@ -40,8 +39,8 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URL
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class RichTextParser {
|
||||
@@ -424,12 +423,18 @@ class RichTextParser {
|
||||
|
||||
fun isValidURL(url: String?): Boolean =
|
||||
try {
|
||||
URL(url).toURI()
|
||||
true
|
||||
if (url != null) {
|
||||
URI(url).toURL()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (e: MalformedURLException) {
|
||||
false
|
||||
} catch (e: URISyntaxException) {
|
||||
false
|
||||
} catch (e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
|
||||
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
|
||||
|
||||
+1
-1
@@ -167,7 +167,7 @@ class RobohashAssembler {
|
||||
if (Hex.isHex(msg) && msg.length > 10) {
|
||||
Hex.decode(msg)
|
||||
} else {
|
||||
Log.w("Robohash", "$msg is not a hex")
|
||||
Log.w("Robohash") { "$msg is not a hex" }
|
||||
sha256(msg.toByteArray())
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -39,7 +39,7 @@ inline fun <T> logTime(
|
||||
if (isDebug) {
|
||||
val (result, elapsed) = measureTimedValue(block)
|
||||
if (elapsed.inWholeMilliseconds > minToReportMs) {
|
||||
Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage")
|
||||
Log.d("DEBUG-TIME") { "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage" }
|
||||
}
|
||||
result
|
||||
} else {
|
||||
@@ -54,7 +54,7 @@ inline fun <T> logTime(
|
||||
if (isDebug) {
|
||||
val (result, elapsed) = measureTimedValue(block)
|
||||
if (elapsed.inWholeMilliseconds > minToReportMs) {
|
||||
Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}")
|
||||
Log.d("DEBUG-TIME") { "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}" }
|
||||
}
|
||||
result
|
||||
} else {
|
||||
|
||||
+2
-3
@@ -24,7 +24,6 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
@@ -95,7 +94,7 @@ class ChatNewMessageState(
|
||||
if (currentRoom != null) {
|
||||
_recipientsMissingDmRelays.value =
|
||||
currentRoom.users.any { hexKey ->
|
||||
val user = cache.getOrCreateUser(hexKey) as? User
|
||||
val user = cache.getOrCreateUser(hexKey)
|
||||
user?.dmInboxRelays().isNullOrEmpty()
|
||||
}
|
||||
} else {
|
||||
@@ -141,7 +140,7 @@ class ChatNewMessageState(
|
||||
) {
|
||||
val pTags =
|
||||
room.users.mapNotNull { hexKey ->
|
||||
(cache.getOrCreateUser(hexKey) as? User)?.toPTag()
|
||||
cache.getOrCreateUser(hexKey)?.toPTag()
|
||||
}
|
||||
|
||||
val replyHint = _replyTo.value?.toEventHint<BaseDMGroupEvent>()
|
||||
|
||||
+4
-4
@@ -49,24 +49,24 @@ abstract class FeedViewModel(
|
||||
override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing)
|
||||
|
||||
init {
|
||||
Log.d("Init", "Starting new Model: ${this::class.simpleName}")
|
||||
Log.d("Init") { "Starting new Model: ${this::class.simpleName}" }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
cacheProvider.getEventStream().newEventBundles.collect { newNotes ->
|
||||
Log.d("Rendering Metrics", "Update feeds: ${this@FeedViewModel::class.simpleName} with ${newNotes.size}")
|
||||
Log.d("Rendering Metrics") { "Update feeds: ${this@FeedViewModel::class.simpleName} with ${newNotes.size}" }
|
||||
feedState.updateFeedWith(newNotes)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
cacheProvider.getEventStream().deletedEventBundles.collect { newNotes ->
|
||||
Log.d("Rendering Metrics", "Delete from feeds: ${this@FeedViewModel::class.simpleName} with ${newNotes.size}")
|
||||
Log.d("Rendering Metrics") { "Delete from feeds: ${this@FeedViewModel::class.simpleName} with ${newNotes.size}" }
|
||||
feedState.deleteFromFeed(newNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
Log.d("Init", "OnCleared: ${this::class.simpleName}")
|
||||
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -46,14 +46,14 @@ abstract class ListChangeFeedViewModel(
|
||||
override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing)
|
||||
|
||||
init {
|
||||
Log.d("Init", "Starting new Model: ${this::class.simpleName}")
|
||||
Log.d("Init") { "Starting new Model: ${this::class.simpleName}" }
|
||||
// Trigger initial load so empty rooms show Empty instead of Loading
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
feedState.invalidateData(ignoreIfDoing = false)
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
localFilter.changesFlow().collect {
|
||||
Log.d("Init", "Collecting changes to: ${this@ListChangeFeedViewModel::class.simpleName}")
|
||||
Log.d("Init") { "Collecting changes to: ${this@ListChangeFeedViewModel::class.simpleName}" }
|
||||
when (it) {
|
||||
is ListChange.Addition -> feedState.updateFeedWith(setOf(it.item))
|
||||
is ListChange.Deletion -> feedState.deleteFromFeed(setOf(it.item))
|
||||
|
||||
+1
-2
@@ -88,8 +88,7 @@ class SearchBarState(
|
||||
.debounce(debounceMs)
|
||||
.onEach { query ->
|
||||
if (query.length >= 2 && _bech32Results.value.isEmpty()) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
_cachedUserResults.value = cache.findUsersStartingWith(query, 20) as List<User>
|
||||
_cachedUserResults.value = cache.findUsersStartingWith(query, 20)
|
||||
} else {
|
||||
_cachedUserResults.value = emptyList()
|
||||
}
|
||||
|
||||
+8
-8
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.commons.chess
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -43,7 +43,7 @@ data class BroadcastResult(
|
||||
/**
|
||||
* Helper for broadcasting chess events to relays with reliable delivery.
|
||||
*
|
||||
* Uses sendAndWaitForResponse to get actual OK confirmations from relays,
|
||||
* Uses publishAndConfirm to get actual OK confirmations from relays,
|
||||
* ensuring the event was actually received and accepted.
|
||||
*/
|
||||
class ChessEventBroadcaster(
|
||||
@@ -89,7 +89,7 @@ class ChessEventBroadcaster(
|
||||
)
|
||||
|
||||
val listener =
|
||||
object : IRequestListener {
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
@@ -105,22 +105,22 @@ class ChessEventBroadcaster(
|
||||
|
||||
// Open subscription to all target relays (triggers connection)
|
||||
val filterMap = targetRelays.associateWith { listOf(dummyFilter) }
|
||||
client.openReqSubscription(subId, filterMap, listener)
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
|
||||
// Wait for relays to connect (poll with timeout)
|
||||
waitForRelays(targetRelays, 5000L)
|
||||
|
||||
// Close the dummy subscription
|
||||
client.close(subId)
|
||||
client.unsubscribe(subId)
|
||||
}
|
||||
|
||||
// Step 3: Send the event and wait for OK responses
|
||||
Log.d("chessdebug", "[Broadcaster] sending event ${event.id.take(8)} and waiting for OK (timeout=${timeoutSeconds}s)")
|
||||
val success = client.sendAndWaitForResponse(event, targetRelays, timeoutSeconds)
|
||||
val success = client.publishAndConfirm(event, targetRelays, timeoutSeconds)
|
||||
|
||||
Log.d("chessdebug", "[Broadcaster] broadcast result: success=$success for event ${event.id.take(8)}")
|
||||
|
||||
// Note: sendAndWaitForResponse only returns aggregate success (any relay accepted)
|
||||
// Note: publishAndConfirm only returns aggregate success (any relay accepted)
|
||||
// We don't have per-relay results, so relayResults is empty
|
||||
return BroadcastResult(
|
||||
success = success,
|
||||
|
||||
Vendored
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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.model.cache
|
||||
|
||||
import com.vitorpamplona.quartz.utils.cache.CacheOperations
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.ConcurrentSkipListMap
|
||||
import java.util.function.BiConsumer
|
||||
|
||||
class LargeSoftCache<K : Any, V : Any> : CacheOperations<K, V> {
|
||||
private val cache = ConcurrentSkipListMap<K, WeakReference<V>>()
|
||||
|
||||
fun keys() = cache.keys
|
||||
|
||||
fun get(key: K): V? {
|
||||
val softRef = cache.get(key) ?: return null
|
||||
val value = softRef.get()
|
||||
|
||||
return if (value != null) {
|
||||
value
|
||||
} else {
|
||||
cache.remove(key, softRef)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: K) = cache.remove(key)
|
||||
|
||||
fun removeIf(
|
||||
key: K,
|
||||
value: WeakReference<V>,
|
||||
) = cache.remove(key, value)
|
||||
|
||||
override fun size() = cache.size
|
||||
|
||||
fun isEmpty() = cache.isEmpty()
|
||||
|
||||
fun clear() = cache.clear()
|
||||
|
||||
fun containsKey(key: K) = cache.containsKey(key)
|
||||
|
||||
/**
|
||||
* Puts an object into the cache with a specified key.
|
||||
* The object is stored as a WeakReference.
|
||||
*
|
||||
* @param key The key to associate with the object.
|
||||
* @param value The object to cache.
|
||||
*/
|
||||
fun put(
|
||||
key: K,
|
||||
value: V,
|
||||
) {
|
||||
cache.put(key, WeakReference(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an object from the cache using its key.
|
||||
* Returns the object if it's still available (not garbage collected),
|
||||
* otherwise returns null. If the object has been garbage collected,
|
||||
* its entry is also removed from the cache.
|
||||
*
|
||||
* @param key The key of the object to retrieve.
|
||||
* @return The cached object, or null if it's no longer available.
|
||||
*/
|
||||
fun getOrCreate(
|
||||
key: K,
|
||||
builder: (key: K) -> V,
|
||||
): V {
|
||||
val softRef = cache[key]
|
||||
|
||||
return if (softRef == null) {
|
||||
val newObject = builder(key)
|
||||
cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject
|
||||
} else {
|
||||
val value = softRef.get()
|
||||
if (value != null) {
|
||||
value
|
||||
} else {
|
||||
// removes first to make sure the putIfAbsent works.
|
||||
// another thread may put in between
|
||||
cache.remove(key, softRef)
|
||||
val newObject = builder(key)
|
||||
cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proactively cleans up the cache by removing entries whose weakly referenced
|
||||
* objects have been garbage collected. Single-pass iterator for efficiency.
|
||||
*/
|
||||
fun cleanUp() {
|
||||
val iter = cache.entries.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val entry = iter.next()
|
||||
if (entry.value.get() == null) {
|
||||
iter.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun forEach(consumer: BiConsumer<K, V>) {
|
||||
cache.forEach(BiConsumerWrapper(this, consumer))
|
||||
}
|
||||
|
||||
override fun forEach(
|
||||
from: K,
|
||||
to: K,
|
||||
consumer: BiConsumer<K, V>,
|
||||
) {
|
||||
cache
|
||||
.subMap(from, true, to, true)
|
||||
.forEach(BiConsumerWrapper(this, consumer))
|
||||
}
|
||||
|
||||
class BiConsumerWrapper<K : Any, V : Any>(
|
||||
val cache: LargeSoftCache<K, V>,
|
||||
val inner: BiConsumer<K, V>,
|
||||
) : BiConsumer<K, WeakReference<V>> {
|
||||
override fun accept(
|
||||
k: K,
|
||||
ref: WeakReference<V>,
|
||||
) {
|
||||
val value = ref.get()
|
||||
if (value == null) {
|
||||
cache.removeIf(k, ref)
|
||||
} else {
|
||||
inner.accept(k, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user