Improvements on NIP-11

- Support for self, privacy_policy, terms_of_service, grasps
- New UI for the Relay Information Screen
- Improvements to the Debug Message
- Compose-stable objects
- Clickable elements for NIPs, external links, grasps, etc
This commit is contained in:
Vitor Pamplona
2026-01-06 17:38:47 -05:00
parent 36b50576d9
commit e26fb94933
10 changed files with 1008 additions and 365 deletions
@@ -95,7 +95,7 @@ class AntiSpamFilter {
if (spammer.shouldHide() && relay != null) {
Amethyst.instance.relayStats
.get(relay)
.newSpam("$link1 $link2")
.newSpam(link1, link2)
}
flowSpam.tryEmit(AntiSpamState(this))
@@ -123,7 +123,7 @@ class AntiSpamFilter {
if (spammer.shouldHide() && relay != null) {
Amethyst.instance.relayStats
.get(relay)
.newSpam("$link1 $link2")
.newSpam(link1, link2)
}
flowSpam.tryEmit(AntiSpamState(this))
@@ -116,6 +116,14 @@ val njumpLink = { nip19BechAddress: String ->
"https://njump.to/$nip19BechAddress"
}
val nipLink = { nipNumber: String ->
"https://nostrhub.io/$nipNumber"
}
val graspLink = { graspNumber: String ->
"https://gitworkshop.dev/danconwaydev.com/grasp/tree/master/$graspNumber.md"
}
val externalLinkForNote = { note: Note ->
if (note is AddressableNote) {
if (note.event?.bountyBaseReward() != null) {
@@ -56,6 +56,12 @@ fun showAmountInteger(amount: BigDecimal?): String {
}
}
fun showAmountInteger(amount: Int?): String {
if (amount == null) return "0"
return showAmountIntegerWithZero(BigDecimal.valueOf(amount.toLong()))
}
fun showAmountIntegerWithZero(amount: BigDecimal?): String {
if (amount == null) return "0"
if (amount.abs() < BigDecimal(0.01)) return "0"
+42 -3
View File
@@ -17,6 +17,8 @@
<string name="could_not_decrypt_the_message">Could not decrypt the message</string>
<string name="group_picture">Group Picture</string>
<string name="explicit_content">Explicit Content</string>
<string name="relay_notice">Relay Notice</string>
<string name="duplicated_post">Duplicated Post</string>
<string name="spam">Spam</string>
<string name="spam_description">The number of spamming events coming from this relay</string>
<string name="impersonation">Impersonation</string>
@@ -133,6 +135,7 @@
<string name="relay_address">Relay Address</string>
<string name="posts">Posts</string>
<string name="bytes">Bytes</string>
<string name="error">Error</string>
<string name="errors">Errors</string>
<string name="errors_description">The number of connection errors in this session</string>
<string name="home_feed">Home Feed</string>
@@ -747,28 +750,64 @@
<string name="read_from_relay_description">The amount in bytes that was received from this relay, including filters and events</string>
<string name="an_error_occurred_trying_to_get_relay_information">An error occurred trying to get relay information from %1$s</string>
<string name="owner">Owner</string>
<string name="self">Service Key</string>
<string name="running_software">Running %1$s</string>
<string name="running_software_version">Running %1$s (%2$s)</string>
<string name="version">Version</string>
<string name="software">Software</string>
<string name="contact">Contact</string>
<string name="supports">Supported NIPs</string>
<string name="supported_grasps">Supported Grasps</string>
<string name="admission_fees">Admission Fees</string>
<string name="admission">Admission</string>
<string name="subscription">Subscription</string>
<string name="publication">Publication</string>
<string name="payment_link">Payments %1$s</string>
<string name="payments_url">Payments url</string>
<string name="target_audience">Target Audience</string>
<string name="policies_and_links">Policies &amp; Links</string>
<string name="fees_and_payments">Fees &amp; Payments</string>
<string name="limitations">Limitations</string>
<string name="countries">Countries</string>
<string name="languages">Languages</string>
<string name="tags">Tags</string>
<string name="topics">Topics</string>
<string name="all_countries">All Countries</string>
<string name="all_languages">All Languages</string>
<string name="posting_policy">Posting policy</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="terms_and_conditions">Terms &amp; Conditions</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Errors and Notices from this Relay</string>
<string name="message_length">Message length</string>
<string name="subscriptions">Subscriptions</string>
<string name="filters">Filters</string>
<string name="subscription_id_length">Subscription id length</string>
<string name="minimum_prefix">Minimum prefix</string>
<string name="maximum_event_tags">Maximum event tags</string>
<string name="minimum_prefix">Min Prefix</string>
<string name="maximum_event_tags">Max Event Tags</string>
<string name="content_length">Content length</string>
<string name="minimum_pow">Minimum PoW</string>
<string name="max_content_length">Max Content Length</string>
<string name="discards_older_than">Discards older than</string>
<string name="accepts_up_to">Accepts up to</string>
<string name="time_in_the_future">%1$s in the future</string>
<string name="time_in_the_past">%1$s ago</string>
<string name="amount_in_zeros">%1$s zeros</string>
<string name="amount_in_bits">%1$s bits</string>
<string name="event_retention">Event Retention</string>
<string name="content_size">Content Size</string>
<string name="connectivity">Connectivity</string>
<string name="access_control">Access Control</string>
<string name="minimum_pow">Min PoW Difficulty</string>
<string name="auth">Auth</string>
<string name="auth_required">Auth Required</string>
<string name="payment">Payment</string>
<string name="payment_required">Payment Required</string>
<string name="max_message_length">Max Message Length</string>
<string name="max_subs">Max Subscriptions</string>
<string name="max_filters_per_sub">Max Filters per Sub</string>
<string name="max_limit_events_returning">Max Limit (Events Returning)</string>
<string name="default_limit">Default Limit (Events Returning)</string>
<string name="max_subid_length">Max SubID Length</string>
<string name="cashu">Cashu Token</string>
<string name="cashu_redeem">Redeem</string>
<string name="cashu_redeem_to_zap">Send to Zap Wallet</string>
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.util
import com.sun.org.apache.xalan.internal.lib.ExsltDatetime.time
import com.vitorpamplona.quartz.utils.TimeUtils
import java.text.SimpleDateFormat
import java.util.Locale
@@ -81,6 +82,50 @@ fun timeAgo(
}
}
fun timeDiffAgoLong(timeDifference: Int): String =
when {
timeDifference > TimeUtils.ONE_YEAR -> {
(timeDifference / TimeUtils.ONE_YEAR).toString() + " years"
}
timeDifference > TimeUtils.ONE_MONTH -> {
(timeDifference / TimeUtils.ONE_MONTH).toString() + " months"
}
timeDifference > TimeUtils.ONE_DAY -> {
(timeDifference / TimeUtils.ONE_DAY).toString() + " days"
}
timeDifference > TimeUtils.ONE_HOUR -> {
(timeDifference / TimeUtils.ONE_HOUR).toString() + " hours"
}
timeDifference > TimeUtils.ONE_MINUTE -> {
(timeDifference / TimeUtils.ONE_MINUTE).toString() + " minutes"
}
else -> {
"now"
}
}
fun timeDiffAgoShortish(timeDifference: Int): String =
when {
timeDifference > TimeUtils.ONE_YEAR -> {
(timeDifference / TimeUtils.ONE_YEAR).toString() + " yrs"
}
timeDifference > TimeUtils.ONE_MONTH -> {
(timeDifference / TimeUtils.ONE_MONTH).toString() + " mos"
}
timeDifference > TimeUtils.ONE_DAY -> {
(timeDifference / TimeUtils.ONE_DAY).toString() + " days"
}
timeDifference > TimeUtils.ONE_HOUR -> {
(timeDifference / TimeUtils.ONE_HOUR).toString() + " hrs"
}
timeDifference > TimeUtils.ONE_MINUTE -> {
(timeDifference / TimeUtils.ONE_MINUTE).toString() + " mins"
}
else -> {
"now"
}
}
/**
* Formats a Unix timestamp as a date string.
* For recent dates (< 1 day), returns the provided today string.
@@ -21,8 +21,10 @@
package com.vitorpamplona.quartz.nip01Core.relay.client.stats
import androidx.collection.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class RelayStat(
var receivedBytes: Int = 0,
var sentBytes: Int = 0,
@@ -31,12 +33,11 @@ class RelayStat(
var pingInMs: Int = 0,
var compression: Boolean = false,
) {
val messages = LruCache<RelayDebugMessage, RelayDebugMessage>(100)
val messages = LruCache<IRelayDebugMessage, IRelayDebugMessage>(100)
fun newNotice(notice: String?) {
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.NOTICE,
NoticeDebugMessage(
message = notice ?: "No error message provided",
)
@@ -47,8 +48,7 @@ class RelayStat(
errorCounter++
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.ERROR,
ErrorDebugMessage(
message = error ?: "No error message provided",
)
@@ -63,27 +63,35 @@ class RelayStat(
sentBytes += bytesUsedInMemory
}
fun newSpam(spamDescriptor: String) {
fun newSpam(
link1: String,
link2: String,
) {
spamCounter++
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.SPAM,
message = spamDescriptor,
)
val debugMessage = SpamDebugMessage(link1, link2)
messages.put(debugMessage, debugMessage)
}
}
class RelayDebugMessage(
val type: RelayDebugMessageType,
val message: String,
val time: Long = TimeUtils.now(),
)
enum class RelayDebugMessageType {
SPAM,
NOTICE,
ERROR,
@Stable
sealed interface IRelayDebugMessage {
val time: Long
}
class SpamDebugMessage(
val link1: String,
val link2: String,
override val time: Long = TimeUtils.now(),
) : IRelayDebugMessage
class NoticeDebugMessage(
val message: String,
override val time: Long = TimeUtils.now(),
) : IRelayDebugMessage
class ErrorDebugMessage(
val message: String,
override val time: Long = TimeUtils.now(),
) : IRelayDebugMessage
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.normalizer
import androidx.compose.runtime.Stable
@Stable
data class NormalizedRelayUrl(
val url: String,
) : Comparable<NormalizedRelayUrl> {
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip11RelayInfo
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.text
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
@@ -32,15 +33,14 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
object FlexibleIntListSerializer : KSerializer<List<Int>?> {
private val listSerializer = ListSerializer(Int.serializer())
object FlexibleIntListSerializer : KSerializer<List<String>?> {
private val listSerializer = ListSerializer(String.serializer())
override val descriptor: SerialDescriptor = listSerializer.descriptor
override fun deserialize(decoder: Decoder): List<Int>? {
override fun deserialize(decoder: Decoder): List<String>? {
require(decoder is JsonDecoder) { "This serializer can only be used with Json format" }
return when (val element = decoder.decodeJsonElement()) {
@@ -51,7 +51,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
is JsonArray -> {
element.mapNotNull { arrayElement ->
try {
arrayElement.jsonPrimitive.int
arrayElement.jsonPrimitive.text
} catch (e: Exception) {
// Skip elements that aren't valid integers (strings, booleans, floats, etc.)
Log.w("FlexibleIntListSerializer", "Invalid element in array: $arrayElement", e)
@@ -63,7 +63,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
// Handle single integer format (malformed but found in the wild): 1
is JsonPrimitive if !element.isString -> {
try {
listOf(element.int)
listOf(element.text)
} catch (e: Exception) {
// Can't parse as integer (e.g., float, boolean), treat as missing data
Log.w("FlexibleIntListSerializer", "Invalid primitive: $element", e)
@@ -79,7 +79,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(
encoder: Encoder,
value: List<Int>?,
value: List<String>?,
) {
if (value == null) {
encoder.encodeNull()
@@ -21,19 +21,22 @@
package com.vitorpamplona.quartz.nip11RelayInfo
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.Serializable
@Stable
@Serializable
class Nip11RelayInformation(
val id: String? = null,
val name: String? = null,
val description: String? = null,
val icon: String? = null,
val pubkey: String? = null,
val pubkey: HexKey? = null,
val self: HexKey? = null,
val contact: String? = null,
@Serializable(with = FlexibleIntListSerializer::class)
val supported_nips: List<Int>? = null,
val supported_nips: List<String>? = null,
val supported_nip_extensions: List<String>? = null,
val software: String? = null,
val version: String? = null,
@@ -42,53 +45,60 @@ class Nip11RelayInformation(
val language_tags: List<String>? = null,
val tags: List<String>? = null,
val posting_policy: String? = null,
val privacy_policy: String? = null,
val terms_of_service: String? = null,
val payments_url: String? = null,
val retention: List<RelayInformationRetentionData>? = null,
val fees: RelayInformationFees? = null,
val nip50: List<String>? = null,
val supported_grasps: List<String>? = null,
) {
companion object {
fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson<Nip11RelayInformation>(json)
}
@Stable
@Serializable
class RelayInformationFee(
val amount: Int? = null,
val unit: String? = null,
val period: Int? = null,
val kinds: List<Int>? = null,
)
@Stable
@Serializable
class RelayInformationFees(
val admission: List<RelayInformationFee>? = null,
val subscription: List<RelayInformationFee>? = null,
val publication: List<RelayInformationFee>? = null,
)
@Stable
@Serializable
class RelayInformationLimitation(
val max_message_length: Int? = null,
val max_subscriptions: Int? = null,
val max_filters: Int? = null,
val max_limit: Int? = null,
val default_limit: Int? = null,
val max_subid_length: Int? = null,
val min_prefix: Int? = null,
val max_event_tags: Int? = null,
val max_content_length: Int? = null,
val min_pow_difficulty: Int? = null,
val auth_required: Boolean? = null,
val payment_required: Boolean? = null,
val restricted_writes: Boolean? = null,
val created_at_lower_limit: Int? = null,
val created_at_upper_limit: Int? = null,
)
@Stable
@Serializable
class RelayInformationRetentionData(
val kinds: ArrayList<Int>? = null,
val time: Int? = null,
val count: Int? = null,
)
}
@Stable
@Serializable
class RelayInformationFee(
val amount: Int? = null,
val unit: String? = null,
val period: Int? = null,
val kinds: List<Int>? = null,
)
@Serializable
class RelayInformationFees(
val admission: List<RelayInformationFee>? = null,
val subscription: List<RelayInformationFee>? = null,
val publication: List<RelayInformationFee>? = null,
)
@Serializable
class RelayInformationLimitation(
val max_message_length: Int? = null,
val max_subscriptions: Int? = null,
val max_filters: Int? = null,
val max_limit: Int? = null,
val max_subid_length: Int? = null,
val min_prefix: Int? = null,
val max_event_tags: Int? = null,
val max_content_length: Int? = null,
val min_pow_difficulty: Int? = null,
val auth_required: Boolean? = null,
val payment_required: Boolean? = null,
val restricted_writes: Boolean? = null,
val created_at_lower_limit: Int? = null,
val created_at_upper_limit: Int? = null,
)
@Serializable
class RelayInformationRetentionData(
val kinds: ArrayList<Int>? = null,
val time: Int? = null,
val count: Int? = null,
)