Further separates channels by kind in the UI

This commit is contained in:
Vitor Pamplona
2025-07-10 10:57:42 -04:00
parent ec985d58e7
commit f9189e9ec1
90 changed files with 1167 additions and 874 deletions
@@ -2254,7 +2254,7 @@ class Account(
delay(1000 * 60 * 1)
// waits 5 minutes before migrating the list.
val contactList = userProfile().latestContactList
val oldChannels = contactList?.taggedEventIds()?.toSet()?.mapNotNull { cache.getChannelIfExists(it) as? PublicChatChannel }
val oldChannels = contactList?.taggedEventIds()?.toSet()?.mapNotNull { cache.getPublicChatChannelIfExists(it) as? PublicChatChannel }
if (oldChannels != null && oldChannels.isNotEmpty()) {
Log.d("DB UPGRADE", "Migrating List with ${oldChannels.size} old channels ")
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
@@ -34,23 +34,17 @@ import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.toNEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
@Stable
class EphemeralChatChannel(
val roomId: RoomId,
) : Channel(roomId.toKey()) {
override fun idNote() = roomId.toDisplayKey()
override fun idDisplayNote() = idNote().toShortenHex()
) : Channel() {
override fun relays() = setOf(roomId.relayUrl)
override fun toBestDisplayName() = roomId.toDisplayKey()
@@ -64,8 +58,8 @@ class EphemeralChatChannel(
@Stable
class PublicChatChannel(
idHex: String,
) : Channel(idHex) {
val idHex: String,
) : Channel() {
var event: ChannelCreateEvent? = null
var infoTags = EmptyTagList
var info = ChannelDataNorm(null, null, null, null)
@@ -110,7 +104,7 @@ class PublicChatChannel(
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String = info.name ?: super.toBestDisplayName()
override fun toBestDisplayName(): String = info.name ?: toNEvent().toShortDisplay()
override fun summary(): String? = info.about
@@ -119,19 +113,18 @@ class PublicChatChannel(
return info.picture ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean = listOfNotNull(info.name, info.about).any { it.contains(prefix, true) }
override fun anyNameStartsWith(prefix: String): Boolean =
idHex.startsWith(prefix) ||
info.name?.contains(prefix, true) == true ||
info.about?.contains(prefix, true) == true
}
@Stable
class LiveActivitiesChannel(
val address: Address,
) : Channel(address.toValue()) {
) : Channel() {
var info: LiveActivitiesEvent? = null
override fun idNote() = toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
fun address() = address
override fun relays() = info?.allRelayUrls()?.toSet() ?: super.relays()
@@ -149,7 +142,7 @@ class LiveActivitiesChannel(
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String = info?.title() ?: super.toBestDisplayName()
override fun toBestDisplayName(): String = info?.title() ?: toNAddr().toShortDisplay()
override fun summary(): String? = info?.summary()
@@ -171,9 +164,7 @@ data class Counter(
)
@Stable
abstract class Channel(
val idHex: String,
) {
abstract class Channel {
var creator: User? = null
var updatedMetadataAt: Long = 0
val notes = LargeCache<HexKey, Note>()
@@ -181,11 +172,7 @@ abstract class Channel(
private var relays = mapOf<NormalizedRelayUrl, Counter>()
open fun idNote() = Hex.decode(idHex).toNEvent()
open fun idDisplayNote() = idNote().toShortenHex()
open fun toBestDisplayName(): String = idDisplayNote()
abstract fun toBestDisplayName(): String
open fun summary(): String? = null
@@ -248,10 +235,6 @@ abstract class Channel(
notes.remove(note.idHex)
}
fun removeNote(noteHex: String) {
notes.remove(noteHex)
}
abstract fun anyNameStartsWith(prefix: String): Boolean
fun pruneOldMessages(): Set<Note> {
@@ -89,6 +89,7 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
@@ -320,11 +321,11 @@ object LocalCache : ILocalCache {
fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId)
fun getChannelIfExists(key: String): PublicChatChannel? = publicChatChannels.get(key)
fun getPublicChatChannelIfExists(key: String): PublicChatChannel? = publicChatChannels.get(key)
fun getChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key)
fun getEphemeralChatChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key)
fun getChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
fun getNoteIfExists(event: Event): Note? =
if (event is AddressableEvent) {
@@ -1345,10 +1346,10 @@ object LocalCache : ILocalCache {
masterNote.removeReport(deleteNote)
}
deleteNote.channelHex()?.let { getChannelIfExists(it)?.removeNote(deleteNote) }
deleteNote.channelHex()?.let { getPublicChatChannelIfExists(it)?.removeNote(deleteNote) }
(deletedEvent as? LiveActivitiesChatMessageEvent)?.activity()?.let {
getChannelIfExists(it.toTag())?.removeNote(deleteNote)
getPublicChatChannelIfExists(it.toTag())?.removeNote(deleteNote)
}
(deletedEvent as? TorrentCommentEvent)?.torrentIds()?.let {
@@ -2318,30 +2319,44 @@ object LocalCache : ILocalCache {
}
}
fun findChannelsStartingWith(text: String): List<Channel> {
checkNotInMainThread()
fun findPublicChatChannelsStartingWith(text: String): List<PublicChatChannel> {
if (text.isBlank()) return emptyList()
val key = decodeEventIdAsHexOrNull(text)
if (key != null && getChannelIfExists(key) != null) {
return listOfNotNull(getChannelIfExists(key))
if (key != null) {
getPublicChatChannelIfExists(key)?.let {
return listOf(it)
}
}
return publicChatChannels.filter { _, channel ->
channel.anyNameStartsWith(text) ||
channel.idHex.startsWith(text, true) ||
channel.idNote().startsWith(text, true)
} +
ephemeralChannels.filter { _, channel ->
channel.anyNameStartsWith(text) ||
channel.idHex.startsWith(text, true) ||
channel.idNote().startsWith(text, true)
} +
liveChatChannels.filter { _, channel ->
channel.anyNameStartsWith(text) ||
channel.idHex.startsWith(text, true) ||
channel.idNote().startsWith(text, true)
channel.anyNameStartsWith(text)
}
}
fun findEphemeralChatChannelsStartingWith(text: String): List<EphemeralChatChannel> {
if (text.isBlank()) return emptyList()
return ephemeralChannels.filter { _, channel ->
channel.anyNameStartsWith(text)
}
}
fun findLiveActivityChannelsStartingWith(text: String): List<LiveActivitiesChannel> {
if (text.isBlank()) return emptyList()
try {
val parsed = Nip19Parser.uriToRoute(text)?.entity
if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) {
return listOf(getOrCreateLiveChannel(parsed.address()))
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
return liveChatChannels.filter { _, channel ->
channel.anyNameStartsWith(text)
}
}
@@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.replace
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue
import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
@@ -91,7 +91,7 @@ class AddressableNote(
override fun toNEvent() = toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
override fun idDisplayNote() = idNote().toShortDisplay()
override fun address() = address
@@ -212,7 +212,7 @@ open class Note(
fun toNostrUri(): String = "nostr:${toNEvent()}"
open fun idDisplayNote() = idNote().toShortenHex()
open fun idDisplayNote() = idNote().toShortDisplay()
fun channelHex(): HexKey? =
if (
@@ -96,7 +96,7 @@ class ParticipantListBuilder {
it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
}
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it ->
LocalCache.getPublicChatChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it ->
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -78,7 +78,7 @@ class User(
fun pubkeyNpub() = pubkey().toNpub()
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay()
fun dmInboxRelayList() = (LocalCache.getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(pubkeyHex))?.event as? ChatMessageRelayListEvent)
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.service.relays.EOSEFollowList
import com.vitorpamplona.amethyst.service.relays.EOSEByKey
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -37,16 +37,16 @@ import kotlin.collections.distinctBy
* This class keeps EOSEs for each SubID.id() for as long as possible and
* shares all EOSEs among all users.
*/
abstract class PerUniqueIdEoseManager<T>(
abstract class PerUniqueIdEoseManager<T, U : Any>(
client: NostrClient,
allKeys: () -> Set<T>,
val invalidateAfterEose: Boolean = false,
) : BaseEoseManager<T>(client, allKeys) {
// long term EOSE cache
private val latestEOSEs = EOSEFollowList()
private val latestEOSEs = EOSEByKey<U>()
// map between each query Id and each subscription id
private val userSubscriptionMap = mutableMapOf<String, String>()
private val userSubscriptionMap = mutableMapOf<U, String>()
fun since(key: T) = latestEOSEs.since(id(key))
@@ -67,7 +67,7 @@ abstract class PerUniqueIdEoseManager<T>(
}
open fun endSub(
key: String,
key: U,
subId: String,
) {
dismissSubscription(subId)
@@ -87,7 +87,7 @@ abstract class PerUniqueIdEoseManager<T>(
override fun updateSubscriptions(keys: Set<T>) {
val uniqueSubscribedAccounts = keys.distinctBy { id(it) }
val updated = mutableSetOf<String>()
val updated = mutableSetOf<U>()
uniqueSubscribedAccounts.forEach {
val mainKey = id(it)
@@ -107,5 +107,5 @@ abstract class PerUniqueIdEoseManager<T>(
since: SincePerRelayMap?,
): List<RelayBasedFilter>?
abstract fun id(key: T): String
abstract fun id(key: T): U
}
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -41,12 +41,12 @@ import kotlin.collections.distinctBy
* does NOT share EOSEs with other users. Changing the list will not make the
* app reuse the EOSE because it assumes the filter is going to be different
*/
abstract class PerUserAndFollowListEoseManager<T>(
abstract class PerUserAndFollowListEoseManager<T, U : Any>(
client: NostrClient,
allKeys: () -> Set<T>,
val invalidateAfterEose: Boolean = false,
) : BaseEoseManager<T>(client, allKeys) {
private val latestEOSEs = EOSEAccount()
private val latestEOSEs = EOSEAccountKey<U>()
private val userSubscriptionMap = mutableMapOf<User, String>()
fun since(key: T) = latestEOSEs.since(user(key), list(key))
@@ -108,5 +108,5 @@ abstract class PerUserAndFollowListEoseManager<T>(
abstract fun user(key: T): User
abstract fun list(key: T): String
abstract fun list(key: T): U
}
@@ -76,5 +76,5 @@ class ChannelMetadataAndLiveActivityWatcherSubAssembler(
}
}
override fun distinct(key: ChannelFinderQueryState) = key.channel.idHex
override fun distinct(key: ChannelFinderQueryState) = key.channel
}
@@ -40,5 +40,5 @@ class ChannelLoaderSubAssembler(
) : SingleSubNoEoseCacheEoseManager<ChannelFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun updateFilter(keys: List<ChannelFinderQueryState>): List<RelayBasedFilter>? = filterMissingChannelsById(keys)
override fun distinct(key: ChannelFinderQueryState) = key.channel.idHex
override fun distinct(key: ChannelFinderQueryState) = key.channel
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
@@ -30,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class ChannelMetadataWatcherSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChannelFinderQueryState>,
) : PerUniqueIdEoseManager<ChannelFinderQueryState>(client, allKeys) {
) : PerUniqueIdEoseManager<ChannelFinderQueryState, Channel>(client, allKeys) {
override fun updateFilter(
key: ChannelFinderQueryState,
since: SincePerRelayMap?,
@@ -46,5 +47,5 @@ class ChannelMetadataWatcherSubAssembler(
/**
* Only one key per channel.
*/
override fun id(key: ChannelFinderQueryState) = key.channel.idHex
override fun id(key: ChannelFinderQueryState) = key.channel
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
@@ -34,7 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class LiveActivityWatcherSubAssembly(
client: NostrClient,
allKeys: () -> Set<ChannelFinderQueryState>,
) : PerUniqueIdEoseManager<ChannelFinderQueryState>(client, allKeys) {
) : PerUniqueIdEoseManager<ChannelFinderQueryState, Channel>(client, allKeys) {
override fun updateFilter(
key: ChannelFinderQueryState,
since: SincePerRelayMap?,
@@ -50,5 +51,5 @@ class LiveActivityWatcherSubAssembly(
/**
* Only one key per channel.
*/
override fun id(key: ChannelFinderQueryState) = key.channel.idHex
override fun id(key: ChannelFinderQueryState) = key.channel
}
@@ -46,7 +46,7 @@ class SearchWatcherSubAssembler(
val cache: LocalCache,
client: NostrClient,
allKeys: () -> Set<SearchQueryState>,
) : PerUniqueIdEoseManager<SearchQueryState>(client, allKeys) {
) : PerUniqueIdEoseManager<SearchQueryState, Int>(client, allKeys) {
override fun updateFilter(
key: SearchQueryState,
since: SincePerRelayMap?,
@@ -95,5 +95,5 @@ class SearchWatcherSubAssembler(
return directFilters + searchFilters
}
override fun id(key: SearchQueryState) = key.searchQuery.hashCode().toString()
override fun id(key: SearchQueryState) = key.searchQuery.hashCode()
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.collection.LruCache
import com.vitorpamplona.amethyst.model.LocalCache.users
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.filters.MutableTime
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -57,11 +58,15 @@ class EOSERelayList {
class EOSEFollowList(
cacheSize: Int = 200,
) : EOSEByKey<String>(cacheSize)
open class EOSEByKey<U : Any>(
cacheSize: Int = 200,
) {
var followList: LruCache<String, EOSERelayList> = LruCache<String, EOSERelayList>(cacheSize)
var followList: LruCache<U, EOSERelayList> = LruCache<U, EOSERelayList>(cacheSize)
fun addOrUpdate(
listCode: String,
listCode: U,
relayUrl: NormalizedRelayUrl,
time: Long,
) {
@@ -75,10 +80,10 @@ class EOSEFollowList(
}
}
fun since(listCode: String) = followList[listCode]?.relayList
fun since(listCode: U) = followList[listCode]?.relayList
fun newEose(
listCode: String,
listCode: U,
relayUrl: NormalizedRelayUrl,
time: Long,
) = addOrUpdate(listCode, relayUrl, time)
@@ -86,18 +91,22 @@ class EOSEFollowList(
class EOSEAccount(
cacheSize: Int = 20,
) : EOSEAccountKey<String>(cacheSize)
open class EOSEAccountKey<U : Any>(
cacheSize: Int = 20,
) {
var users: LruCache<User, EOSEFollowList> = LruCache<User, EOSEFollowList>(cacheSize)
var users: LruCache<User, EOSEByKey<U>> = LruCache<User, EOSEByKey<U>>(cacheSize)
fun addOrUpdate(
user: User,
listCode: String,
listCode: U,
relayUrl: NormalizedRelayUrl,
time: Long,
) {
val followList = users[user]
if (followList == null) {
val newList = EOSEFollowList()
val newList = EOSEByKey<U>()
users.put(user, newList)
newList.addOrUpdate(listCode, relayUrl, time)
} else {
@@ -111,12 +120,12 @@ class EOSEAccount(
fun since(
key: User,
listCode: String,
listCode: U,
) = users[key]?.followList?.get(listCode)?.relayList
fun newEose(
user: User,
listCode: String,
listCode: U,
relayUrl: NormalizedRelayUrl,
time: Long,
) = addOrUpdate(user, listCode, relayUrl, time)
@@ -40,11 +40,12 @@ import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.prepareSharedViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
@@ -52,12 +53,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -165,30 +161,33 @@ fun uriToRoute(
is NProfile -> Route.Profile(nip19.hex)
is NNote -> Route.Note(nip19.hex)
is NEvent -> {
if (nip19.kind == PrivateDmEvent.KIND) {
nip19.author?.let { Route.RoomByAuthor(it) }
} else if (
nip19.kind == ChannelMessageEvent.KIND ||
nip19.kind == ChannelCreateEvent.KIND ||
nip19.kind == ChannelMetadataEvent.KIND
) {
Route.Channel(nip19.hex)
} else {
Route.EventRedirect(nip19.hex)
}
routeFor(
note = LocalCache.getOrCreateNote(nip19.hex),
loggedIn = account.userProfile(),
) ?: Route.EventRedirect(nip19.hex)
}
is NAddress -> {
if (nip19.kind == CommunityDefinitionEvent.KIND) {
Route.Community(nip19.kind, nip19.author, nip19.dTag)
} else if (nip19.kind == LiveActivitiesEvent.KIND) {
Route.Channel(nip19.aTag())
} else {
Route.EventRedirect(nip19.aTag())
}
routeFor(
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
loggedIn = account.userProfile(),
) ?: Route.EventRedirect(nip19.aTag())
}
is NEmbed -> Route.EventRedirect(nip19.event.id)
is NEmbed -> {
val noteEvent = nip19.event
if (noteEvent is AddressableEvent) {
routeFor(
note = LocalCache.getOrCreateAddressableNote(noteEvent.address()),
loggedIn = account.userProfile(),
) ?: Route.EventRedirect(noteEvent.addressTag())
} else {
routeFor(
note = LocalCache.getOrCreateNote(nip19.event.id),
loggedIn = account.userProfile(),
) ?: Route.EventRedirect(nip19.event.id)
}
}
else -> null
}
@@ -42,7 +42,6 @@ class NewMessageTagger(
var message: String,
var pTags: List<User>? = null,
var eTags: List<Note>? = null,
var channelHex: String? = null,
var dao: Dao,
) {
val directMentions = mutableSetOf<HexKey>()
@@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -57,30 +56,27 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip19Bech32.toNIP19
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -95,19 +91,13 @@ fun ClickableRoute(
when (val entity = nip19.entity) {
is NPub -> DisplayUser(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is NProfile -> DisplayUser(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> DisplayEvent(entity.hex, null, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is NEvent -> DisplayEvent(entity.hex, entity.kind, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is NNote -> DisplayEvent(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is NEvent -> DisplayEvent(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is NEmbed -> LoadAndDisplayEvent(entity.event, nip19.additionalChars, accountViewModel, nav)
is NAddress -> DisplayAddress(entity, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
is NRelay -> {
Text(word)
}
is NSec -> {
Text(word)
}
else -> {
Text(word)
}
is NRelay -> Text(word)
is NSec -> Text(word)
else -> Text(word)
}
}
@@ -138,7 +128,7 @@ private fun LoadAndDisplayEvent(
) {
LoadOrCreateNote(event, accountViewModel) {
if (it != null) {
DisplayNoteLink(it, event.id, event.kind, additionalChars, accountViewModel, nav)
DisplayNoteLink(it, event.id, additionalChars, accountViewModel, nav)
} else {
val externalLink = event.toNIP19()
val uri = LocalUriHandler.current
@@ -158,7 +148,6 @@ private fun LoadAndDisplayEvent(
@Composable
fun DisplayEvent(
hex: HexKey,
kind: Int?,
nip19: String,
additionalChars: String?,
accountViewModel: AccountViewModel,
@@ -166,7 +155,7 @@ fun DisplayEvent(
) {
LoadNote(hex, accountViewModel) {
if (it != null) {
DisplayNoteLink(it, hex, kind, additionalChars, accountViewModel, nav)
DisplayNoteLink(it, hex, additionalChars, accountViewModel, nav)
} else {
val externalLink = njumpLink(nip19)
val uri = LocalUriHandler.current
@@ -187,55 +176,21 @@ fun DisplayEvent(
private fun DisplayNoteLink(
it: Note,
hex: HexKey,
kind: Int?,
addedCharts: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by observeNote(it, accountViewModel)
val note = remember(noteState) { noteState?.note } ?: return
val noteIdDisplayNote = remember(noteState) { "@${noteState.note.idDisplayNote()}" }
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val route = routeFor(it, accountViewModel.userProfile()) ?: Route.EventRedirect(hex)
if (note.event is ChannelCreateEvent || kind == ChannelCreateEvent.KIND) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { Route.Channel(hex) },
route = route,
nav = nav,
)
} else if (note.event is PrivateDmEvent || kind == PrivateDmEvent.KIND) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route =
remember(noteState) { (note.author?.pubkeyHex ?: hex).let { Route.RoomByAuthor(it) } },
nav = nav,
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel ->
val channelState by observeChannel(baseChannel, accountViewModel)
val channelDisplayName by
remember(channelState) {
derivedStateOf { channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote }
}
CreateClickableText(
clickablePart = channelDisplayName,
suffix = addedCharts,
route = remember(noteState) { Route.Channel(baseChannel.idHex) },
nav = nav,
)
}
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { Route.EventRedirect(hex) },
nav = nav,
)
}
}
@Composable
@@ -258,7 +213,7 @@ private fun DisplayAddress(
val noteState by observeNote(it, accountViewModel)
val route = remember(noteState) { Route.Note(nip19.aTag()) }
val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" }
val displayName = remember(noteState) { "@${noteState.note.idDisplayNote()}" }
CreateClickableText(
clickablePart = displayName,
@@ -94,7 +94,7 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@@ -727,7 +727,7 @@ fun TagLink(
) {
LoadNote(baseNoteHex = word.hex, accountViewModel) {
if (it == null) {
Text(text = remember { word.segmentText.toShortenHex() })
Text(text = remember { word.segmentText.toShortDisplay() })
} else {
Row {
DisplayNoteFromTag(
@@ -766,7 +766,7 @@ private fun DisplayNoteFromTag(
)
} else {
ClickableTextPrimary(
text = "@${baseNote.idNote().toShortenHex()}",
text = "@${baseNote.idNote().toShortDisplay()}",
onClick = { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } },
)
}
@@ -35,7 +35,7 @@ abstract class FeedFilter<T> {
open fun limit() = 1000
/** Returns a string that serves as the key to invalidate the list if it changes. */
abstract fun feedKey(): String
abstract fun feedKey(): Any
open fun showHiddenKey(): Boolean = false
@@ -24,7 +24,7 @@ import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.AdditiveComplexFeedFilter
@@ -42,7 +42,7 @@ import kotlin.collections.distinctBy
@Stable
class ChannelFeedContentState(
val localFilter: AdditiveComplexFeedFilter<Channel, Note>,
val localFilter: AdditiveComplexFeedFilter<EphemeralChatChannel, Note>,
val viewModelScope: CoroutineScope,
) : InvalidatableContent {
private val _feedContent = MutableStateFlow<ChannelFeedState>(ChannelFeedState.Loading)
@@ -53,7 +53,7 @@ class ChannelFeedContentState(
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
private var lastFeedKey: String? = null
private var lastFeedKey: Any? = null
override val isRefreshing: MutableState<Boolean> = mutableStateOf(false)
@@ -78,7 +78,7 @@ class ChannelFeedContentState(
isRefreshing.value = true
try {
lastFeedKey = localFilter.feedKey()
val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList()
val notes = localFilter.loadTop().distinctBy { it }.toImmutableList()
val oldNotesState = _feedContent.value
if (oldNotesState is ChannelFeedState.Loaded) {
@@ -93,15 +93,15 @@ class ChannelFeedContentState(
}
}
private fun updateFeed(notes: ImmutableList<Channel>) {
private fun updateFeed(notes: ImmutableList<EphemeralChatChannel>) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.tryEmit(ChannelFeedState.Empty)
} else if (currentState is ChannelFeedState.Loaded) {
currentState.feed.tryEmit(LoadedFeedState<Channel>(notes, localFilter.showHiddenKey()))
currentState.feed.tryEmit(LoadedFeedState<EphemeralChatChannel>(notes, localFilter.showHiddenKey()))
} else {
_feedContent.tryEmit(
ChannelFeedState.Loaded(MutableStateFlow(LoadedFeedState<Channel>(notes, localFilter.showHiddenKey()))),
ChannelFeedState.Loaded(MutableStateFlow(LoadedFeedState<EphemeralChatChannel>(notes, localFilter.showHiddenKey()))),
)
}
}
@@ -123,7 +123,7 @@ class ChannelFeedContentState(
val newList =
localFilter
.updateListWith(emptyList(), newItems)
.distinctBy { it.idHex }
.distinctBy { it }
.toImmutableList()
if (newList.isNotEmpty()) {
updateFeed(newList)
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.feeds
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import kotlinx.coroutines.flow.MutableStateFlow
@Stable
@@ -29,7 +29,7 @@ sealed class ChannelFeedState {
object Loading : ChannelFeedState()
class Loaded(
val feed: MutableStateFlow<LoadedFeedState<Channel>>,
val feed: MutableStateFlow<LoadedFeedState<EphemeralChatChannel>>,
) : ChannelFeedState()
object Empty : ChannelFeedState()
@@ -53,7 +53,7 @@ class FeedContentState(
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
private var lastFeedKey: String? = null
private var lastFeedKey: Any? = null
override val isRefreshing: MutableState<Boolean> = mutableStateOf(false)
@@ -64,7 +64,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
@@ -242,7 +242,7 @@ private fun AccountName(
}
Text(
text = remember(user) { acc.npub.toShortenHex() },
text = remember(user) { acc.npub.toShortDisplay() },
)
}
@@ -59,10 +59,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScree
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.EphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
@@ -138,9 +139,12 @@ fun AppNavigation(
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.Room> { ChatroomScreen(it.id.toString(), it.message, it.replyId, it.draftId, accountViewModel, nav) }
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEndArgs<Route.Channel> { ChannelScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.PublicChatChannel> { PublicChatChannelScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.LiveActivityChannel> { LiveActivityChannelScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.EphemeralChat> {
RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.let { relay ->
EphemeralChatScreen(RoomId(it.id, relay), accountViewModel, nav)
@@ -388,7 +392,7 @@ private fun isSameRoute(
if (newRoute is Route.EventRedirect) {
return when (currentRoute) {
is Route.Note -> newRoute.id == currentRoute.id
is Route.Channel -> newRoute.id == currentRoute.id
is Route.PublicChatChannel -> newRoute.id == currentRoute.id
else -> false
}
}
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.navigation
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -39,6 +41,8 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip73ExternalIds.location.isGeohashedScoped
import com.vitorpamplona.quartz.nip73ExternalIds.topics.isHashtagScoped
@@ -48,7 +52,7 @@ fun routeFor(
note: Note,
loggedIn: User,
): Route? {
val noteEvent = note.event ?: return Route.Note(note.idHex)
val noteEvent = note.event ?: return Route.EventRedirect(note.idHex)
return routeFor(noteEvent, loggedIn)
}
@@ -62,15 +66,15 @@ fun routeFor(
if (innerEvent is IsInPublicChatChannel) {
innerEvent.channelId()?.let {
return Route.Channel(it)
return Route.PublicChatChannel(it)
}
} else if (innerEvent is LiveActivitiesEvent) {
innerEvent.aTag().toTag().let {
return Route.Channel(it)
innerEvent.address().let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (innerEvent is LiveActivitiesChatMessageEvent) {
innerEvent.activity()?.toTag()?.let {
return Route.Channel(it)
innerEvent.activityAddress()?.let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (innerEvent is ChatroomKeyable) {
val room = innerEvent.chatroomKey(loggedIn.pubkeyHex)
@@ -85,17 +89,17 @@ fun routeFor(
return Route.ContentDiscovery(noteEvent.id)
} else if (noteEvent is IsInPublicChatChannel) {
noteEvent.channelId()?.let {
return Route.Channel(it)
return Route.PublicChatChannel(it)
}
} else if (noteEvent is ChannelCreateEvent) {
return Route.Channel(noteEvent.id)
return Route.PublicChatChannel(noteEvent.id)
} else if (noteEvent is LiveActivitiesEvent) {
noteEvent.aTag().toTag().let {
return Route.Channel(it)
noteEvent.address().let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (noteEvent is LiveActivitiesChatMessageEvent) {
noteEvent.activity()?.toTag()?.let {
return Route.Channel(it)
noteEvent.activityAddress()?.let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (noteEvent is ChatroomKeyable) {
val room = noteEvent.chatroomKey(loggedIn.pubkeyHex)
@@ -103,6 +107,14 @@ fun routeFor(
return Route.Room(room.hashCode())
} else if (noteEvent is CommunityDefinitionEvent) {
return Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
} else if (noteEvent is GiftWrapEvent) {
noteEvent.innerEventId?.let {
return routeFor(LocalCache.getOrCreateNote(it), loggedIn)
}
} else if (noteEvent is SealedRumorEvent) {
noteEvent.innerEventId?.let {
return routeFor(LocalCache.getOrCreateNote(it), loggedIn)
}
} else if (noteEvent is AddressableEvent) {
return Route.Note(noteEvent.aTag().toTag())
} else {
@@ -169,12 +181,11 @@ fun routeToMessage(
accountViewModel: AccountViewModel,
): Route = routeToMessage(user.pubkeyHex, draftMessage, replyId, draftId, accountViewModel)
fun routeFor(note: Channel): Route =
if (note is EphemeralChatChannel) {
Route.EphemeralChat(note.roomId.id, note.roomId.relayUrl.url)
} else {
Route.Channel(note.idHex)
}
fun routeFor(note: EphemeralChatChannel): Route = Route.EphemeralChat(note.roomId.id, note.roomId.relayUrl.url)
fun routeFor(note: PublicChatChannel): Route = Route.PublicChatChannel(note.idHex)
fun routeFor(note: LiveActivitiesChannel): Route = Route.LiveActivityChannel(note.address.kind, note.address.pubKeyHex, note.address.dTag)
fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.relayUrl.url)
@@ -85,7 +85,17 @@ sealed class Route {
val dTag: String,
) : Route()
@Serializable data class Channel(
@Serializable data class PublicChatChannel(
val id: String,
) : Route()
@Serializable data class LiveActivityChannel(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route()
@Serializable data class EphemeralChatChannel(
val id: String,
) : Route()
@@ -204,7 +214,8 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
dest.hasRoute<Route.RelayInfo>() -> entry.toRoute<Route.RelayInfo>()
dest.hasRoute<Route.RoomByAuthor>() -> entry.toRoute<Route.RoomByAuthor>()
dest.hasRoute<Route.Channel>() -> entry.toRoute<Route.Channel>()
dest.hasRoute<Route.PublicChatChannel>() -> entry.toRoute<Route.PublicChatChannel>()
dest.hasRoute<Route.LiveActivityChannel>() -> entry.toRoute<Route.LiveActivityChannel>()
dest.hasRoute<Route.ChannelMetadataEdit>() -> entry.toRoute<Route.ChannelMetadataEdit>()
dest.hasRoute<Route.EphemeralChat>() -> entry.toRoute<Route.EphemeralChat>()
dest.hasRoute<Route.NewEphemeralChat>() -> entry.toRoute<Route.NewEphemeralChat>()
@@ -31,19 +31,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteOts
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
@@ -161,36 +159,28 @@ fun LoadOts(
}
@Composable
fun LoadChannel(
baseChannelHex: String,
fun LoadPublicChatChannel(
id: String,
accountViewModel: AccountViewModel,
content: @Composable (Channel) -> Unit,
content: @Composable (PublicChatChannel) -> Unit,
) {
var channel by
remember(baseChannelHex) {
mutableStateOf<Channel?>(accountViewModel.getChannelIfExists(baseChannelHex))
val channel =
produceStateIfNotNull(accountViewModel.getPublicChatChannelIfExists(id), id) {
value = accountViewModel.checkGetOrCreatePublicChatChannel(id)
}
if (channel == null) {
LaunchedEffect(key1 = baseChannelHex) {
accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel ->
launch(Dispatchers.Main) { channel = newChannel }
}
}
}
channel?.let { content(it) }
channel.value?.let { content(it) }
}
@Composable
fun LoadChannel(
id: RoomId,
fun LoadLiveActivityChannel(
id: Address,
accountViewModel: AccountViewModel,
content: @Composable (EphemeralChatChannel) -> Unit,
content: @Composable (LiveActivitiesChannel) -> Unit,
) {
val channel =
produceStateIfNotNull(accountViewModel.getChannelIfExists(id) as? EphemeralChatChannel, id) {
value = accountViewModel.checkGetOrCreateChannel(id) as? EphemeralChatChannel
produceStateIfNotNull(accountViewModel.getLiveActivityChannelIfExists(id), id) {
value = accountViewModel.checkGetOrCreateLiveActivityChannel(id)
}
channel.value?.let { content(it) }
@@ -52,9 +52,9 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.produceCachedStateAsync
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
@@ -125,7 +125,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment
import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.RenderChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP
@@ -276,9 +276,8 @@ fun AcceptableNote(
is ChannelCreateEvent,
is ChannelMetadataEvent,
->
RenderChannelHeader(
RenderPublicChatChannelHeader(
channelNote = baseNote,
showVideo = !makeItShort,
sendToChannel = true,
accountViewModel = accountViewModel,
nav = nav,
@@ -316,9 +315,8 @@ fun AcceptableNote(
is ChannelCreateEvent,
is ChannelMetadataEvent,
->
RenderChannelHeader(
RenderPublicChatChannelHeader(
channelNote = baseNote,
showVideo = !makeItShort,
sendToChannel = true,
accountViewModel = accountViewModel,
nav = nav,
@@ -1186,7 +1184,7 @@ private fun RenderAuthorImages(
if (baseNote.event is ChannelMessageEvent) {
val baseChannelHex = remember(baseNote) { baseNote.channelHex() }
if (baseChannelHex != null) {
LoadChannel(baseChannelHex, accountViewModel) { channel ->
LoadPublicChatChannel(baseChannelHex, accountViewModel) { channel ->
ChannelNotePicture(
channel,
accountViewModel,
@@ -1198,7 +1196,7 @@ private fun RenderAuthorImages(
@Composable
private fun ChannelNotePicture(
baseChannel: Channel,
baseChannel: PublicChatChannel,
accountViewModel: AccountViewModel,
) {
val model by observeChannelPicture(baseChannel, accountViewModel)
@@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.ui.note
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
fun ByteArray.toShortenHex(): String = toHexKey().toShortenHex()
fun ByteArray.toHexShortDisplay(): String = toHexKey().toShortDisplay()
fun String.toShortenHex(): String {
fun String.toShortDisplay(): String {
if (length <= 16) return this
return replaceRange(8, length - 8, ":")
}
fun HexKey.toDisplayHexKey(): String = this.toShortenHex()
fun HexKey.toDisplayHexKey(): String = this.toShortDisplay()
@@ -656,7 +656,7 @@ open class CommentPostViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.Default) {
val tagger =
NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!)
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
@@ -60,9 +60,8 @@ fun RenderChannelMessage(
}
showChannelInfo?.let {
ChannelHeader(
PublicChatChannelHeader(
channelHex = it,
showVideo = false,
sendToChannel = true,
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
accountViewModel = accountViewModel,
@@ -139,7 +139,7 @@ fun RenderLiveActivityEventInner(
val subject = remember(eventUpdates) { noteEvent.title() }
val content = remember(eventUpdates) { noteEvent.summary() }
val participants = remember(eventUpdates) { noteEvent.participants() }
val status = remember(eventUpdates) { noteEvent.status() }
val status = remember(eventUpdates) { noteEvent.statusEnum() }
val starts = remember(eventUpdates) { noteEvent.starts() }
Row(
@@ -163,19 +163,22 @@ fun RenderLiveActivityEventInner(
CrossfadeIfEnabled(targetState = status, label = "RenderLiveActivityEventInner", accountViewModel = accountViewModel) {
when (it) {
StatusTag.STATUS.LIVE.code -> {
StatusTag.STATUS.LIVE -> {
media?.let { CrossfadeCheckIfVideoIsOnline(it, accountViewModel) { LiveFlag() } }
}
StatusTag.STATUS.PLANNED.code -> {
StatusTag.STATUS.PLANNED -> {
ScheduledFlag(starts)
}
StatusTag.STATUS.ENDED -> {}
null -> {}
}
}
}
media?.let { media ->
if (status == StatusTag.STATUS.LIVE.code) {
if (status == StatusTag.STATUS.LIVE) {
CheckIfVideoIsOnline(media, accountViewModel) { isOnline ->
if (isOnline) {
Row(
@@ -210,7 +213,7 @@ fun RenderLiveActivityEventInner(
}
}
} else {
if (status == StatusTag.STATUS.ENDED.code || (status == StatusTag.STATUS.PLANNED.code && (starts ?: 0) < TimeUtils.eightHoursAgo())) {
if (status == StatusTag.STATUS.ENDED || (status == StatusTag.STATUS.PLANNED && (starts ?: 0) < TimeUtils.eightHoursAgo())) {
Box(
contentAlignment = Alignment.Center,
modifier =
@@ -32,8 +32,9 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
@@ -53,21 +54,24 @@ fun RenderLiveActivityChatMessage(
val showChannelInfo =
remember(noteEvent) {
if (noteEvent is LiveActivitiesChatMessageEvent) {
noteEvent.activity()?.toTag()
noteEvent.activityAddress()
} else {
null
}
}
showChannelInfo?.let {
ChannelHeader(
channelHex = it,
LoadLiveActivityChannel(it, accountViewModel) {
LiveActivitiesChannelHeader(
baseChannel = it,
showVideo = false,
showFlag = true,
sendToChannel = true,
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
accountViewModel = accountViewModel,
nav = nav,
)
}
Spacer(modifier = StdVertSpacer)
}
@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
@@ -1116,20 +1117,24 @@ class AccountViewModel(
)
}
private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? = LocalCache.checkGetOrCreateChannel(key)
suspend fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel? = LocalCache.getOrCreatePublicChatChannel(key)
suspend fun checkGetOrCreateChannel(key: RoomId): Channel? = LocalCache.getOrCreateEphemeralChannel(key)
suspend fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel? = LocalCache.getOrCreateLiveChannel(key)
suspend fun checkGetOrCreateEphemeralChatChannel(key: RoomId): EphemeralChatChannel? = LocalCache.getOrCreateEphemeralChannel(key)
fun checkGetOrCreateChannel(
key: HexKey,
onResult: (Channel?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateChannel(key)) }
viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreatePublicChatChannel(key)) }
}
fun getChannelIfExists(hex: HexKey): Channel? = LocalCache.getChannelIfExists(hex)
fun getPublicChatChannelIfExists(hex: HexKey) = LocalCache.getPublicChatChannelIfExists(hex)
fun getChannelIfExists(key: RoomId): Channel? = LocalCache.getChannelIfExists(key.toKey())
fun getEphemeralChatChannelIfExists(key: RoomId) = LocalCache.getEphemeralChatChannelIfExists(key)
fun getLiveActivityChannelIfExists(key: Address) = LocalCache.getLiveActivityChannelIfExists(key)
fun <T : PubKeyReferenceTag> loadParticipants(
participants: List<T>,
@@ -1226,7 +1231,7 @@ class AccountViewModel(
route?.let {
if (route is Route.Room) {
account.markAsRead("Room/${route.id}", date)
} else if (route is Route.Channel) {
} else if (route is Route.PublicChatChannel) {
account.markAsRead("Channel/${route.id}", date)
}
}
@@ -42,7 +42,6 @@ import androidx.compose.ui.Alignment.Companion.CenterStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.INav
@@ -60,7 +59,6 @@ import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation
import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote
@@ -99,9 +97,6 @@ fun ChatroomMessageCompose(
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
) {
logTime(
debugMessage = { "ChatroomMessageCompose " + externalLinkForNote(baseNote) },
) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel) {
WatchBlockAndReport(
note = baseNote,
@@ -123,7 +118,6 @@ fun ChatroomMessageCompose(
)
}
}
}
}
@Composable
@@ -176,7 +170,7 @@ fun NormalChatNote(
parentBackgroundColor = parentBackgroundColor,
onClick = {
if (note.event is ChannelCreateEvent) {
nav.nav(Route.Channel(note.idHex))
nav.nav(Route.PublicChatChannel(note.idHex))
true
} else {
false
@@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
class ChatroomFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChatroomQueryState>,
) : PerUserAndFollowListEoseManager<ChatroomQueryState>(client, allKeys) {
) : PerUserAndFollowListEoseManager<ChatroomQueryState, String>(client, allKeys) {
override fun updateFilter(
key: ChatroomQueryState,
since: SincePerRelayMap?,
@@ -578,7 +578,7 @@ class ChatNewMessageViewModel :
fun updateRoomFromUsersInput() {
viewModelScope.launch(Dispatchers.Default) {
delay(300)
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!)
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, accountViewModel!!)
toUsersTagger.run()
val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex }
@@ -701,7 +701,7 @@ class ChatNewMessageViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.Default) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!)
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -30,7 +30,7 @@ class ChannelFeedFilter(
val channel: Channel,
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = channel.idHex
override fun feedKey() = channel
// returns the last Note of each user.
override fun feed(): List<Note> =
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
@@ -32,7 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class ChannelFromUserFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChannelQueryState>,
) : PerUserAndFollowListEoseManager<ChannelQueryState>(client, allKeys) {
) : PerUserAndFollowListEoseManager<ChannelQueryState, Channel>(client, allKeys) {
override fun updateFilter(
key: ChannelQueryState,
since: SincePerRelayMap?,
@@ -48,5 +49,5 @@ class ChannelFromUserFilterSubAssembler(
override fun user(key: ChannelQueryState) = key.account.userProfile()
override fun list(key: ChannelQueryState) = key.channel.idHex
override fun list(key: ChannelQueryState) = key.channel
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
@@ -32,7 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class ChannelPublicFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<ChannelQueryState>,
) : PerUniqueIdEoseManager<ChannelQueryState>(client, allKeys) {
) : PerUniqueIdEoseManager<ChannelQueryState, Channel>(client, allKeys) {
override fun updateFilter(
key: ChannelQueryState,
since: SincePerRelayMap?,
@@ -44,5 +45,5 @@ class ChannelPublicFilterSubAssembler(
else -> null
}
override fun id(key: ChannelQueryState) = key.channel.idHex
override fun id(key: ChannelQueryState) = key.channel
}
@@ -36,7 +36,7 @@ fun filterMessagesToLiveActivities(
filter =
Filter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel.idHex)),
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
limit = 200,
since = since?.get(it)?.time,
),
@@ -38,7 +38,7 @@ fun filterMyMessagesToLiveActivities(
filter =
Filter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel.idHex)),
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
authors = listOf(pubKey),
limit = 50,
since = since?.get(it)?.time,
@@ -0,0 +1,140 @@
/**
* 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.ui.screen.loggedIn.chats.publicChannels.ephemChat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import kotlinx.coroutines.launch
@Composable
fun EphemeralChatChannelView(
channelId: RoomId?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
LoadEphemeralChatChannel(channelId, accountViewModel) { ephem ->
PrepareChannelViewModels(
baseChannel = ephem,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun PrepareChannelViewModels(
baseChannel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val feedViewModel: ChannelFeedViewModel =
viewModel(
key = baseChannel.roomId.toKey() + "ChannelFeedViewModel",
factory =
ChannelFeedViewModel.Factory(
baseChannel,
accountViewModel.account,
),
)
val channelScreenModel: ChannelNewMessageViewModel = viewModel()
channelScreenModel.init(accountViewModel)
channelScreenModel.load(baseChannel)
ChannelView(
channel = baseChannel,
feedViewModel = feedViewModel,
newPostModel = channelScreenModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
private fun ChannelView(
channel: EphemeralChatChannel,
feedViewModel: ChannelFeedViewModel,
newPostModel: ChannelNewMessageViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedViewModel)
ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel)
Column(Modifier.fillMaxHeight()) {
Column(
modifier =
remember {
Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true)
},
) {
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Channel/${channel.roomId.toKey()}",
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
)
}
Spacer(modifier = DoubleVertSpacer)
val scope = rememberCoroutineScope()
// LAST ROW
EditFieldRow(
newPostModel,
accountViewModel,
onSendNewMessage = {
scope.launch {
feedViewModel.sendToTop()
}
},
nav,
)
}
}
@@ -18,51 +18,18 @@
* 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.ui.screen.loggedIn.chats.publicChannels
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@Composable
fun ChannelScreen(
channelId: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
LoadChannel(channelId, accountViewModel) {
when (it) {
is EphemeralChatChannel -> EphemeralChatTopBar(it, accountViewModel, nav)
is PublicChatChannel -> PublicChatTopBar(it, accountViewModel, nav)
is LiveActivitiesChannel -> LiveActivityTopBar(it, accountViewModel, nav)
}
}
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it)) {
ChannelView(channelId, accountViewModel, nav)
}
}
}
@Composable
fun EphemeralChatScreen(
channelId: RoomId,
@@ -72,14 +39,14 @@ fun EphemeralChatScreen(
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
LoadChannel(channelId, accountViewModel) {
LoadEphemeralChatChannel(channelId, accountViewModel) {
EphemeralChatTopBar(it, accountViewModel, nav)
}
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it)) {
ChannelView(channelId, accountViewModel, nav)
EphemeralChatChannelView(channelId, accountViewModel, nav)
}
}
}
@@ -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.ui.screen.loggedIn.chats.publicChannels.ephemChat
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.note.produceStateIfNotNull
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@Composable
fun LoadEphemeralChatChannel(
id: RoomId,
accountViewModel: AccountViewModel,
content: @Composable (EphemeralChatChannel) -> Unit,
) {
val channel =
produceStateIfNotNull(accountViewModel.getEphemeralChatChannelIfExists(id), id) {
value = accountViewModel.checkGetOrCreateEphemeralChatChannel(id)
}
channel.value?.let { content(it) }
}
@@ -123,7 +123,7 @@ private fun DrawRelayIcon(
val relayInfo by loadRelayInfo(channel.roomId.relayUrl, accountViewModel)
RobohashFallbackAsyncImage(
robot = channel.idHex,
robot = channel.roomId.toKey(),
model = relayInfo?.icon,
contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop,
@@ -18,7 +18,7 @@
* 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.ui.screen.loggedIn.chats.publicChannels
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -30,48 +30,28 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShowVideoStreaming
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import kotlinx.coroutines.launch
@Composable
fun ChannelView(
fun PublicChatChannelView(
channelId: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
LoadChannel(channelId, accountViewModel) {
PrepareChannelViewModels(
baseChannel = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
fun ChannelView(
channelId: RoomId?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
LoadChannel(channelId, accountViewModel) {
LoadPublicChatChannel(channelId, accountViewModel) {
PrepareChannelViewModels(
baseChannel = it,
accountViewModel = accountViewModel,
@@ -82,7 +62,7 @@ fun ChannelView(
@Composable
fun PrepareChannelViewModels(
baseChannel: Channel,
baseChannel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -111,7 +91,7 @@ fun PrepareChannelViewModels(
@Composable
fun ChannelView(
channel: Channel,
channel: PublicChatChannel,
feedViewModel: ChannelFeedViewModel,
newPostModel: ChannelNewMessageViewModel,
accountViewModel: AccountViewModel,
@@ -130,9 +110,6 @@ fun ChannelView(
.weight(1f, true)
},
) {
if (channel is LiveActivitiesChannel) {
ShowVideoStreaming(channel, accountViewModel)
}
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
@@ -18,38 +18,31 @@
* 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.ui.screen.loggedIn.chats.publicChannels
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
@Composable
fun RenderChannelHeader(
fun RenderPublicChatChannelHeader(
channelNote: Note,
showVideo: Boolean,
sendToChannel: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
channelNote.channelHex()?.let {
ChannelHeader(
PublicChatChannelHeader(
channelHex = it,
showVideo = showVideo,
sendToChannel = sendToChannel,
modifier = MaterialTheme.colorScheme.innerPostModifier.padding(Size10dp),
accountViewModel = accountViewModel,
@@ -59,28 +52,14 @@ fun RenderChannelHeader(
}
@Composable
fun ChannelHeader(
fun PublicChatChannelHeader(
channelHex: String,
showVideo: Boolean,
showFlag: Boolean = true,
sendToChannel: Boolean = false,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadChannel(channelHex, accountViewModel) {
when (it) {
is LiveActivitiesChannel ->
LiveActivitiesChannelHeader(
it,
showVideo,
showFlag,
sendToChannel,
modifier,
accountViewModel,
nav,
)
is PublicChatChannel ->
LoadPublicChatChannel(channelHex, accountViewModel) {
PublicChatChannelHeader(
it,
sendToChannel,
@@ -88,15 +67,5 @@ fun ChannelHeader(
accountViewModel,
nav,
)
is EphemeralChatChannel ->
EphemeralChatChannelHeader(
it,
sendToChannel,
modifier,
accountViewModel,
nav,
)
}
}
}
@@ -0,0 +1,55 @@
/**
* 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.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Composable
fun PublicChatChannelScreen(
channelId: HexKey?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
LoadPublicChatChannel(channelId, accountViewModel) {
PublicChatTopBar(it, accountViewModel, nav)
}
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it)) {
PublicChatChannelView(channelId, accountViewModel, nav)
}
}
}
@@ -58,7 +58,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -86,7 +86,7 @@ fun ChannelMetadataScreen(
if (channelId == null) {
ChannelMetadataScreen(null as PublicChatChannel?, accountViewModel, nav)
} else {
LoadChannel(channelId, accountViewModel) {
LoadPublicChatChannel(channelId, accountViewModel) {
if (it is PublicChatChannel) {
ChannelMetadataScreen(it, accountViewModel, nav)
}
@@ -0,0 +1,142 @@
/**
* 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.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import kotlinx.coroutines.launch
@Composable
fun LiveActivityChannelView(
channelId: Address?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
LoadLiveActivityChannel(channelId, accountViewModel) {
PrepareChannelViewModels(
baseChannel = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
fun PrepareChannelViewModels(
baseChannel: LiveActivitiesChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val feedViewModel: ChannelFeedViewModel =
viewModel(
key = baseChannel.address.toValue() + "ChannelFeedViewModel",
factory =
ChannelFeedViewModel.Factory(
baseChannel,
accountViewModel.account,
),
)
val channelScreenModel: ChannelNewMessageViewModel = viewModel()
channelScreenModel.init(accountViewModel)
channelScreenModel.load(baseChannel)
LiveActivityChannelView(
channel = baseChannel,
feedViewModel = feedViewModel,
newPostModel = channelScreenModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun LiveActivityChannelView(
channel: LiveActivitiesChannel,
feedViewModel: ChannelFeedViewModel,
newPostModel: ChannelNewMessageViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedViewModel)
ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel)
Column(Modifier.fillMaxHeight()) {
Column(
modifier =
remember {
Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true)
},
) {
ShowVideoStreaming(channel, accountViewModel)
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Channel/${channel.address.toValue()}",
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
)
}
Spacer(modifier = DoubleVertSpacer)
val scope = rememberCoroutineScope()
// LAST ROW
EditFieldRow(
newPostModel,
accountViewModel,
onSendNewMessage = {
scope.launch {
feedViewModel.sendToTop()
}
},
nav,
)
}
}
@@ -64,9 +64,9 @@ fun LiveActivitiesChannelHeader(
) {
ShortLiveActivityChannelHeader(
baseChannel = baseChannel,
showFlag = showFlag,
accountViewModel = accountViewModel,
nav = nav,
showFlag = showFlag,
)
if (expanded.value) {
@@ -0,0 +1,55 @@
/**
* 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.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
@Composable
fun LiveActivityChannelScreen(
channelId: Address?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
LoadLiveActivityChannel(channelId, accountViewModel) {
LiveActivityTopBar(it, accountViewModel, nav)
}
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it)) {
LiveActivityChannelView(channelId, accountViewModel, nav)
}
}
}
@@ -1,77 +0,0 @@
/**
* 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.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
@Composable
fun LiveChannelActionOptions(
channel: LiveActivitiesChannel,
showFlag: Boolean = true,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isLive by remember(channel) { derivedStateOf { channel.info?.status() == StatusTag.STATUS.LIVE.code } }
val note = remember(channel.idHex) { LocalCache.getNoteIfExists(channel.idHex) }
note?.let {
if (showFlag && isLive) {
LiveFlag()
Spacer(modifier = StdHorzSpacer)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = RowColSpacing,
) {
LikeReaction(
baseNote = it,
grayTint = MaterialTheme.colorScheme.onSurface,
accountViewModel = accountViewModel,
nav,
)
}
Spacer(modifier = StdHorzSpacer)
ZapReaction(
baseNote = it,
grayTint = MaterialTheme.colorScheme.onSurface,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@@ -43,11 +44,11 @@ import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -60,8 +61,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -79,44 +80,14 @@ fun LongLiveActivityChannelHeader(
) {
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? LiveActivitiesChannel ?: return
val activity = channel.info ?: return
val callbackUri = remember(channel) { channel.toNostrUri() }
Row(
lineModifier,
) {
val summary = remember(channelState) { channel.summary()?.ifBlank { null } }
Column(
Modifier.weight(1f),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val defaultBackground = MaterialTheme.colorScheme.background
val background = remember { mutableStateOf(defaultBackground) }
val tags = remember(channelState) { baseChannel.info?.tags?.toImmutableListOfLists() ?: EmptyTagList }
TranslatableRichTextViewer(
content = summary ?: stringRes(id = R.string.groups_no_descriptor),
canPreview = false,
quotesLeft = 1,
tags = tags,
backgroundColor = background,
id = baseChannel.idHex,
accountViewModel = accountViewModel,
nav = nav,
)
Row(lineModifier) {
RenderSummary(activity, callbackUri, accountViewModel, nav)
}
if (summary != null) {
baseChannel.info?.let {
if (it.hasHashtags()) {
DisplayUncitedHashtags(it, summary, accountViewModel, nav)
}
}
}
}
}
LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote ->
LoadAddressableNote(channel.address, accountViewModel) { loadingNote ->
loadingNote?.let { note ->
Row(
lineModifier,
@@ -197,3 +168,36 @@ fun LongLiveActivityChannelHeader(
}
}
}
@Composable
private fun RowScope.RenderSummary(
activity: LiveActivitiesEvent,
callbackUri: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val summary = activity.summary() ?: stringRes(id = R.string.groups_no_descriptor)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val defaultBackground = MaterialTheme.colorScheme.background
val background = remember { mutableStateOf(defaultBackground) }
TranslatableRichTextViewer(
content = summary,
canPreview = false,
quotesLeft = 1,
tags = activity.tags.toImmutableListOfLists(),
backgroundColor = background,
id = activity.id,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (activity.hasHashtags()) {
DisplayUncitedHashtags(activity, summary, callbackUri, accountViewModel, nav)
}
}
}
@@ -23,10 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -34,25 +37,51 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
@Composable
fun ShortLiveActivityChannelHeader(
baseChannel: LiveActivitiesChannel,
showFlag: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
showFlag: Boolean,
) {
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? LiveActivitiesChannel ?: return
ShortLiveActivityChannelHeader(
name = channel.toBestDisplayName(),
creator = channel.creator,
liveActivitiesEvent = channel.info,
showFlag = showFlag,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun ShortLiveActivityChannelHeader(
name: String,
creator: User?,
liveActivitiesEvent: LiveActivitiesEvent?,
showFlag: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
channel.creator?.let {
creator?.let {
UserPicture(
user = it,
size = Size34dp,
@@ -71,13 +100,14 @@ fun ShortLiveActivityChannelHeader(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = remember(channelState) { channel.toBestDisplayName() },
text = name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
liveActivitiesEvent?.let {
Row(
modifier =
Modifier
@@ -85,7 +115,45 @@ fun ShortLiveActivityChannelHeader(
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LiveChannelActionOptions(channel, showFlag, accountViewModel, nav)
LiveChannelActionOptions(it, showFlag, accountViewModel, nav)
}
}
}
}
@Composable
fun LiveChannelActionOptions(
activity: LiveActivitiesEvent,
showFlag: Boolean = true,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isLive by remember(activity) { derivedStateOf { activity.isLive() } }
if (showFlag && isLive) {
LiveFlag()
Spacer(modifier = StdHorzSpacer)
}
val note = remember(activity) { LocalCache.getAddressableNoteIfExists(activity.address()) }
note?.let {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = RowColSpacing,
) {
LikeReaction(
baseNote = it,
grayTint = MaterialTheme.colorScheme.onSurface,
accountViewModel = accountViewModel,
nav,
)
}
Spacer(modifier = StdHorzSpacer)
ZapReaction(
baseNote = it,
grayTint = MaterialTheme.colorScheme.onSurface,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -38,9 +38,9 @@ fun LiveActivityTopBar(
title = {
ShortLiveActivityChannelHeader(
baseChannel = baseChannel,
showFlag = true,
accountViewModel = accountViewModel,
nav = nav,
showFlag = true,
)
},
extendableRow = {
@@ -364,7 +364,6 @@ open class ChannelNewMessageViewModel :
message = message.text,
pTags = listOfNotNull(replyTo.value?.author),
eTags = listOfNotNull(replyTo.value),
channelHex = channel.idHex,
dao = accountViewModel,
)
tagger.run()
@@ -627,7 +626,7 @@ open class ChannelNewMessageViewModel :
fun updateZapFromText() {
viewModelScope.launch(Dispatchers.Default) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!)
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.items.any { it.key == taggedUser }) {
@@ -32,7 +32,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,8 +52,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
@@ -62,31 +59,29 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
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.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
@Composable
@@ -95,9 +90,6 @@ fun ChatroomHeaderCompose(
accountViewModel: AccountViewModel,
nav: INav,
) {
logTime(
debugMessage = { "ChatroomHeaderCompose " + externalLinkForNote(baseNote) },
) {
if (baseNote.event != null) {
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
} else {
@@ -108,7 +100,6 @@ fun ChatroomHeaderCompose(
BlankNote()
}
}
}
}
@Composable
@@ -117,80 +108,62 @@ fun ChatroomComposeChannelOrUser(
accountViewModel: AccountViewModel,
nav: INav,
) {
if (baseNote.event is DraftEvent) {
ObserveDraftEvent(baseNote, accountViewModel) {
val channelHex by remember(it) { derivedStateOf { it.channelHex() } }
if (channelHex != null) {
ChatroomChannel(channelHex!!, it, accountViewModel, nav)
} else {
ChatroomPrivateMessages(it, accountViewModel, nav)
}
val baseNoteEvent = baseNote.event
if (baseNoteEvent is DraftEvent) {
ObserveDraftEvent(baseNote, accountViewModel) { innerNote ->
ChatroomEntry(innerNote, accountViewModel, nav)
}
} else {
val channelHex by remember(baseNote) { derivedStateOf { baseNote.channelHex() } }
if (channelHex != null) {
ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav)
} else {
ChatroomPrivateMessages(baseNote, accountViewModel, nav)
}
ChatroomEntry(baseNote, accountViewModel, nav)
}
}
@Composable
private fun ChatroomPrivateMessages(
baseNote: Note,
private fun ChatroomEntry(
lastMessage: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val userRoom by
remember(baseNote) {
derivedStateOf {
(baseNote.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex)
val baseNoteEvent = lastMessage.event
when (baseNoteEvent) {
is ChannelMessageEvent ->
baseNoteEvent.channelId()?.let {
LoadPublicChatChannel(it, accountViewModel) { channel ->
ChannelRoomCompose(lastMessage, channel, accountViewModel, nav)
}
}
CrossfadeIfEnabled(targetState = userRoom, label = "ChatroomPrivateMessages", accountViewModel = accountViewModel) { room ->
if (room != null) {
UserRoomCompose(baseNote, room, accountViewModel, nav)
} else {
BlankNote()
is ChannelMetadataEvent ->
baseNoteEvent.channelId()?.let {
LoadPublicChatChannel(it, accountViewModel) { channel ->
ChannelRoomCompose(lastMessage, channel, accountViewModel, nav)
}
}
}
@Composable
private fun ChatroomChannel(
channelHex: HexKey,
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadChannel(baseChannelHex = channelHex, accountViewModel) { channel ->
when (channel) {
is PublicChatChannel -> ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
is EphemeralChatChannel -> ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
is ChannelCreateEvent ->
LoadPublicChatChannel(baseNoteEvent.id, accountViewModel) { channel ->
ChannelRoomCompose(lastMessage, channel, accountViewModel, nav)
}
is ChatroomKeyable -> {
val room = baseNoteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex)
UserRoomCompose(room, lastMessage, accountViewModel, nav)
}
else -> BlankNote()
}
}
@Composable
private fun ChannelRoomCompose(
note: Note,
lastMessage: Note,
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val authorName by observeUserName(note.author!!, accountViewModel)
val authorName by observeUserName(lastMessage.author!!, accountViewModel)
val channelState by observeChannel(channel, accountViewModel)
val channelPicture = channelState?.channel?.profilePicture() ?: channel.profilePicture()
val channelName = channelState?.channel?.toBestDisplayName() ?: channel.toBestDisplayName()
val noteEvent = note.event
val route = Route.Channel(channel.idHex)
val noteEvent = lastMessage.event
val description =
if (noteEvent is ChannelCreateEvent) {
@@ -207,54 +180,12 @@ private fun ChannelRoomCompose(
channelIdHex = channel.idHex,
channelPicture = channelPicture,
channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, R.string.public_chat, modifier) },
channelLastTime = note.createdAt(),
channelLastTime = lastMessage.createdAt(),
channelLastContent = "$authorName: $description",
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
onClick = { nav.nav(route) },
)
}
@Composable
private fun ChannelRoomCompose(
note: Note,
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val authorName by observeUserName(note.author!!, accountViewModel)
val channelState by observeChannel(channel, accountViewModel)
val relayInfo by loadRelayInfo(channel.roomId.relayUrl, accountViewModel)
val channelName = channelState?.channel?.toBestDisplayName() ?: channel.toBestDisplayName()
val noteEvent = note.event
val route = Route.Channel(channel.idHex)
val description =
if (noteEvent is ChannelCreateEvent) {
stringRes(R.string.channel_created)
} else if (noteEvent is ChannelMetadataEvent) {
"${stringRes(R.string.channel_information_changed_to)} "
} else {
noteEvent?.content?.take(200)
}
val lastReadTime by accountViewModel.account.loadLastReadFlow("Channel/${channel.idHex}").collectAsStateWithLifecycle()
ChannelName(
channelIdHex = channel.idHex,
channelPicture = relayInfo?.icon,
channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, R.string.ephemeral_relay_chat, modifier) },
channelLastTime = note.createdAt(),
channelLastContent = "$authorName: $description",
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
onClick = { nav.nav(route) },
onClick = { nav.nav(routeFor(channel)) },
)
}
@@ -300,8 +231,8 @@ private fun ChannelTitleWithLabelInfo(
@Composable
private fun UserRoomCompose(
note: Note,
room: ChatroomKey,
lastMessage: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -315,10 +246,10 @@ private fun UserRoomCompose(
},
firstRow = {
RoomNameDisplay(room, Modifier.weight(1f), accountViewModel)
TimeAgo(note.createdAt())
TimeAgo(lastMessage.createdAt())
},
secondRow = {
LoadDecryptedContentOrNull(note, accountViewModel) { content ->
LoadDecryptedContentOrNull(lastMessage, accountViewModel) { content ->
if (content != null) {
Text(
content,
@@ -340,7 +271,7 @@ private fun UserRoomCompose(
}
val lastReadTime by accountViewModel.account.loadLastReadFlow("Room/${room.hashCode()}").collectAsStateWithLifecycle()
if ((note.createdAt() ?: Long.MIN_VALUE) > lastReadTime) {
if ((lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) {
NewItemsBubble()
}
},
@@ -58,7 +58,7 @@ class ChatroomListKnownFeedFilter(
val publicChannels =
account
.publicChatList.flow.value
.mapNotNull { LocalCache.getChannelIfExists(it.eventId) }
.mapNotNull { LocalCache.getPublicChatChannelIfExists(it.eventId) }
.mapNotNull { it ->
it.notes
.filter { key, it -> account.isAcceptable(it) && it.event != null }
@@ -69,7 +69,7 @@ class ChatroomListKnownFeedFilter(
val ephemeralChats =
account
.ephemeralChatList.liveEphemeralChatList.value
.mapNotNull { LocalCache.getChannelIfExists(it) }
.mapNotNull { LocalCache.getEphemeralChatChannelIfExists(it) }
.mapNotNull { it ->
it.notes
.filter { key, it -> account.isAcceptable(it) && it.event != null }
@@ -39,7 +39,7 @@ fun filterFollowingPublicChats(
mapOfSet {
followingChannels.forEach { channelId ->
val relays =
LocalCache.getChannelIfExists(channelId)?.relays()
LocalCache.getPublicChatChannelIfExists(channelId)?.relays()
?: LocalCache.relayHints.hintsForEvent(channelId).ifEmpty { null }
?: Constants.eventFinderRelays
@@ -40,7 +40,7 @@ fun filterLastMessageFollowingPublicChats(
mapOfSet {
followingChannels.forEach { channelId ->
val relays =
LocalCache.getChannelIfExists(channelId)?.relays()
LocalCache.getPublicChatChannelIfExists(channelId)?.relays()
?: LocalCache.relayHints.hintsForEvent(channelId).ifEmpty { null }
?: Constants.eventFinderRelays
@@ -43,7 +43,7 @@ import com.vitorpamplona.amethyst.ui.navigation.MainTopBar
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.Chatroom
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.theme.Size20dp
@@ -62,13 +62,9 @@ fun MessagesTwoPane(
val strategy =
remember {
if (widthSizeClass == WindowWidthSizeClass.Expanded) {
HorizontalTwoPaneStrategy(
splitFraction = 1f / 3f,
)
HorizontalTwoPaneStrategy(splitFraction = 1f / 3f)
} else {
HorizontalTwoPaneStrategy(
splitFraction = 1f / 2.5f,
)
HorizontalTwoPaneStrategy(splitFraction = 1f / 2.5f)
}
}
@@ -117,8 +113,8 @@ fun MessagesTwoPane(
)
}
if (it is Route.Channel) {
ChannelView(
if (it is Route.PublicChatChannel) {
PublicChatChannelView(
channelId = it.id,
accountViewModel = accountViewModel,
nav = nav,
@@ -38,18 +38,18 @@ class TwoPaneNav(
val innerNav = mutableStateOf<Route?>(null)
override fun nav(route: Route) {
if (route is Route.Room || route is Route.Channel) {
if (route is Route.Room || route is Route.PublicChatChannel) {
innerNav.value = route
} else {
nav.nav(route)
}
}
override fun nav(routeMaker: suspend () -> Route?) {
override fun nav(computeRoute: suspend () -> Route?) {
scope.launch(Dispatchers.Default) {
val route = routeMaker()
val route = computeRoute()
if (route != null) {
if (route is Route.Room || route is Route.Channel) {
if (route is Route.Room || route is Route.PublicChatChannel) {
innerNav.value = route
} else {
nav.nav(route)
@@ -68,9 +68,9 @@ class TwoPaneNav(
override fun <T : Route> popUpTo(
route: Route,
upToClass: KClass<T>,
klass: KClass<T>,
) {
nav.popUpTo<T>(route, upToClass)
nav.popUpTo<T>(route, klass)
}
override fun closeDrawer() {
@@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.RenderLongFormThumb
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.RenderChannelThumb
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.RenderPublicChatChannelThumb
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.RenderFollowSetThumb
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.RenderLiveActivityThumb
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.RenderCommunitiesThumb
@@ -203,7 +203,7 @@ private fun RenderNoteRow(
when (baseNote.event) {
is LiveActivitiesEvent -> RenderLiveActivityThumb(baseNote, accountViewModel, nav)
is CommunityDefinitionEvent -> RenderCommunitiesThumb(baseNote, accountViewModel, nav)
is ChannelCreateEvent -> RenderChannelThumb(baseNote, accountViewModel, nav)
is ChannelCreateEvent -> RenderPublicChatChannelThumb(baseNote, accountViewModel, nav)
is AppDefinitionEvent -> RenderContentDVMThumb(baseNote, accountViewModel, nav)
is FollowListEvent -> RenderFollowSetThumb(baseNote, accountViewModel, nav)
is LongTextNoteEvent -> RenderLongFormThumb(baseNote, accountViewModel, nav)
@@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
class DiscoveryFollowsDiscoverySubAssembler1(
client: NostrClient,
allKeys: () -> Set<DiscoveryQueryState>,
) : PerUserAndFollowListEoseManager<DiscoveryQueryState>(client, allKeys) {
) : PerUserAndFollowListEoseManager<DiscoveryQueryState, String>(client, allKeys) {
override fun updateFilter(
key: DiscoveryQueryState,
since: SincePerRelayMap?,
@@ -38,7 +38,7 @@ import kotlinx.coroutines.launch
class DiscoveryFollowsDiscoverySubAssembler2(
client: NostrClient,
allKeys: () -> Set<DiscoveryQueryState>,
) : PerUserAndFollowListEoseManager<DiscoveryQueryState>(client, allKeys) {
) : PerUserAndFollowListEoseManager<DiscoveryQueryState, String>(client, allKeys) {
override fun updateFilter(
key: DiscoveryQueryState,
since: SincePerRelayMap?,
@@ -38,7 +38,7 @@ import kotlinx.coroutines.launch
class DiscoveryFollowsDiscoverySubAssembler3(
client: NostrClient,
allKeys: () -> Set<DiscoveryQueryState>,
) : PerUserAndFollowListEoseManager<DiscoveryQueryState>(client, allKeys) {
) : PerUserAndFollowListEoseManager<DiscoveryQueryState, String>(client, allKeys) {
override fun updateFilter(
key: DiscoveryQueryState,
since: SincePerRelayMap?,
@@ -74,7 +74,7 @@ open class DiscoverChatFeedFilter(
// note event here will never be null
val noteEvent = note.event
if (noteEvent is ChannelCreateEvent && params.match(noteEvent)) {
if ((LocalCache.getChannelIfExists(noteEvent.id)?.notes?.size() ?: 0) > 0) {
if ((LocalCache.getPublicChatChannelIfExists(noteEvent.id)?.notes?.size() ?: 0) > 0) {
note
} else {
null
@@ -86,7 +86,7 @@ open class DiscoverChatFeedFilter(
if (channel != null &&
(channelEvent == null || (channelEvent is ChannelCreateEvent && params.match(channelEvent)))
) {
if ((LocalCache.getChannelIfExists(channel.idHex)?.notes?.size() ?: 0) > 0) {
if ((LocalCache.getPublicChatChannelIfExists(channel.idHex)?.notes?.size() ?: 0) > 0) {
channel
} else {
null
@@ -103,7 +103,7 @@ open class DiscoverChatFeedFilter(
override fun sort(collection: Set<Note>): List<Note> {
val lastNote =
collection.associateWith { note ->
LocalCache.getChannelIfExists(note.idHex)?.lastNoteCreatedAt ?: 0
LocalCache.getPublicChatChannelIfExists(note.idHex)?.lastNoteCreatedAt ?: 0
}
return collection
@@ -41,9 +41,9 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter
@@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
import com.vitorpamplona.amethyst.ui.note.Gallery
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
@@ -73,22 +73,22 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun RenderChannelThumb(
fun RenderPublicChatChannelThumb(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? ChannelCreateEvent ?: return
LoadChannel(baseChannelHex = baseNote.idHex, accountViewModel) {
RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav)
LoadPublicChatChannel(baseNote.idHex, accountViewModel) {
RenderPublicChatChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav)
}
}
@Composable
fun RenderChannelThumb(
fun RenderPublicChatChannelThumb(
baseNote: Note,
channel: Channel,
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -47,7 +47,7 @@ open class DiscoverLiveFeedFilter(
followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex)
override fun feed(): List<Note> {
val allChannelNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getNoteIfExists(channel.idHex) }
val allChannelNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getAddressableNoteIfExists(channel.address) }
val allMessageNotes = LocalCache.liveChatChannels.map { _, channel -> channel.notes.filter { key, it -> it.event is LiveActivitiesEvent } }.flatten()
val notes = innerApplyFilter(allChannelNotes + allMessageNotes)
@@ -94,7 +94,7 @@ open class DiscoverLiveFeedFilter(
return collection
.sortedWith(
compareBy(
{ convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) },
{ convertStatusToOrder((it.event as? LiveActivitiesEvent)?.statusEnum()) },
{ participantCounts[it] },
{ allParticipants[it] },
{ (it.event as? LiveActivitiesEvent)?.starts() ?: it.createdAt() },
@@ -103,11 +103,11 @@ open class DiscoverLiveFeedFilter(
).reversed()
}
fun convertStatusToOrder(status: String?): Int =
fun convertStatusToOrder(status: StatusTag.STATUS?): Int =
when (status) {
StatusTag.STATUS.LIVE.code -> 2
StatusTag.STATUS.PLANNED.code -> 1
StatusTag.STATUS.ENDED.code -> 0
StatusTag.STATUS.LIVE -> 2
StatusTag.STATUS.PLANNED -> 1
StatusTag.STATUS.ENDED -> 0
else -> 0
}
}
@@ -54,9 +54,10 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
import com.vitorpamplona.amethyst.ui.note.Gallery
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.EndedFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.OfflineFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag
@@ -64,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
@@ -75,13 +77,14 @@ import kotlinx.coroutines.launch
@Immutable
data class LiveActivityCard(
val id: Address?,
val name: String,
val cover: String?,
val media: String?,
val subject: String?,
val content: String?,
val participants: ImmutableList<ParticipantTag>,
val status: String?,
val status: StatusTag.STATUS?,
val starts: Long?,
)
@@ -95,13 +98,14 @@ fun RenderLiveActivityThumb(
val noteEvent = it.event as? LiveActivitiesEvent
LiveActivityCard(
id = noteEvent?.address(),
name = noteEvent?.dTag() ?: "",
cover = noteEvent?.image()?.ifBlank { null },
media = noteEvent?.streaming(),
subject = noteEvent?.title()?.ifBlank { null },
content = noteEvent?.summary(),
participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(),
status = noteEvent?.status(),
status = noteEvent?.statusEnum(),
starts = noteEvent?.starts(),
)
}
@@ -146,7 +150,7 @@ fun RenderLiveActivityThumb(
Box(Modifier.padding(10.dp)) {
CrossfadeIfEnabled(targetState = card.status, label = "RenderLiveActivityThumb", accountViewModel = accountViewModel) {
when (it) {
StatusTag.STATUS.LIVE.code -> {
StatusTag.STATUS.LIVE -> {
val url = card.media
if (url.isNullOrBlank()) {
LiveFlag()
@@ -160,10 +164,10 @@ fun RenderLiveActivityThumb(
}
}
}
StatusTag.STATUS.ENDED.code -> {
StatusTag.STATUS.ENDED -> {
EndedFlag()
}
StatusTag.STATUS.PLANNED.code -> {
StatusTag.STATUS.PLANNED -> {
ScheduledFlag(card.starts)
}
else -> {
@@ -188,8 +192,10 @@ fun RenderLiveActivityThumb(
Spacer(modifier = DoubleVertSpacer)
ChannelHeader(
channelHex = baseNote.idHex,
baseNote.address()?.let {
LoadLiveActivityChannel(it, accountViewModel) {
LiveActivitiesChannelHeader(
baseChannel = it,
showVideo = false,
showFlag = false,
sendToChannel = true,
@@ -198,6 +204,8 @@ fun RenderLiveActivityThumb(
nav = nav,
)
}
}
}
}
@Composable
@@ -596,7 +596,7 @@ open class NewProductViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.Default) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!)
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class GeoHashFeedFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<GeohashQueryState>,
) : PerUniqueIdEoseManager<GeohashQueryState>(client, allKeys) {
) : PerUniqueIdEoseManager<GeohashQueryState, String>(client, allKeys) {
override fun updateFilter(
key: GeohashQueryState,
since: SincePerRelayMap?,
@@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class HashtagFeedFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<HashtagQueryState>,
) : PerUniqueIdEoseManager<HashtagQueryState>(client, allKeys) {
) : PerUniqueIdEoseManager<HashtagQueryState, String>(client, allKeys) {
override fun updateFilter(
key: HashtagQueryState,
since: SincePerRelayMap?,
@@ -56,7 +56,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
@@ -343,10 +342,8 @@ fun DisplayLiveBubbles(
val feed by liveFeed.feed.collectAsStateWithLifecycle()
LazyRow(HorzPadding, horizontalArrangement = spacedBy(Size5dp)) {
itemsIndexed(feed.list, key = { _, item -> item.idHex }) { _, item ->
when (item) {
is EphemeralChatChannel -> RenderEphemeralBubble(item, accountViewModel, nav)
}
itemsIndexed(feed.list, key = { _, item -> item.roomId.toKey() }) { _, item ->
RenderEphemeralBubble(item, accountViewModel, nav)
}
}
}
@@ -516,7 +516,6 @@ open class ShortNotePostViewModel :
message.text,
pTags,
eTags,
originalNote?.channelHex(),
accountViewModel!!,
)
tagger.run()
@@ -865,7 +864,7 @@ open class ShortNotePostViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.Default) {
val tagger =
NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!)
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -38,7 +37,7 @@ import kotlin.collections.toSet
class HomeLiveFilter(
val account: Account,
) : AdditiveComplexFeedFilter<Channel, Note>() {
) : AdditiveComplexFeedFilter<EphemeralChatChannel, Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun showHiddenKey(): Boolean = false
@@ -51,7 +50,7 @@ class HomeLiveFilter(
fun limitTime() = TimeUtils.fifteenMinutesAgo()
override fun feed(): List<Channel> {
override fun feed(): List<EphemeralChatChannel> {
val filterParams = buildFilterParams(account)
val fiveMinsAgo = limitTime()
@@ -74,9 +73,9 @@ class HomeLiveFilter(
}.isNotEmpty()
override fun updateListWith(
oldList: List<Channel>,
oldList: List<EphemeralChatChannel>,
newItems: Set<Note>,
): List<Channel> {
): List<EphemeralChatChannel> {
val fiveMinsAgo = limitTime()
val revisedOldList =
@@ -91,7 +90,7 @@ class HomeLiveFilter(
.mapNotNull {
val room = (it.event as? EphemeralChatEvent)?.roomId()
if (room != null) {
LocalCache.getChannelIfExists(room)
LocalCache.getEphemeralChatChannelIfExists(room)
} else {
null
}
@@ -124,7 +123,7 @@ class HomeLiveFilter(
filterParams.match(noteEvent, note.relays)
}
fun sort(collection: Set<Channel>): List<Channel> {
fun sort(collection: Set<EphemeralChatChannel>): List<EphemeralChatChannel> {
val topFilter = account.liveDiscoveryFollowLists.value
val topFilterAuthors =
when (topFilter) {
@@ -141,18 +140,17 @@ class HomeLiveFilter(
collection.associate { it to followsThatParticipateOn(it, followingKeySet) }
return collection.sortedWith(
compareByDescending<Channel> { followCounts[it] }
.thenByDescending<Channel> { it.lastNoteCreatedAt }
.thenBy { it.idHex },
compareByDescending<EphemeralChatChannel> { followCounts[it] }
.thenByDescending<EphemeralChatChannel> { it.lastNoteCreatedAt }
.thenBy { it.roomId.id }
.thenBy { it.roomId.relayUrl },
)
}
fun followsThatParticipateOn(
channel: Channel,
channel: EphemeralChatChannel,
followingSet: Set<HexKey>?,
): Int {
if (channel == null) return 0
var count = 0
channel.notes.forEach { key, value ->
@@ -49,7 +49,7 @@ import kotlinx.coroutines.launch
class HomeOutboxEventsEoseManager(
client: NostrClient,
allKeys: () -> Set<HomeQueryState>,
) : PerUserAndFollowListEoseManager<HomeQueryState>(client, allKeys) {
) : PerUserAndFollowListEoseManager<HomeQueryState, String>(client, allKeys) {
override fun updateFilter(
key: HomeQueryState,
since: SincePerRelayMap?,
@@ -72,7 +72,7 @@ class CardFeedContentState(
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
private var lastFeedKey: String? = null
private var lastFeedKey: Any? = null
override val isRefreshing: MutableState<Boolean> = mutableStateOf(false)
@@ -29,33 +29,21 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Nav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
@@ -66,24 +54,15 @@ fun LoadRedirectScreen(
) {
if (eventId == null) return
var noteBase by remember { mutableStateOf<Note?>(null) }
LaunchedEffect(eventId) {
launch(Dispatchers.IO) {
val newNoteBase = LocalCache.checkGetOrCreateNote(eventId)
if (newNoteBase != noteBase) {
noteBase = newNoteBase
}
}
}
noteBase?.let {
LoadNote(eventId, accountViewModel) { note ->
note?.let {
LoadRedirectScreen(
baseNote = it,
baseNote = note,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
@@ -95,11 +74,13 @@ fun LoadRedirectScreen(
val noteState by observeNote(baseNote, accountViewModel)
LaunchedEffect(key1 = noteState) {
val note = noteState?.note ?: return@LaunchedEffect
val event = note.event
val event = noteState.note.event
if (event != null) {
withContext(Dispatchers.IO) { redirect(event, accountViewModel, nav) }
withContext(Dispatchers.IO) {
routeFor(event, accountViewModel.userProfile())?.let { route ->
nav.popUpTo(route, Route.EventRedirect::class)
}
}
}
}
@@ -111,62 +92,3 @@ fun LoadRedirectScreen(
Text(stringRes(R.string.looking_for_event, baseNote.idHex))
}
}
fun redirect(
eventId: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
LocalCache.getNoteIfExists(eventId)?.event?.let {
redirect(it, accountViewModel, nav)
}
}
fun redirect(
event: Event,
accountViewModel: AccountViewModel,
nav: INav,
) {
val channelHex =
if (
event is ChannelMessageEvent ||
event is ChannelMetadataEvent ||
event is ChannelCreateEvent ||
event is LiveActivitiesChatMessageEvent ||
event is LiveActivitiesEvent
) {
(event as? ChannelMessageEvent)?.channelId()
?: (event as? ChannelMetadataEvent)?.channelId()
?: (event as? ChannelCreateEvent)?.id
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.aTag()?.toTag()
} else {
null
}
if (event is GiftWrapEvent) {
event.innerEventId?.let {
redirect(it, accountViewModel, nav)
} ?: run {
accountViewModel.unwrap(event) { redirect(it, accountViewModel, nav) }
}
} else if (event is SealedRumorEvent) {
event.innerEventId?.let {
redirect(it, accountViewModel, nav)
} ?: run {
accountViewModel.unseal(event) { redirect(it, accountViewModel, nav) }
}
} else {
if (event is ChannelCreateEvent) {
nav.popUpTo(Route.Channel(event.id), Route.EventRedirect::class)
} else if (event is ChatroomKeyable) {
val withKey = event.chatroomKey(accountViewModel.userProfile().pubkeyHex)
accountViewModel.userProfile().createChatroom(withKey)
nav.popUpTo(Route.Room(withKey.hashCode()), Route.EventRedirect::class)
} else if (channelHex != null) {
nav.popUpTo(Route.Channel(channelHex), Route.EventRedirect::class)
} else {
nav.popUpTo(Route.Note(event.id), Route.EventRedirect::class)
}
}
}
@@ -29,7 +29,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
@@ -71,36 +70,58 @@ class SearchBarViewModel(
val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account)
val searchResultsUsers =
combine(searchValueFlow.debounce(100), invalidations.debounce(100)) { term, version ->
logTime("SearchBarViewModel findUsersStartingWith") {
combine(
searchValueFlow.debounce(100),
invalidations.debounce(100),
) { term, version ->
LocalCache.findUsersStartingWith(term, account)
}
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
val searchResultsNotes =
combine(searchValueFlow.debounce(100), invalidations) { term, version ->
logTime("SearchBarViewModel findNotesStartingWith") {
combine(
searchValueFlow.debounce(100),
invalidations,
) { term, version ->
LocalCache
.findNotesStartingWith(term, account.hiddenUsers)
.sortedWith(DefaultFeedOrder)
}
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
val searchResultsChannels =
combine(searchValueFlow.debounce(100), invalidations) { term, version ->
logTime("SearchBarViewModel findChannelsStartingWith") {
LocalCache.findChannelsStartingWith(term)
}
val searchResultsPublicChatChannels =
combine(
searchValueFlow.debounce(100),
invalidations,
) { term, version ->
LocalCache.findPublicChatChannelsStartingWith(term)
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
val searchResultsEphemeralChannels =
combine(
searchValueFlow.debounce(100),
invalidations,
) { term, version ->
LocalCache.findEphemeralChatChannelsStartingWith(term)
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
val searchResultsLiveActivityChannels =
combine(
searchValueFlow.debounce(100),
invalidations,
) { term, version ->
LocalCache.findLiveActivityChannelsStartingWith(term)
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
val hashtagResults =
combine(searchValueFlow.debounce(100), invalidations) { term, version ->
logTime("SearchBarViewModel findHashtags") {
combine(
searchValueFlow.debounce(100),
invalidations,
) { term, version ->
findHashtags(term)
}
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.SearchIcon
@@ -230,7 +231,9 @@ private fun DisplaySearchResults(
val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle()
val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle()
val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle()
val publicChatChannels by searchBarViewModel.searchResultsPublicChatChannels.collectAsStateWithLifecycle()
val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle()
val liveActivityChannels by searchBarViewModel.searchResultsLiveActivityChannels.collectAsStateWithLifecycle()
val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle()
LazyColumn(
@@ -262,8 +265,8 @@ private fun DisplaySearchResults(
}
itemsIndexed(
channels,
key = { _, item -> "c" + item.idHex },
publicChatChannels,
key = { _, item -> "public" + item.idHex },
) { _, item ->
ChannelName(
channelIdHex = item.idHex,
@@ -279,7 +282,61 @@ private fun DisplaySearchResults(
hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
onClick = { nav.nav(Route.Channel(item.idHex)) },
onClick = { nav.nav(routeFor(item)) },
)
HorizontalDivider(
modifier = StdTopPadding,
thickness = DividerThickness,
)
}
itemsIndexed(
ephemeralChannels,
key = { _, item -> "ephem" + item.roomId.toKey() },
) { _, item ->
ChannelName(
channelIdHex = item.roomId.toKey(),
channelPicture = item.profilePicture(),
channelTitle = {
Text(
item.toBestDisplayName(),
fontWeight = FontWeight.Bold,
)
},
channelLastTime = null,
channelLastContent = item.summary(),
hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
onClick = { nav.nav(routeFor(item)) },
)
HorizontalDivider(
modifier = StdTopPadding,
thickness = DividerThickness,
)
}
itemsIndexed(
liveActivityChannels,
key = { _, item -> "live" + item.address.toValue() },
) { _, item ->
ChannelName(
channelIdHex = item.address.toValue(),
channelPicture = item.profilePicture(),
channelTitle = {
Text(
item.toBestDisplayName(),
fontWeight = FontWeight.Bold,
)
},
channelLastTime = null,
channelLastContent = item.summary(),
hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
onClick = { nav.nav(routeFor(item)) },
)
HorizontalDivider(
@@ -153,7 +153,7 @@ import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.RenderFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.LevelFeedViewModel
@@ -525,9 +525,8 @@ private fun FullBleedNoteCompose(
(noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) &&
baseNote.channelHex() != null
) {
ChannelHeader(
PublicChatChannelHeader(
channelHex = baseNote.channelHex()!!,
showVideo = true,
sendToChannel = true,
accountViewModel = accountViewModel,
nav = nav,
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -39,7 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class ThreadEventLoaderSubAssembler(
client: NostrClient,
allKeys: () -> Set<ThreadQueryState>,
) : PerUniqueIdEoseManager<ThreadQueryState>(client, allKeys, invalidateAfterEose = true) {
) : PerUniqueIdEoseManager<ThreadQueryState, HexKey>(client, allKeys, invalidateAfterEose = true) {
override fun updateFilter(
key: ThreadQueryState,
since: SincePerRelayMap?,
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -36,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class ThreadFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<ThreadQueryState>,
) : PerUniqueIdEoseManager<ThreadQueryState>(client, allKeys) {
) : PerUniqueIdEoseManager<ThreadQueryState, HexKey>(client, allKeys) {
override fun updateFilter(
key: ThreadQueryState,
since: SincePerRelayMap?,
@@ -52,7 +52,7 @@ import kotlinx.coroutines.launch
class VideoOutboxEventsFilterSubAssembler(
client: NostrClient,
allKeys: () -> Set<VideoQueryState>,
) : PerUserAndFollowListEoseManager<VideoQueryState>(client, allKeys) {
) : PerUserAndFollowListEoseManager<VideoQueryState, String>(client, allKeys) {
override fun updateFilter(
key: VideoQueryState,
since: SincePerRelayMap?,
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
@@ -331,3 +332,13 @@ val LargeRelayIconModifier =
Modifier
.size(Size55dp)
.clip(shape = CircleShape)
val FollowSetImageModifier =
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 21f / 9f)
.clip(QuoteBorder)
val SimpleImageBorder = Modifier.fillMaxSize().clip(QuoteBorder)
val SimpleHeaderImage = Modifier.fillMaxWidth().heightIn(max = 200.dp)
@@ -90,6 +90,10 @@ class LiveActivitiesEvent(
fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parse))
fun statusEnum() = checkStatusEnum(tags.firstNotNullOfOrNull(StatusTag::parseEnum))
fun isLive() = statusEnum() == StatusTag.STATUS.LIVE
fun currentParticipants() = tags.firstNotNullOfOrNull(CurrentParticipantsTag::parse)
fun totalParticipants() = tags.firstNotNullOfOrNull(TotalParticipantsTag::parse)
@@ -113,6 +117,13 @@ class LiveActivitiesEvent(
eventStatus
}
fun checkStatusEnum(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? =
if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) {
StatusTag.STATUS.ENDED
} else {
eventStatus
}
fun participantsIntersect(keySet: Set<String>): Boolean = keySet.contains(pubKey) || tags.any(ParticipantTag::isIn, keySet)
companion object {
@@ -33,6 +33,16 @@ class StatusTag {
;
fun toTagArray() = assemble(this)
companion object {
fun parse(code: String): STATUS? =
when (code) {
LIVE.code -> LIVE
PLANNED.code -> PLANNED
ENDED.code -> ENDED
else -> null
}
}
}
companion object {
@@ -46,6 +56,14 @@ class StatusTag {
return tag[1]
}
@JvmStatic
fun parseEnum(tag: Array<String>): STATUS? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return STATUS.parse(tag[1])
}
@JvmStatic
fun assemble(status: STATUS) = arrayOf(TAG_NAME, status.code)
}