Merge upstream/main into nrobi144/phase-2A

Resolved import conflicts caused by package reorganization:
- upstream moved classes to amethyst/service/relayClient/*
- upstream moved models to commons/model/*
- kept our desktop additions (zaps, bookmarks, search)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-01-21 06:04:13 +02:00
co-authored by Claude Opus 4.5
244 changed files with 3194 additions and 1352 deletions
@@ -0,0 +1,226 @@
/**
* 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
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
@Stable
abstract class Channel : NotesGatherer {
companion object {
val DefaultFeedOrder: Comparator<Note> =
compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex }
}
val notes = LargeCache<HexKey, Note>()
var lastNote: Note? = null
private var relays = mapOf<NormalizedRelayUrl, Counter>()
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
val current = changesFlow.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(0, 10, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
return new
}
open fun participatingAuthors(maxTimeLimit: Long) =
notes.mapNotNull { key, value ->
val createdAt = value.createdAt()
if (createdAt != null && createdAt > maxTimeLimit) {
value.author
} else {
null
}
}
abstract fun toBestDisplayName(): String
open fun relays(): Set<NormalizedRelayUrl> =
relays.keys
.toSortedSet { o1, o2 ->
val o1Count = relays[o1]?.number ?: 0
val o2Count = relays[o2]?.number ?: 0
o2Count.compareTo(o1Count) // descending
}
fun updateChannelInfo() {
flowSet?.metadata?.invalidateData()
}
@Synchronized
fun addRelaySync(briefInfo: NormalizedRelayUrl) {
if (briefInfo !in relays) {
relays = relays + Pair(briefInfo, Counter(1))
}
}
fun addRelay(relay: NormalizedRelayUrl) {
val counter = relays[relay]
if (counter != null) {
counter.number++
} else {
addRelaySync(relay)
}
}
fun addNote(
note: Note,
relay: NormalizedRelayUrl? = null,
) {
if (!notes.containsKey(note.idHex)) {
notes.put(note.idHex, note)
note.addGatherer(this)
if ((note.createdAt() ?: 0L) > (lastNote?.createdAt() ?: 0L)) {
lastNote = note
}
if (relay != null) {
addRelay(relay)
}
changesFlow.get()?.tryEmit(ListChange.Addition(note))
flowSet?.notes?.invalidateData()
}
}
override fun removeNote(note: Note) {
if (notes.containsKey(note.idHex)) {
notes.remove(note.idHex)
note.removeGatherer(this)
if (note == lastNote) {
lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull()
}
changesFlow.get()?.tryEmit(ListChange.Deletion(note))
flowSet?.notes?.invalidateData()
}
}
fun pruneOldMessages(): Set<Note> {
val important =
notes
.values()
.sortedWith(DefaultFeedOrder)
.take(500)
.toSet()
val toBeRemoved = notes.filter { key, it -> it !in important }
toBeRemoved.forEach { notes.remove(it.idHex) }
changesFlow.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
flowSet?.notes?.invalidateData()
return toBeRemoved.toSet()
}
fun pruneHiddenMessages(account: IAccount): Set<Note> {
val hidden =
notes
.filter { key, it ->
it.author?.let { author -> account.isHidden(author) } == true
}.toSet()
hidden.forEach { notes.remove(it.idHex) }
changesFlow.get()?.tryEmit(ListChange.SetDeletion(hidden))
flowSet?.notes?.invalidateData()
return hidden.toSet()
}
var flowSet: ChannelFlowSet? = null
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = ChannelFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet = null
}
}
}
fun flow(): ChannelFlowSet {
if (flowSet == null) {
createOrDestroyFlowSync(true)
}
return flowSet!!
}
fun clearFlow() {
if (flowSet != null && flowSet?.isInUse() == false) {
createOrDestroyFlowSync(false)
}
}
}
data class Counter(
var number: Int = 0,
)
@Stable
class ChannelFlowSet(
u: Channel,
) {
// Observers line up here.
val metadata = ChannelFlow(u)
val notes = ChannelFlow(u)
fun isInUse(): Boolean =
metadata.hasObservers() ||
notes.hasObservers()
}
class ChannelFlow(
val channel: Channel,
) {
val stateFlow = MutableStateFlow(ChannelState(channel))
fun invalidateData() {
stateFlow.tryEmit(ChannelState(channel))
}
fun hasObservers() = stateFlow.subscriptionCount.value > 0
}
class ChannelState(
val channel: Channel,
)
@@ -84,4 +84,6 @@ interface IAccount {
/** Set of followed user pubkeys (for feed ordering/highlighting) */
fun followingKeySet(): Set<String>
fun isHidden(user: User): Boolean
}
@@ -0,0 +1,39 @@
/**
* 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
sealed class ListChange<out T> {
data class Addition<T>(
val item: T,
) : ListChange<T>()
data class Deletion<T>(
val item: T,
) : ListChange<T>()
data class SetAddition<T>(
val item: Set<T>,
) : ListChange<T>()
data class SetDeletion<T>(
val item: Set<T>,
) : ListChange<T>()
}
@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread
import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.commons.util.replace
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue
import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -109,7 +110,6 @@ class AddressableNote(
@Stable
open class Note(
val idHex: String,
private val cacheProvider: ICacheProvider? = null,
) : NotesGatherer {
// These fields are only available after the Text Note event is received.
// They are immutable after that.
@@ -196,15 +196,28 @@ open class Note(
}
fun relayHintUrl(): NormalizedRelayUrl? {
val noteEvent = event
val communityPostRelays =
when (noteEvent) {
is CommunityDefinitionEvent -> noteEvent.relayUrls().ifEmpty { null }?.toSet()
is IsInPublicChatChannel -> cacheProvider?.getAnyChannel(this)?.relays()
else -> null
// checks Community Events first
when (val noteEvent = event) {
is CommunityDefinitionEvent -> noteEvent.relayUrls().firstOrNull()?.let { return it }
is IsInPublicChatChannel -> {
inGatherers?.forEach {
if (it is com.vitorpamplona.amethyst.commons.model.Channel) {
it.relays().firstOrNull()?.let { return it }
}
}
}
if (!communityPostRelays.isNullOrEmpty()) return (communityPostRelays as? Collection<NormalizedRelayUrl?>)?.firstOrNull()
is LiveActivitiesEvent -> {
noteEvent.relays().ifEmpty { null }?.toSet()
}
is LiveActivitiesChatMessageEvent -> {
inGatherers?.forEach {
if (it is com.vitorpamplona.amethyst.commons.model.Channel) {
it.relays().firstOrNull()?.let { return it }
}
}
}
is EphemeralChatEvent -> noteEvent.roomId()?.let { return it.relayUrl }
}
val currentOutbox = author?.outboxRelays()?.toSet()
@@ -22,11 +22,11 @@ package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportCache
import com.vitorpamplona.amethyst.commons.model.trustedAssertions.UserCardsCache
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
@@ -51,7 +51,6 @@ class User(
val pubkeyHex: String,
val nip65RelayListNote: Note,
val dmRelayListNote: Note,
private val cacheProvider: ICacheProvider? = null,
) {
private var reports: UserReportCache? = null
private var cards: UserCardsCache? = null
@@ -120,8 +119,8 @@ class User(
fun profilePicture(): String? = info?.picture
fun updateContactList(event: ContactListEvent) {
if (event.id == latestContactList?.id) return
fun updateContactList(event: ContactListEvent): Set<HexKey> {
if (event.id == latestContactList?.id) return emptySet()
val oldContactListEvent = latestContactList
latestContactList = event
@@ -129,20 +128,9 @@ class User(
// Update following of the current user
flowSet?.follows?.invalidateData()
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
(cacheProvider?.getUserIfExists(it) as? User)
?.flowSet
?.followers
?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
(cacheProvider?.getUserIfExists(it) as? User)
?.flowSet
?.followers
?.invalidateData()
}
val affectedUsers = event.verifiedFollowKeySet() + (oldContactListEvent?.verifiedFollowKeySet() ?: emptySet())
return affectedUsers
}
fun addZap(
@@ -217,11 +205,6 @@ class User(
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
fun transientFollowerCount(): Int =
cacheProvider?.countUsers { _, it ->
(it as? User)?.latestContactList?.isTaggedUser(pubkeyHex) ?: false
} ?: 0
fun reportsOrNull(): UserReportCache? = reports
fun reports(): UserReportCache = reports ?: UserReportCache().also { reports = it }
@@ -20,6 +20,12 @@
*/
package com.vitorpamplona.amethyst.commons.model.cache
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
@@ -42,7 +48,7 @@ interface ICacheProvider {
* @param note The note to look up channel for
* @return The channel if found, null otherwise
*/
fun getAnyChannel(note: Any?): IChannel?
fun getAnyChannel(note: Note): Channel?
/**
* Gets a User by public key hex.
@@ -60,7 +66,7 @@ interface ICacheProvider {
* @param predicate Filter function for counting users
* @return Count of users matching the predicate
*/
fun countUsers(predicate: (String, Any) -> Boolean): Int
fun countUsers(predicate: (String, User) -> Boolean): Int
/**
* Gets a Note if it exists in cache.
@@ -78,7 +84,16 @@ interface ICacheProvider {
* @param hexKey The note's ID in hex format
* @return The Note (existing or newly created)
*/
fun checkGetOrCreateNote(hexKey: HexKey): Any?
fun checkGetOrCreateNote(hexKey: HexKey): Note?
/**
* Gets an existing AddressableNote or creates a new one if it doesn't exist.
* Used by ThreadAssembler for building thread structures.
*
* @param address The note's ID in address format
* @return The AddressableNote (existing or newly created)
*/
fun getOrCreateAddressableNote(key: Address): AddressableNote
/**
* Gets the event stream for cache updates.
@@ -118,17 +133,6 @@ interface ICacheProvider {
* @return The User (existing or newly created)
*/
fun getOrCreateUser(pubkey: HexKey): Any?
}
/**
* Minimal channel interface for relay resolution.
* Full channel implementations (PublicChatChannel, LiveActivitiesChannel)
* implement this interface.
*/
interface IChannel {
/**
* Gets the relay URLs for this channel.
* @return List of relay URLs or null if none configured
*/
fun relays(): List<Any>?
fun justConsumeMyOwnEvent(event: Event): Boolean
}
@@ -0,0 +1,36 @@
/**
* 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.emphChat
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@Stable
class EphemeralChatChannel(
val roomId: RoomId,
) : Channel() {
override fun relays() = setOf(roomId.relayUrl)
override fun toBestDisplayName() = roomId.toDisplayKey()
fun anyNameStartsWith(prefix: String): Boolean = roomId.id.contains(prefix, true)
}
@@ -0,0 +1,41 @@
/**
* 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.emphChat
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.roomSet
import com.vitorpamplona.quartz.experimental.ephemChat.list.rooms
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
class EphemeralChatListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<EphemeralChatListEvent>(signer)
fun cachedRoomSet(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagListPrecached(event).roomSet()
fun cachedRooms(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagListPrecached(event).rooms()
suspend fun roomSet(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagList(event).roomSet()
suspend fun rooms(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagList(event).rooms()
}
@@ -0,0 +1,135 @@
/**
* 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.emphChat
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.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
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.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
interface EphemeralChatRepository {
fun ephemeralChatList(): EphemeralChatListEvent?
fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?)
}
class EphemeralChatListState(
val signer: NostrSigner,
val cache: ICacheProvider,
val decryptionCache: EphemeralChatListDecryptionCache,
val scope: CoroutineScope,
val settings: EphemeralChatRepository,
) {
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val ephemeralChatListNote = cache.getOrCreateAddressableNote(getEphemeralChatListAddress())
fun getEphemeralChatListAddress() = EphemeralChatListEvent.createAddress(signer.pubKey)
fun getEphemeralChatListFlow(): StateFlow<NoteState> = ephemeralChatListNote.flow().metadata.stateFlow
fun getEphemeralChatList(): EphemeralChatListEvent? = ephemeralChatListNote.event as? EphemeralChatListEvent
suspend fun ephemeralChatListWithBackup(note: Note): Set<RoomId> {
val event = note.event as? EphemeralChatListEvent ?: settings.ephemeralChatList()
return event?.let { decryptionCache.roomSet(it) } ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveEphemeralChatList: StateFlow<Set<RoomId>> =
getEphemeralChatListFlow()
.transformLatest { noteState ->
emit(ephemeralChatListWithBackup(noteState.note))
}.onStart {
emit(ephemeralChatListWithBackup(ephemeralChatListNote))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
suspend fun follow(channel: EphemeralChatChannel): EphemeralChatListEvent {
val ephemeralChatList = getEphemeralChatList()
return if (ephemeralChatList == null) {
EphemeralChatListEvent.create(
room = channel.roomId,
isPrivate = true,
signer = signer,
)
} else {
EphemeralChatListEvent.add(
earlierVersion = ephemeralChatList,
room = channel.roomId,
isPrivate = true,
signer = signer,
)
}
}
suspend fun unfollow(channel: EphemeralChatChannel): EphemeralChatListEvent? {
val ephemeralChatList = getEphemeralChatList()
return if (ephemeralChatList != null) {
EphemeralChatListEvent.remove(
earlierVersion = ephemeralChatList,
room = channel.roomId,
signer = signer,
)
} else {
null
}
}
init {
settings.ephemeralChatList()?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
cache.justConsumeMyOwnEvent(event)
}
}
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start")
getEphemeralChatListFlow().collect { noteState ->
Log.d("AccountRegisterObservers", "EphemeralChatList List for ${signer.pubKey}")
(noteState.note.event as? EphemeralChatListEvent)?.let {
settings.updateEphemeralChatListTo(it)
}
}
}
}
}
@@ -0,0 +1,110 @@
/**
* 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.nip28PublicChats
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHintOptional
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm
@Stable
class PublicChatChannel(
val idHex: String,
) : Channel() {
var creator: User? = null
var event: ChannelCreateEvent? = null
// Important to keep this long-term reference because LocalCache uses WeakReferences.
var creationEventNote: Note? = null
var updateEventNote: Note? = null
var info = ChannelDataNorm(null, null, null, null)
var infoTags = EmptyTagList
var updatedMetadataAt: Long = 0
override fun relays() = info.relays?.toSet() ?: super.relays()
fun relayHintUrls() = relays().take(3)
fun relayHintUrl() = relays().firstOrNull()
fun toNEvent() = NEvent.create(idHex, event?.pubKey, ChannelCreateEvent.KIND, relayHintUrls())
fun toNostrUri() = "nostr:${toNEvent()}"
fun toEventHint() = event?.let { EventHintBundle(it, relayHintUrl(), null) }
fun toEventId() = EventIdHintOptional(idHex, relayHintUrl())
fun updateChannelInfo(
creator: User,
event: ChannelCreateEvent,
eventNote: Note? = null,
) {
this.creator = creator
this.event = event
this.info = event.channelInfo()
this.infoTags = event.tags.toImmutableListOfLists()
this.updatedMetadataAt = event.createdAt
this.creationEventNote = eventNote
updateChannelInfo()
}
fun updateChannelInfo(
creator: User,
event: ChannelMetadataEvent,
eventNote: Note? = null,
) {
this.creator = creator
this.info = event.channelInfo()
this.infoTags = event.tags.toImmutableListOfLists()
this.updatedMetadataAt = event.createdAt
this.updateEventNote = eventNote
super.updateChannelInfo()
}
override fun toBestDisplayName(): String = info.name ?: toNEvent().toShortDisplay()
fun summary(): String? = info.about
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) return creator?.info?.banner
return info.picture ?: creator?.info?.banner
}
fun anyNameStartsWith(prefix: String): Boolean =
idHex.startsWith(prefix) ||
info.name?.contains(prefix, true) == true ||
info.about?.contains(prefix, true) == true
}
@@ -0,0 +1,41 @@
/**
* 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.nip28PublicChats
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.channelSet
import com.vitorpamplona.quartz.nip28PublicChat.list.channels
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
class PublicChatListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<ChannelListEvent>(signer)
fun cachedChannelSet(event: ChannelListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channelSet()
fun cachedChannels(event: ChannelListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channels()
suspend fun channelSet(event: ChannelListEvent) = cachedPrivateLists.mergeTagList(event).channelSet()
suspend fun channels(event: ChannelListEvent) = cachedPrivateLists.mergeTagList(event).channels()
}
@@ -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.nip28PublicChats
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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
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.GlobalScope
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.flow.transformLatest
import kotlinx.coroutines.launch
interface PublicChatListRepository {
fun channelList(): ChannelListEvent?
fun updateChannelListTo(newChannelList: ChannelListEvent?)
}
class PublicChatListState(
val signer: NostrSigner,
val cache: ICacheProvider,
val decryptionCache: PublicChatListDecryptionCache,
val scope: CoroutineScope,
val settings: PublicChatListRepository,
) {
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val publicChatListNote = cache.getOrCreateAddressableNote(getChannelListAddress())
fun getChannelListAddress() = ChannelListEvent.createAddress(signer.pubKey)
fun getChannelListFlow(): StateFlow<NoteState> = publicChatListNote.flow().metadata.stateFlow
fun getChannelList(): ChannelListEvent? = publicChatListNote.event as? ChannelListEvent
suspend fun publicChatListWithBackup(note: Note): Set<ChannelTag> {
val event = note.event as? ChannelListEvent ?: settings.channelList()
return event?.let { decryptionCache.channelSet(it) } ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<ChannelTag>> =
getChannelListFlow()
.transformLatest { noteState ->
emit(publicChatListWithBackup(noteState.note))
}.onStart {
emit(publicChatListWithBackup(publicChatListNote))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val flowSet: StateFlow<Set<HexKey>> =
flow
.map {
it.mapTo(mutableSetOf()) { it.eventId }
}.onStart {
emit(flow.value.mapTo(mutableSetOf()) { it.eventId })
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
suspend fun follow(channel: PublicChatChannel): ChannelListEvent {
val publicChatList = getChannelList()
return if (publicChatList == null) {
ChannelListEvent.create(ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer)
} else {
ChannelListEvent.add(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer)
}
}
suspend fun follow(channels: List<PublicChatChannel>): ChannelListEvent {
val publicChatList = getChannelList()
val channelTags = channels.map { ChannelTag(it.idHex, it.relayHintUrl()) }
return if (publicChatList == null) {
ChannelListEvent.create(channelTags, true, signer)
} else {
ChannelListEvent.add(publicChatList, channelTags, true, signer)
}
}
suspend fun unfollow(channel: PublicChatChannel): ChannelListEvent? {
val publicChatList = getChannelList()
return if (publicChatList != null) {
ChannelListEvent.remove(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), signer)
} else {
null
}
}
init {
settings.channelList()?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved channel list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
cache.justConsumeMyOwnEvent(event)
}
}
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Channel List Collector Start")
getChannelListFlow().collect {
Log.d("AccountRegisterObservers", "Channel List for ${signer.pubKey}")
(it.note.event as? ChannelListEvent)?.let {
settings.updateChannelListTo(it)
}
}
}
}
}
@@ -0,0 +1,154 @@
/**
* 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.nip30CustomEmojis
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.nip01Core.tags.aTag.taggedAddresses
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
class EmojiPackState(
val signer: NostrSigner,
val cache: ICacheProvider,
val scope: CoroutineScope,
) {
class EmojiMedia(
val code: String,
val link: String,
)
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val emojiPackListNote = cache.getOrCreateAddressableNote(getEmojiPackSelectionAddress())
fun getEmojiPackSelectionAddress() = EmojiPackSelectionEvent.createAddress(signer.pubKey)
fun getEmojiPackSelection(): EmojiPackSelectionEvent? = emojiPackListNote.event as? EmojiPackSelectionEvent
fun getEmojiPackSelectionFlow(): StateFlow<NoteState> = emojiPackListNote.flow().metadata.stateFlow
fun convertEmojiSelectionPack(selection: EmojiPackSelectionEvent?): List<StateFlow<NoteState>>? =
selection?.taggedAddresses()?.map {
cache
.getOrCreateAddressableNote(it)
.flow()
.metadata.stateFlow
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<List<StateFlow<NoteState>>?> =
getEmojiPackSelectionFlow()
.transformLatest {
emit(convertEmojiSelectionPack(it.note.event as? EmojiPackSelectionEvent))
}.onStart {
emit(convertEmojiSelectionPack(getEmojiPackSelection()))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
fun convertEmojiPack(pack: EmojiPackEvent): List<EmojiMedia> =
pack.taggedEmojis().map {
EmojiMedia(it.code, it.url)
}
fun mergePack(list: Array<NoteState>): List<EmojiMedia> =
list
.mapNotNull {
val ev = it.note.event as? EmojiPackEvent
if (ev != null) {
convertEmojiPack(ev)
} else {
null
}
}.flatten()
.distinctBy { it.link }
@OptIn(ExperimentalCoroutinesApi::class)
val myEmojis =
flow
.transformLatest { emojiList ->
if (emojiList != null) {
emitAll(
combineTransform(emojiList) {
emit(mergePack(it))
},
)
} else {
emit(emptyList())
}
}.onStart {
emit(
mergePack(
convertEmojiSelectionPack(
getEmojiPackSelection(),
)?.map { it.value }?.toTypedArray() ?: emptyArray(),
),
)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun addEmojiPack(emojiPack: Note): EmojiPackSelectionEvent {
val emojiPackEvent = emojiPack.event
if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.")
val eventHint = emojiPack.toEventHint<EmojiPackEvent>() ?: throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.")
val usersEmojiList = getEmojiPackSelection()
return if (usersEmojiList == null) {
val template = EmojiPackSelectionEvent.build(listOf(eventHint))
signer.sign(template)
} else {
val template = EmojiPackSelectionEvent.add(usersEmojiList, eventHint)
signer.sign(template)
}
}
suspend fun removeEmojiPack(emojiPack: Note): EmojiPackSelectionEvent? {
val usersEmojiList = getEmojiPackSelection() ?: throw IllegalArgumentException("Cannot remove an emoji pack to this kind of event.")
val emojiPackEvent = emojiPack.event
if (emojiPackEvent !is EmojiPackEvent) return null
val template = EmojiPackSelectionEvent.remove(usersEmojiList, emojiPackEvent)
return signer.sign(template)
}
}
@@ -0,0 +1,62 @@
/**
* 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.nip38UserStatuses
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
class UserStatusAction {
companion object {
suspend fun create(
newStatus: String,
signer: NostrSigner,
): StatusEvent = StatusEvent.create(newStatus, "general", expiration = null, signer)
suspend fun update(
oldStatus: AddressableNote,
newStatus: String,
signer: NostrSigner,
): StatusEvent {
val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event")
return StatusEvent.update(oldEvent, newStatus, signer)
}
suspend fun delete(
oldStatus: AddressableNote,
signer: NostrSigner,
): List<Event> {
val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event")
val event = StatusEvent.clear(oldEvent, signer)
val deletion =
signer.sign(
DeletionEvent.buildForVersionOnly(listOf(event)),
)
return listOf(event, deletion)
}
}
}
@@ -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.nip53LiveActivities
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
@Stable
class LiveActivitiesChannel(
val address: Address,
) : Channel() {
var creator: User? = null
var info: LiveActivitiesEvent? = null
// Important to keep this long-term reference because LocalCache uses WeakReferences.
var infoNote: Note? = null
fun address() = address
override fun relays() = info?.allRelayUrls()?.toSet()?.ifEmpty { null } ?: super.relays()
fun relayHintUrl() = relays().firstOrNull()
fun relayHintUrls() = relays().take(3)
fun updateChannelInfo(
creator: User,
channelInfo: LiveActivitiesEvent,
channelInfoNote: Note,
) {
this.info = channelInfo
this.creator = creator
this.infoNote = channelInfoNote
super.updateChannelInfo()
}
override fun toBestDisplayName(): String = info?.title() ?: creatorName() ?: toNAddr().toShortDisplay()
fun creatorName(): String? = creator?.toBestDisplayName()
fun summary(): String? = info?.summary()
fun profilePicture(): String? = info?.image()?.ifBlank { null }
fun anyNameStartsWith(prefix: String): Boolean =
info?.title()?.contains(prefix, true) == true ||
info?.summary()?.contains(prefix, true) == true
fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, relayHintUrls())
fun toATag() = ATag(address, relayHintUrl())
fun toNostrUri() = "nostr:${toNAddr()}"
}
@@ -0,0 +1,65 @@
/**
* 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.nip56Reports
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
class ReportAction {
companion object {
suspend fun report(
user: User,
type: ReportType,
content: String = "",
by: User,
signer: NostrSigner,
): ReportEvent? {
if (user.reports().hasReport(by, type)) {
// has already reported this note
return null
}
val template = ReportEvent.build(user.pubkeyHex, type, content)
return signer.sign(template)
}
suspend fun report(
note: Note,
type: ReportType,
content: String = "",
by: User,
signer: NostrSigner,
): ReportEvent? {
if (note.hasReport(by, type)) {
// has already reported this note
return null
}
return note.event?.let {
signer.sign(ReportEvent.build(it, type, content))
}
}
}
}
@@ -0,0 +1,145 @@
/**
* 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.privateChats
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel.Companion.DefaultFeedOrder
import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
@Stable
class Chatroom : NotesGatherer {
var activeSenders: Set<User> = setOf()
var messages: Set<Note> = setOf()
var subject = MutableStateFlow<String?>(null)
var subjectCreatedAt: Long? = null
var ownerSentMessage: Boolean = false
var newestMessage: Note? = null
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
val current = changesFlow.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
return new
}
override fun removeNote(note: Note) {
removeMessageSync(note)
}
@Synchronized
fun addMessageSync(msg: Note): Boolean {
if (msg !in messages) {
messages = messages + msg
msg.addGatherer(this)
msg.author?.let { author ->
if (author !in activeSenders) {
activeSenders + author
}
}
val createdAt = msg.createdAt() ?: 0L
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
newestMessage = msg
}
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0L) > (subjectCreatedAt ?: 0)) {
subject.tryEmit(newSubject)
subjectCreatedAt = msg.createdAt()
}
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
return true
}
return false
}
@Synchronized
fun removeMessageSync(msg: Note): Boolean {
if (msg in messages) {
messages = messages - msg
msg.removeGatherer(this)
if (msg == newestMessage) {
newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
}
if (msg.event?.subject() == subject.value) {
messages
.maxByOrNull {
val noteEvent = it.event
if (noteEvent?.subject() != null) {
noteEvent.createdAt
} else {
0
}
}?.let {
subject.tryEmit(it.event?.subject())
subjectCreatedAt = it.createdAt()
}
}
changesFlow.get()?.tryEmit(ListChange.Deletion(msg))
return true
}
return false
}
fun senderIntersects(keySet: Set<HexKey>): Boolean = activeSenders.any { it.pubkeyHex in keySet }
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = messages.sortedWith(DefaultFeedOrder)
val toKeep =
if ((sorted.firstOrNull()?.createdAt() ?: 0L) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent }
val toRemove = messages.minus(toKeep)
messages = toKeep
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
return toRemove
}
}
@@ -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.model.privateChats
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.collections.immutable.persistentSetOf
class ChatroomList(
val ownerPubKey: HexKey,
) {
var rooms = LargeCache<ChatroomKey, Chatroom>()
private set
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom = rooms.getOrCreate(key) { Chatroom() }
fun getOrCreatePrivateChatroom(user: User): Chatroom {
val key = ChatroomKey(persistentSetOf(user.pubkeyHex))
return getOrCreatePrivateChatroom(key)
}
fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom = getOrCreatePrivateChatroomSync(key)
fun add(
event: ChatroomKeyable,
msg: Note,
) {
if (event.isIncluded(ownerPubKey)) {
val key = event.chatroomKey(ownerPubKey)
addMessage(key, msg)
}
}
fun delete(
event: ChatroomKeyable,
msg: Note,
) {
if (event.isIncluded(ownerPubKey)) {
val key = event.chatroomKey(ownerPubKey)
removeMessage(key, msg)
}
}
fun addMessage(
room: ChatroomKey,
msg: Note,
) {
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.messages) {
privateChatroom.addMessageSync(msg)
if (msg.author?.pubkeyHex == ownerPubKey) {
privateChatroom.ownerSentMessage = true
}
}
}
fun addMessage(
user: User,
msg: Note,
) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.messages) {
privateChatroom.addMessageSync(msg)
if (msg.author?.pubkeyHex == ownerPubKey) {
privateChatroom.ownerSentMessage = true
}
}
}
fun removeMessage(
user: User,
msg: Note,
) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.messages) {
privateChatroom.removeMessageSync(msg)
}
}
fun removeMessage(
room: ChatroomKey,
msg: Note,
) {
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg in privateChatroom.messages) {
privateChatroom.removeMessageSync(msg)
}
}
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
if (key == null) return false
return rooms.get(key)?.ownerSentMessage == true
}
}
@@ -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.richtext
import java.util.Base64
object Base64Image {
val pattern = Patterns.BASE64_IMAGE
fun isBase64(content: String): Boolean = Patterns.BASE64_IMAGE.matches(content)
fun parse(content: String): ByteArray {
val matcher = pattern.find(content)
if (matcher != null) {
val base64String = matcher.groups[2]?.value
val byteArray = Base64.getDecoder().decode(base64String)
return byteArray
}
throw Exception("Unable to convert base64 to image $content")
}
}
@@ -0,0 +1,82 @@
/**
* 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.richtext
class ExpandableTextCutOffCalculator {
companion object {
private const val SHORT_TEXT_LENGTH = 350
private const val SHORTEN_AFTER_LINES = 10
private const val TOO_FAR_SEARCH_THE_OTHER_WAY = 450
fun indexToCutOff(content: String): Int {
// Cuts the text in the first space or first new line after SHORT_TEXT_LENGTH characters
val firstSpaceAfterCut =
content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val firstNewLineAfterCut =
content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
// Cuts the text if too many new lines have passed.
val firstLineAfterLineLimits =
content.nthIndexOf('\n', SHORTEN_AFTER_LINES).let { if (it < 0) content.length else it }
// gets the minimum of them all.
val min = minOf(firstSpaceAfterCut, firstNewLineAfterCut, firstLineAfterLineLimits)
val result =
if (min > TOO_FAR_SEARCH_THE_OTHER_WAY) {
// if it is still too big, finds the first space or new line BEFORE the cut off.
val newString = content.take(SHORT_TEXT_LENGTH)
val firstSpaceBeforeCut =
newString.lastIndexOf(' ').let { if (it < 0) content.length else it }
val firstNewLineBeforeCut =
newString.lastIndexOf('\n').let { if (it < 0) content.length else it }
maxOf(firstSpaceBeforeCut, firstNewLineBeforeCut)
} else {
min
}
// Only returns if the difference between short and long posts is more than 100 chars or too many new lines.
return if (result == firstLineAfterLineLimits || result + 100 < content.length) {
result
} else {
content.length
}
}
}
}
fun String.nthIndexOf(
ch: Char,
N: Int,
): Int {
var occur = N
var pos = -1
while (occur > 0) {
// calling the native function multiple times is faster than looping just once
pos = indexOf(ch, pos + 1)
if (pos == -1) return -1
occur--
}
return if (occur == 0) pos else -1
}
@@ -0,0 +1,194 @@
/**
* 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.richtext
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import kotlinx.collections.immutable.toImmutableList
data class ParagraphImageAnalysis(
val imageCount: Int,
val isImageOnly: Boolean,
val hasMultipleImages: Boolean,
)
class GalleryParser {
fun analyzeParagraphImages(paragraph: ParagraphState): ParagraphImageAnalysis {
var imageCount = 0
var hasNonWhitespaceNonImageContent = false
paragraph.words.forEach { word ->
when (word) {
is ImageSegment, is Base64Segment -> imageCount++
is VideoSegment -> hasNonWhitespaceNonImageContent = true // Videos are not images
is RegularTextSegment -> {
if (word.segmentText.isNotBlank()) {
hasNonWhitespaceNonImageContent = true
}
}
else -> hasNonWhitespaceNonImageContent = true // Links, emojis, etc.
}
}
val isImageOnly = imageCount > 0 && !hasNonWhitespaceNonImageContent
val hasMultipleImages = imageCount > 1
return ParagraphImageAnalysis(
imageCount = imageCount,
isImageOnly = isImageOnly,
hasMultipleImages = hasMultipleImages,
)
}
fun collectConsecutiveImageParagraphs(
paragraphs: List<ParagraphState>,
startIndex: Int,
): Pair<List<ParagraphState>, Int> {
val imageParagraphs = mutableListOf<ParagraphState>()
var j = startIndex
while (j < paragraphs.size) {
val currentParagraph = paragraphs[j]
val words = currentParagraph.words
// Fast path for empty check
if (words.isEmpty()) {
j++
continue
}
// Check for single whitespace word
if (words.size == 1) {
val firstWord = words.first()
if (firstWord is RegularTextSegment && firstWord.segmentText.isBlank()) {
j++
continue
}
}
// Check if it's an image-only paragraph using unified analysis
val analysis = analyzeParagraphImages(currentParagraph)
if (analysis.isImageOnly) {
imageParagraphs.add(currentParagraph)
j++
} else {
break
}
}
return imageParagraphs to j
}
@OptIn(ExperimentalLayoutApi::class)
fun processParagraphs(paragraphs: List<ParagraphState>): List<ParagraphState> {
val result = mutableListOf<ParagraphState>()
var paragraphIndex = 0
while (paragraphIndex < paragraphs.size) {
val paragraph = paragraphs[paragraphIndex]
if (paragraph.words.isEmpty()) {
// Empty paragraph - render normally with FlowRow (will render nothing)
result.add(paragraph)
paragraphIndex++
} else {
val analysis = analyzeParagraphImages(paragraph)
if (analysis.isImageOnly) {
// Collect consecutive image-only paragraphs for gallery
val (imageParagraphs, endIndex) = collectConsecutiveImageParagraphs(paragraphs, paragraphIndex)
val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList()
if (allImageWords.size > 1) {
result.add(ImageGalleryParagraph(allImageWords, paragraph.isRTL))
} else {
// Single image - render with FlowRow wrapper
result.add(paragraph)
}
paragraphIndex = endIndex // Return next index to process
} else if (analysis.hasMultipleImages) {
// Mixed paragraph with multiple images - break it down into many paragraphs
result.addAll(processWordsWithImageGrouping(paragraph))
paragraphIndex++
} else {
result.add(paragraph)
paragraphIndex++
}
}
}
return result
}
fun processWordsWithImageGrouping(paragraph: ParagraphState): List<ParagraphState> {
val resultingParagraphs = mutableListOf<ParagraphState>()
var i = 0
val n = paragraph.words.size
var currentParagraphSegments = mutableListOf<Segment>()
while (i < n) {
val word = paragraph.words[i]
if (word is ImageSegment || word is Base64Segment) {
// Collect consecutive image/whitespace segments (but not videos)
val imageSegments = mutableListOf<Segment>()
var j = i
var hasVideo = false
while (j < n) {
val seg = paragraph.words[j]
when {
seg is VideoSegment -> {
hasVideo = true
break
}
seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg)
seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ }
else -> break
}
j++
}
// If we found a video, don't create a gallery - render images individually
if (hasVideo || imageSegments.size <= 1) {
currentParagraphSegments.addAll(imageSegments)
} else {
if (currentParagraphSegments.isNotEmpty()) {
resultingParagraphs.add(ParagraphState(currentParagraphSegments.toImmutableList(), paragraph.isRTL))
currentParagraphSegments = mutableListOf<Segment>()
}
resultingParagraphs.add(ImageGalleryParagraph(imageSegments.toImmutableList(), paragraph.isRTL))
}
i = j // jump past processed run
} else {
currentParagraphSegments.add(word)
i++
}
}
if (currentParagraphSegments.isNotEmpty()) {
resultingParagraphs.add(ParagraphState(currentParagraphSegments.toImmutableList(), paragraph.isRTL))
}
return resultingParagraphs
}
}
@@ -0,0 +1,138 @@
/**
* 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.richtext
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import java.io.File
@Immutable
abstract class BaseMediaContent(
val description: String? = null,
val dim: DimensionTag? = null,
val blurhash: String? = null,
)
@Immutable
abstract class MediaUrlContent(
val url: String,
description: String? = null,
val hash: String? = null,
dim: DimensionTag? = null,
blurhash: String? = null,
val uri: String? = null,
val mimeType: String? = null,
) : BaseMediaContent(description, dim, blurhash)
@Immutable
open class MediaUrlImage(
url: String,
description: String? = null,
hash: String? = null,
blurhash: String? = null,
dim: DimensionTag? = null,
uri: String? = null,
val contentWarning: String? = null,
mimeType: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
class EncryptedMediaUrlImage(
url: String,
description: String? = null,
hash: String? = null,
blurhash: String? = null,
dim: DimensionTag? = null,
uri: String? = null,
contentWarning: String? = null,
mimeType: String? = null,
val encryptionAlgo: String,
val encryptionKey: ByteArray,
val encryptionNonce: ByteArray,
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType)
@Immutable
open class MediaUrlVideo(
url: String,
description: String? = null,
hash: String? = null,
dim: DimensionTag? = null,
uri: String? = null,
val artworkUri: String? = null,
val authorName: String? = null,
blurhash: String? = null,
val contentWarning: String? = null,
mimeType: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
@Immutable
class EncryptedMediaUrlVideo(
url: String,
description: String? = null,
hash: String? = null,
dim: DimensionTag? = null,
uri: String? = null,
artworkUri: String? = null,
authorName: String? = null,
blurhash: String? = null,
contentWarning: String? = null,
mimeType: String? = null,
val encryptionAlgo: String,
val encryptionKey: ByteArray,
val encryptionNonce: ByteArray,
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType)
@Immutable
abstract class MediaPreloadedContent(
val localFile: File?,
description: String? = null,
val mimeType: String? = null,
val isVerified: Boolean? = null,
dim: DimensionTag? = null,
blurhash: String? = null,
val uri: String,
val id: String? = null,
) : BaseMediaContent(description, dim, blurhash) {
fun localFileExists() = localFile != null && localFile.exists()
}
@Immutable
class MediaLocalImage(
localFile: File?,
mimeType: String? = null,
description: String? = null,
dim: DimensionTag? = null,
blurhash: String? = null,
isVerified: Boolean? = null,
uri: String,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri)
@Immutable
class MediaLocalVideo(
localFile: File?,
mimeType: String? = null,
description: String? = null,
dim: DimensionTag? = null,
blurhash: String? = null,
isVerified: Boolean? = null,
uri: String,
val artworkUri: String? = null,
val authorName: String? = null,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri)
@@ -0,0 +1,48 @@
/**
* 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.richtext
/**
* Pattern constants for email and phone validation.
* These replace android.util.Patterns for KMP compatibility.
*/
object Patterns {
/**
* Email address pattern from RFC 5322... From android.util.Patterns.
*/
val EMAIL_ADDRESS: Regex =
Regex(
"[a-zA-Z0-9+._%-]{1,256}@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+",
)
/**
* Phone number pattern - matches common phone formats.
*/
val PHONE: Regex =
Regex(
"^[+]?[(]?[0-9]{1,4}[)]?[-\\s./0-9]*\$",
)
val BASE64_IMAGE: Regex =
Regex(
"data:image/(${RichTextParser.imageExtensions.joinToString(separator = "|")});base64,([a-zA-Z0-9+/]+={0,2})",
)
}
@@ -0,0 +1,440 @@
/**
* 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.richtext
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetasByUrl
import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.collections.immutable.toPersistentList
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import kotlin.coroutines.cancellation.CancellationException
class RichTextParser {
fun createMediaContent(
fullUrl: String,
eventTags: Map<String, IMetaTag>,
description: String?,
callbackUri: String? = null,
): MediaUrlContent? {
val frags = Nip54InlineMetadata().parse(fullUrl)
val tags = eventTags.get(fullUrl)?.properties ?: emptyMap()
val contentType = frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull()
val isImage: Boolean
val isVideo: Boolean
if (contentType != null) {
isImage = contentType.startsWith("image/")
isVideo = contentType.startsWith("video/")
} else if (fullUrl.startsWith("data:")) {
isImage = fullUrl.startsWith("data:image/")
isVideo = fullUrl.startsWith("data:video/")
} else {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
}
return if (isImage) {
MediaUrlImage(
url = fullUrl,
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(),
blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(),
dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) },
contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(),
uri = callbackUri,
mimeType = contentType,
)
} else if (isVideo) {
MediaUrlVideo(
url = fullUrl,
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(),
blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(),
dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) },
contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(),
uri = callbackUri,
mimeType = contentType,
)
} else {
null
}
}
fun parseValidUrls(content: String): LinkedHashSet<String> {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
return urls.mapNotNullTo(LinkedHashSet(urls.size)) {
if (it.originalUrl.contains("@")) {
if (Patterns.EMAIL_ADDRESS.matches(it.originalUrl)) {
null
} else {
it.originalUrl
}
} else if (isNumber(it.originalUrl)) {
null // avoids urls that look like 123.22
} else if (it.originalUrl.contains("")) {
null // avoids Japanese characters as fake urls
} else {
if (HTTPRegex.matches(it.originalUrl)) {
it.originalUrl
} else {
null
}
}
}
}
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String?,
): RichTextViewerState {
val imetas = tags.lists.imetasByUrl()
val urlSet = parseValidUrls(content)
val imagesForPager =
urlSet.mapNotNull { fullUrl -> createMediaContent(fullUrl, imetas, content, callbackUri) }.associateBy { it.url }
val imageUrls = imagesForPager.filterValues { it is MediaUrlImage }.keys
val videoUrls = imagesForPager.filterValues { it is MediaUrlVideo }.keys
val emojiMap = CustomEmoji.createEmojiMap(tags.lists)
val segments = findTextSegments(content, imageUrls, videoUrls, urlSet, emojiMap, tags)
val base64Images = segments.map { it.words.filterIsInstance<Base64Segment>() }.flatten()
val imagesForPagerWithBase64 =
imagesForPager +
base64Images
.mapNotNull { createMediaContent(it.segmentText, emptyMap(), content, callbackUri) }
.associateBy { it.url }
return RichTextViewerState(
urlSet.toImmutableSet(),
imagesForPagerWithBase64.toImmutableMap(),
imagesForPagerWithBase64.values.toImmutableList(),
emojiMap.toImmutableMap(),
segments,
tags,
)
}
private fun findTextSegments(
content: String,
images: Set<String>,
videos: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): ImmutableList<ParagraphState> {
val lines = content.split('\n')
val paragraphSegments = ArrayList<ParagraphState>(lines.size)
lines.forEach { paragraph ->
val isRTL = isArabic(paragraph)
val wordList = paragraph.trimEnd().split(' ')
val segments = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
segments.add(wordIdentifier(word, images, videos, urls, emojis, tags))
}
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
}
val segmentsWithGalleries = GalleryParser().processParagraphs(paragraphSegments)
return segmentsWithGalleries
.map { paragraph ->
if (paragraph.words.isEmpty() || paragraph.words.any { it !is RegularTextSegment }) {
paragraph
} else {
ParagraphState(
persistentListOf<Segment>(RegularTextSegment(paragraph.words.joinToString(" ") { it.segmentText })),
paragraph.isRTL,
)
}
}.toImmutableList()
}
private fun isNumber(word: String) = numberPattern.matches(word)
private fun isPhoneNumberChar(c: Char): Boolean =
when (c) {
in '0'..'9' -> true
'-' -> true
' ' -> true
'.' -> true
else -> false
}
fun isPotentialPhoneNumber(word: String): Boolean {
if (word.length !in 7..14) return false
var isPotentialNumber = true
for (c in word) {
if (!isPhoneNumberChar(c)) {
isPotentialNumber = false
break
}
}
return isPotentialNumber
}
fun isDate(word: String): Boolean = shortDatePattern.matches(word) || longDatePattern.matches(word)
private fun isArabic(text: String): Boolean = text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
private fun wordIdentifier(
word: String,
images: Set<String>,
videos: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): Segment {
if (word.isEmpty()) return RegularTextSegment(word)
if (word.startsWith("data:image/")) {
if (Patterns.BASE64_IMAGE.matches(word)) return Base64Segment(word)
}
if (images.contains(word)) return ImageSegment(word)
if (videos.contains(word)) return VideoSegment(word)
if (urls.contains(word)) return LinkSegment(word)
if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word)
if (word.startsWith("lnbc", true)) return InvoiceSegment(word)
if (word.startsWith("lnurl", true)) return WithdrawSegment(word)
if (word.startsWith("cashuA", true) || word.startsWith("cashuB", true)) return CashuSegment(word)
if (word.startsWith("#")) return parseHash(word, tags)
if (EmojiCoder.isCoded(word)) return SecretEmoji(word)
if (word.contains("@")) {
if (Patterns.EMAIL_ADDRESS.matches(word)) return EmailSegment(word)
}
if (startsWithNIP19Scheme(word)) return BechSegment(word)
if (isPotentialPhoneNumber(word) && !isDate(word)) {
if (Patterns.PHONE.matches(word)) return PhoneSegment(word)
}
val indexOfPeriod = word.indexOf(".")
if (indexOfPeriod > 0 && indexOfPeriod < word.length - 1) { // periods cannot be the last one
val schemelessMatcher = noProtocolUrlValidator.find(word)
if (schemelessMatcher != null) {
val url = schemelessMatcher.groups[1]?.value // url
val additionalChars = schemelessMatcher.groups[4]?.value?.ifEmpty { null } // additional chars
if (additionalUrlSchema.find(word) != null && url != null) {
return SchemelessUrlSegment(word, url, additionalChars)
}
}
}
return RegularTextSegment(word)
}
private fun parseHash(
word: String,
tags: ImmutableListOfLists<String>,
): Segment {
// First #[n]
try {
val matcher = tagIndex.find(word)
if (matcher != null) {
val index = matcher.groups[1]?.value?.toInt()
val suffix = matcher.groups[2]?.value
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
return HashIndexUserSegment(word, tag[1], suffix)
} else if (tag[0] == "e" || tag[0] == "a") {
return HashIndexEventSegment(word, tag[1], suffix)
}
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("Tag Parser", "Couldn't link tag $word", e)
}
// Second #Amethyst
try {
val hashtagMatcher = hashTagsPattern.find(word)
if (hashtagMatcher != null) {
val hashtag = hashtagMatcher.groups[1]?.value
if (hashtag != null) {
return HashTagSegment(word, hashtag, hashtagMatcher.groups[2]?.value?.ifEmpty { null })
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
}
return RegularTextSegment(word)
}
companion object {
val longDatePattern: Regex = Regex("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Regex = Regex("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Regex = Regex("^(-?[\\d.]+)([a-zA-Z%]*)$")
// Android9 seems to have an issue starting this regex.
val noProtocolUrlValidator =
try {
Regex(
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)",
)
} catch (e: Exception) {
Regex(
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)",
)
}
val additionalUrlSchema =
"""^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?"""
.toRegex(RegexOption.IGNORE_CASE)
val HTTPRegex =
"^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?"
.toRegex(RegexOption.IGNORE_CASE)
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
val hashTagsPattern: Regex =
Regex("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", RegexOption.IGNORE_CASE)
val acceptedNIP19schemes =
listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1", "nembed") +
listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1", "nembed").map {
it.uppercase()
}
private fun removeQueryParamsForExtensionComparison(fullUrl: String): String =
if (fullUrl.contains("?")) {
fullUrl.split("?")[0]
} else if (fullUrl.contains("#")) {
fullUrl.split("#")[0]
} else {
fullUrl
}
fun isImageOrVideoUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return imageExtensions.any { removedParamsFromUrl.endsWith(it) } ||
videoExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isImageUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return imageExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isVideoUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return videoExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isValidURL(url: String?): Boolean =
try {
URL(url).toURI()
true
} catch (e: MalformedURLException) {
false
} catch (e: URISyntaxException) {
false
}
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
return if (isImage) {
MediaUrlImage(fullUrl)
} else if (isVideo) {
MediaUrlVideo(fullUrl)
} else {
MediaUrlImage(fullUrl)
}
}
fun startsWithNIP19Scheme(word: String): Boolean {
if (word.isEmpty()) return false
return if (word[0] == 'n' || word[0] == 'N') {
if (word.startsWith("nostr:n") || word.startsWith("NOSTR:N")) {
acceptedNIP19schemes.any { word.startsWith(it, 6) }
} else {
acceptedNIP19schemes.any { word.startsWith(it) }
}
} else if (word[0] == '@') {
acceptedNIP19schemes.any { word.startsWith(it, 1) }
} else {
false
}
}
fun isUrlWithoutScheme(url: String) = noProtocolUrlValidator.matches(url)
}
}
@@ -0,0 +1,152 @@
/**
* 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.richtext
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
@Immutable
class RichTextViewerState(
val urlSet: ImmutableSet<String>,
val imagesForPager: ImmutableMap<String, MediaUrlContent>,
val imageList: ImmutableList<MediaUrlContent>,
val customEmoji: ImmutableMap<String, String>,
val paragraphs: ImmutableList<ParagraphState>,
val tags: ImmutableListOfLists<String>,
)
@Immutable
open class ParagraphState(
val words: ImmutableList<Segment>,
val isRTL: Boolean,
)
@Immutable
class ImageGalleryParagraph(
words: ImmutableList<Segment>,
isRTL: Boolean,
) : ParagraphState(words, isRTL)
@Immutable
open class Segment(
val segmentText: String,
)
@Immutable
class ImageSegment(
segment: String,
) : Segment(segment)
@Immutable
class VideoSegment(
segment: String,
) : Segment(segment)
@Immutable
class LinkSegment(
segment: String,
) : Segment(segment)
@Immutable
class EmojiSegment(
segment: String,
) : Segment(segment)
@Immutable
class InvoiceSegment(
segment: String,
) : Segment(segment)
@Immutable
class WithdrawSegment(
segment: String,
) : Segment(segment)
@Immutable
class CashuSegment(
segment: String,
) : Segment(segment)
@Immutable
class EmailSegment(
segment: String,
) : Segment(segment)
class SecretEmoji(
segment: String,
) : Segment(segment)
@Immutable
class PhoneSegment(
segment: String,
) : Segment(segment)
@Immutable
class BechSegment(
segment: String,
) : Segment(segment)
@Immutable
class Base64Segment(
segment: String,
) : Segment(segment)
open class HashIndexSegment(
segment: String,
val hex: String,
val extras: String?,
) : Segment(segment)
@Immutable
class HashIndexUserSegment(
segment: String,
hex: String,
extras: String?,
) : HashIndexSegment(segment, hex, extras)
@Immutable
class HashIndexEventSegment(
segment: String,
hex: String,
extras: String?,
) : HashIndexSegment(segment, hex, extras)
@Immutable
class HashTagSegment(
segment: String,
val hashtag: String,
val extras: String?,
) : Segment(segment)
@Immutable
class SchemelessUrlSegment(
segment: String,
val url: String,
val extras: String?,
) : Segment(segment)
@Immutable
class RegularTextSegment(
segment: String,
) : Segment(segment)
@@ -64,9 +64,8 @@ class ThreadFeedFilter(
val eventsInHex = filteredThreadInfo.allNotes.map { it.idHex }.toSet()
val now = TimeUtils.now()
// Currently orders by date of each event, descending, at each level of the reply stack
val order =
compareByDescending<Note> {
val signatures =
filteredThreadInfo.allNotes.associateWith {
ThreadLevelCalculator
.replyLevelSignature(
it,
@@ -78,6 +77,11 @@ class ThreadFeedFilter(
).signature
}
// Currently orders by date of each event, descending, at each level of the reply stack
val order =
compareByDescending<Note> { signatures[it] }
.thenBy { it.idHex }
return filteredThreadInfo.allNotes.sortedWith(order)
}
}