Merge remote-tracking branch 'origin/labeled-bookmarks' into labeled-bookmarks

This commit is contained in:
KotlinGeekDev
2025-11-15 03:36:28 +01:00
157 changed files with 5269 additions and 1436 deletions
@@ -176,7 +176,6 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
@@ -352,6 +351,7 @@ class Account(
val liveHomeFollowLists: StateFlow<IFeedTopNavFilter> =
FeedTopNavFilterState(
feedFilterListName = settings.defaultHomeFollowList,
kind3Follows = kind3FollowList.flow,
allFollows = allFollows.flow,
locationFlow = geolocationFlow,
followsRelays = defaultGlobalRelays.flow,
@@ -367,6 +367,7 @@ class Account(
val liveStoriesFollowLists: StateFlow<IFeedTopNavFilter> =
FeedTopNavFilterState(
feedFilterListName = settings.defaultStoriesFollowList,
kind3Follows = kind3FollowList.flow,
allFollows = allFollows.flow,
locationFlow = geolocationFlow,
followsRelays = defaultGlobalRelays.flow,
@@ -382,6 +383,7 @@ class Account(
val liveDiscoveryFollowLists: StateFlow<IFeedTopNavFilter> =
FeedTopNavFilterState(
feedFilterListName = settings.defaultDiscoveryFollowList,
kind3Follows = kind3FollowList.flow,
allFollows = allFollows.flow,
locationFlow = geolocationFlow,
followsRelays = defaultGlobalRelays.flow,
@@ -397,6 +399,7 @@ class Account(
val liveNotificationFollowLists: StateFlow<IFeedTopNavFilter> =
FeedTopNavFilterState(
feedFilterListName = settings.defaultNotificationFollowList,
kind3Follows = kind3FollowList.flow,
allFollows = allFollows.flow,
locationFlow = geolocationFlow,
followsRelays = defaultGlobalRelays.flow,
@@ -1468,18 +1471,6 @@ class Account(
val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) }
mine.forEach { giftWrap ->
val gift = giftWrap.unwrapOrNull(signer)
if (gift is SealedRumorEvent) {
val rumor = gift.unsealOrNull(signer)
if (rumor != null) {
cache.justConsumeMyOwnEvent(rumor)
}
}
if (gift != null) {
cache.justConsumeMyOwnEvent(gift)
}
cache.justConsumeMyOwnEvent(giftWrap)
}
@@ -1693,6 +1684,10 @@ class Account(
fun isFollowing(user: HexKey): Boolean = user in followingKeySet()
fun isKnown(user: User): Boolean = user.pubkeyHex in allFollows.flow.value.authors
fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors
fun isAcceptable(note: Note): Boolean {
return note.author?.let { isAcceptable(it) } ?: true &&
// if user hasn't hided this author
@@ -101,6 +101,9 @@ val ALL_FOLLOWS = " All Follows "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val ALL_USER_FOLLOWS = " All User Follows "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val KIND3_FOLLOWS = " Main User Follows "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val AROUND_ME = " Around Me "
@@ -64,7 +64,7 @@ fun RenderHashTagIconsPreview() {
) { paragraph, state, spaceWidth, modifier ->
RenderTextParagraph(paragraph, spaceWidth, modifier) { word ->
when (word) {
is HashTagSegment -> HashTag(word, EmptyNav)
is HashTagSegment -> HashTag(word, EmptyNav())
is RegularTextSegment -> Text(word.segmentText)
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.peopleList
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -30,8 +32,20 @@ import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.amethyst.model.updateFlow
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.description
import com.vitorpamplona.quartz.nip51Lists.followList.image
import com.vitorpamplona.quartz.nip51Lists.followList.person
import com.vitorpamplona.quartz.nip51Lists.followList.personFirst
import com.vitorpamplona.quartz.nip51Lists.followList.removePerson
import com.vitorpamplona.quartz.nip51Lists.followList.title
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
import com.vitorpamplona.quartz.nip51Lists.peopleList.image
import com.vitorpamplona.quartz.nip51Lists.peopleList.name
import com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -46,6 +60,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
import java.util.UUID
/**
* Maintains several stateflows for each step in processing PeopleLists
@@ -59,7 +74,7 @@ class FollowListsState(
) {
val user = cache.getOrCreateUser(signer.pubKey)
fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.Companion.KIND, user.pubkeyHex)
fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.KIND, user.pubkeyHex)
val followListVersions = MutableStateFlow(0)
@@ -83,7 +98,7 @@ class FollowListsState(
.transformLatest { emitAll(it.updateFlow<FollowListEvent>()) }
.onStart { emit(followListNotes.value.events()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyList())
.stateIn(scope, SharingStarted.Eagerly, emptyList())
fun List<FollowListEvent>.mapToUserIdSet() = this.map { it.followIdSet() }.flattenToSet()
@@ -99,6 +114,7 @@ class FollowListsState(
identifierTag = this.dTag(),
title = this.title() ?: this.dTag(),
description = this.description(),
image = this.image(),
privateMembers = emptySet(),
publicMembers = cache.load(this.followIdSet()),
)
@@ -112,6 +128,18 @@ class FollowListsState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyList())
fun List<PeopleList>.select(dTag: String) =
this.firstOrNull {
it.identifierTag == dTag
}
fun selectList(dTag: String) = uiListFlow.value.select(dTag)
fun selectListFlow(dTag: String) =
uiListFlow
.map { it.select(dTag) }
.onStart { emit(selectList(dTag)) }
fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex)
fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.Companion.KIND) || deletesAnyEventIn(followListsEventIds.value)
@@ -140,4 +168,146 @@ class FollowListsState(
fun forceRefresh() {
followListVersions.update { it + 1 }
}
// --------------
// Updating Lists
// --------------
fun getPeopleListNote(noteIdentifier: String): AddressableNote? = existingPeopleListNotes().find { it.dTag() == noteIdentifier }
fun getPeopleList(noteIdentifier: String): FollowListEvent = getPeopleListNote(noteIdentifier)?.event as FollowListEvent
fun User.toUserTag() = UserTag(this.pubkeyHex, this.bestRelayHint())
fun Set<User>.toUserTags() = map { it.toUserTag() }
suspend fun addFollowList(
name: String,
desc: String?,
image: String?,
member: User? = null,
isPrivate: Boolean = false,
account: Account,
): String {
val dTag = UUID.randomUUID().toString()
val newListTemplate =
FollowListEvent.build(
name = name,
people = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
dTag = dTag,
) {
if (desc != null) description(desc)
if (image != null) image(image)
}
val newList = signer.sign(newListTemplate)
account.sendMyPublicAndPrivateOutbox(newList)
return dTag
}
suspend fun updateMetadata(
name: String?,
desc: String?,
image: String?,
peopleList: PeopleList,
account: Account,
) {
val listEvent = getPeopleList(peopleList.identifierTag)
val template =
listEvent.update {
if (name != null) title(name)
if (desc != null) description(desc)
if (image != null) image(image)
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun cloneFollowSet(
currentFollowPack: PeopleList,
customCloneName: String?,
customCloneDescription: String?,
account: Account,
) {
val listEvent = getPeopleList(currentFollowPack.identifierTag)
val template =
listEvent.update {
// new list
dTag(UUID.randomUUID().toString())
// updates names
if (customCloneName != null) title(customCloneName)
if (customCloneDescription != null) description(customCloneDescription)
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun deleteFollowSet(
identifierTag: String,
account: Account,
) {
val followListEvent = getPeopleList(identifierTag)
val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent)))
account.sendMyPublicAndPrivateOutbox(deletionEvent)
}
suspend fun addUserToSet(
user: User,
identifierTag: String,
account: Account,
) {
val followListEvent = getPeopleList(identifierTag)
val template =
followListEvent.update {
person(user.pubkeyHex, user.bestRelayHint())
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun addUserFirstToSet(
user: User,
identifierTag: String,
account: Account,
) {
val followListEvent = getPeopleList(identifierTag)
val template =
followListEvent.update {
personFirst(user.pubkeyHex, user.bestRelayHint())
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun removeUserFromSet(
user: User,
identifierTag: String,
account: Account,
) {
val followListEvent = getPeopleList(identifierTag)
val template =
followListEvent.update {
removePerson(user.pubkeyHex)
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
}
@@ -30,6 +30,7 @@ data class PeopleList(
val identifierTag: String,
val title: String,
val description: String?,
val image: String?,
val privateMembers: Set<User> = emptySet(),
val publicMembers: Set<User> = emptySet(),
) {
@@ -32,9 +32,14 @@ import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.amethyst.model.updateFlow
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.followList.description
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
import com.vitorpamplona.quartz.nip51Lists.peopleList.image
import com.vitorpamplona.quartz.nip51Lists.peopleList.name
import com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -116,6 +121,7 @@ class PeopleListsState(
identifierTag = this.dTag(),
title = this.nameOrTitle() ?: this.dTag(),
description = this.description(),
image = this.image(),
privateMembers = cache.load(decryptionCache.privateUserIdSet(this)),
publicMembers = cache.load(this.publicUsersIdSet()),
)
@@ -129,17 +135,17 @@ class PeopleListsState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
fun selectListFlow(selectedDTag: String) =
fun List<PeopleList>.select(dTag: String) =
this.firstOrNull {
it.identifierTag == dTag
}
fun selectList(dTag: String) = uiListFlow.value.select(dTag)
fun selectListFlow(dTag: String) =
uiListFlow
.map { peopleLists ->
peopleLists.firstOrNull { list ->
list.identifierTag == selectedDTag
}
}.onStart {
emit(
uiListFlow.value.firstOrNull { it.identifierTag == selectedDTag },
)
}
.map { it.select(dTag) }
.onStart { emit(selectList(dTag)) }
fun DeletionEvent.hasDeletedAnyPeopleList() = deleteAddressesWithKind(PeopleListEvent.KIND) || deletesAnyEventIn(peopleListsEventIds.value)
@@ -183,49 +189,48 @@ class PeopleListsState(
suspend fun addFollowList(
listName: String,
listDescription: String?,
listImage: String?,
member: User? = null,
isPrivate: Boolean = false,
account: Account,
) {
val newList =
PeopleListEvent.createListWithDescription(
dTag = UUID.randomUUID().toString(),
title = listName,
description = listDescription,
): String {
val dTag = UUID.randomUUID().toString()
val newListTemplate =
PeopleListEvent.build(
dTag = dTag,
name = listName,
publicMembers = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
privateMembers = if (isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
signer = account.signer,
)
) {
if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage)
}
val newList = signer.sign(newListTemplate)
account.sendMyPublicAndPrivateOutbox(newList)
return dTag
}
suspend fun renameFollowList(
newName: String,
suspend fun updateMetadata(
listName: String?,
listDescription: String?,
listImage: String?,
peopleList: PeopleList,
account: Account,
) {
val listEvent = getPeopleList(peopleList.identifierTag)
val newList =
PeopleListEvent.modifyListName(
earlierVersion = listEvent,
newName = newName,
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun modifyFollowSetDescription(
newDescription: String?,
peopleList: PeopleList,
account: Account,
) {
val listEvent = getPeopleList(peopleList.identifierTag)
val newList =
PeopleListEvent.modifyDescription(
earlierVersion = listEvent,
newDescription = newDescription,
signer = account.signer,
)
val template =
listEvent.update {
if (listName != null) name(listName)
if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage)
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
@@ -74,7 +74,7 @@ class BlossomServerListState(
return if (serverList != null && serverList.tags.isNotEmpty()) {
BlossomServersEvent.updateRelayList(
earlierVersion = serverList,
relays = servers,
servers = servers,
signer = signer,
)
} else {
@@ -24,10 +24,13 @@ import com.vitorpamplona.amethyst.model.ALL_FOLLOWS
import com.vitorpamplona.amethyst.model.ALL_USER_FOLLOWS
import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFollowsFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
@@ -42,6 +45,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
@@ -49,6 +53,7 @@ import kotlinx.coroutines.flow.transformLatest
class FeedTopNavFilterState(
val feedFilterListName: MutableStateFlow<String>,
val kind3Follows: StateFlow<Kind3FollowListState.Kind3Follows>,
val allFollows: StateFlow<MergedFollowListsState.AllFollows>,
val locationFlow: StateFlow<LocationState.LocationResult>,
val followsRelays: StateFlow<Set<NormalizedRelayUrl>>,
@@ -63,6 +68,7 @@ class FeedTopNavFilterState(
GLOBAL_FOLLOWS -> GlobalFeedFlow(followsRelays, proxyRelays)
ALL_FOLLOWS -> AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
ALL_USER_FOLLOWS -> AllUserFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
KIND3_FOLLOWS -> Kind3UserFollowsFeedFlow(kind3Follows, followsRelays, blockedRelays, proxyRelays)
AROUND_ME -> AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays)
else -> {
val note = LocalCache.checkGetOrCreateAddressableNote(listName)
@@ -0,0 +1,69 @@
/**
* 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.model.topNavFeeds.allUserFollows
import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
class Kind3UserFollowsFeedFlow(
val allFollows: StateFlow<Kind3FollowListState.Kind3Follows>,
val followsRelays: StateFlow<Set<NormalizedRelayUrl>>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
fun convert(
allFollows: Kind3FollowListState.Kind3Follows?,
proxyRelays: Set<NormalizedRelayUrl>,
): IFeedTopNavFilter =
if (allFollows != null) {
if (proxyRelays.isEmpty()) {
AllUserFollowsByOutboxTopNavFilter(
authors = allFollows.authors,
defaultRelays = followsRelays,
blockedRelays = blockedRelays,
)
} else {
AllUserFollowsByProxyTopNavFilter(
authors = allFollows.authors,
proxyRelays = proxyRelays,
)
}
} else {
AllUserFollowsByOutboxTopNavFilter(
authors = emptySet(),
defaultRelays = followsRelays,
blockedRelays = blockedRelays,
)
}
override fun flow() = combine(allFollows, proxyRelays, ::convert)
override fun startValue(): IFeedTopNavFilter = convert(allFollows.value, proxyRelays.value)
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(startValue())
}
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.asTextOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@@ -127,7 +128,7 @@ class MeltProcessor {
val msg =
tree
?.get("detail")
?.asText()
?.asTextOrNull()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
@@ -203,7 +204,7 @@ class MeltProcessor {
val msg =
tree
?.get("detail")
?.asText()
?.asTextOrNull()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
@@ -21,11 +21,14 @@
package com.vitorpamplona.amethyst.service.relayClient
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.EventCollector
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayInsertConfirmationCollector
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
class CacheClientConnector(
val client: INostrClient,
@@ -50,5 +53,39 @@ class CacheClientConnector(
private fun markAsSeen(
eventId: HexKey,
info: NormalizedRelayUrl,
) = LocalCache.getNoteIfExists(eventId)?.addRelay(info)
) {
val note = LocalCache.getNoteIfExists(eventId)
if (note != null) {
note.addRelay(info)
markAsSeenInner(note, info)
}
}
private fun markAsSeenInner(
note: Note,
info: NormalizedRelayUrl,
) {
val noteEvent = note.event
if (noteEvent is GiftWrapEvent) {
val innerEvent = noteEvent.innerEventId
if (innerEvent != null) {
val innerNote = cache.getNoteIfExists(innerEvent)
if (innerNote != null) {
innerNote.addRelay(info)
markAsSeenInner(innerNote, info)
}
}
}
if (noteEvent is SealedRumorEvent) {
val innerEvent = noteEvent.innerEventId
if (innerEvent != null) {
val innerNote = cache.getNoteIfExists(innerEvent)
if (innerNote != null) {
innerNote.addRelay(info)
markAsSeenInner(innerNote, info)
}
}
}
}
}
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dataso
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
@@ -77,6 +78,7 @@ class RelaySubscriptionsCoordinator(
val profile = UserProfileFilterAssembler(client)
val hashtags = HashtagFilterAssembler(client)
val geohashes = GeoHashFilterAssembler(client)
val followPacks = FollowPackFeedFilterAssembler(client)
// active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client)
@@ -107,7 +107,7 @@ fun <T> observeNoteAndMap(
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("UNCHECKED_CAST")
@Composable
fun <T, U> observeNoteEventAndMap(
fun <T, U> observeNoteEventAndMapNotNull(
note: Note,
accountViewModel: AccountViewModel,
map: (T) -> U,
@@ -130,6 +130,32 @@ fun <T, U> observeNoteEventAndMap(
return flow.collectAsStateWithLifecycle((note.event as? T)?.let { map(it) })
}
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("UNCHECKED_CAST")
@Composable
fun <T, U> observeNoteEventAndMap(
note: Note,
accountViewModel: AccountViewModel,
map: (T?) -> U,
): State<U> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { map(it.note.event as? T) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(map(note.event as? T))
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeNoteHasEvent(
@@ -110,7 +110,7 @@ fun EditPostView(
nav: INav,
) {
val postViewModel: EditPostViewModel = viewModel()
postViewModel.prepare(edit, versionLookingAt, accountViewModel)
postViewModel.init(accountViewModel)
val context = LocalContext.current
@@ -118,7 +118,7 @@ fun EditPostView(
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
postViewModel.load(edit, versionLookingAt, accountViewModel)
postViewModel.load(edit, versionLookingAt)
}
Dialog(
@@ -61,13 +61,12 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Stable
open class EditPostViewModel : ViewModel() {
var accountViewModel: AccountViewModel? = null
var account: Account? = null
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
var editedFromNote: Note? = null
@@ -94,46 +93,32 @@ open class EditPostViewModel : ViewModel() {
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
open fun prepare(
edit: Note,
versionLookingAt: Note?,
accountViewModel: AccountViewModel,
) {
open fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
this.editedFromNote = edit
this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountViewModel.account)
}
open fun load(
edit: Note,
versionLookingAt: Note?,
accountViewModel: AccountViewModel,
) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
multiOrchestrator = null
message = TextFieldValue(versionLookingAt?.event?.content ?: edit.event?.content ?: "")
urlPreview = findUrlInMessage()
editedFromNote = edit
this.editedFromNote = edit
this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountViewModel.account)
}
fun sendPost() {
viewModelScope.launch(Dispatchers.IO) { innerSendPost() }
accountViewModel.launchSigner(::innerSendPost)
}
suspend fun innerSendPost() {
if (accountViewModel == null) {
cancel()
return
}
val extraNotesToBroadcast = mutableListOf<Event>()
nip95attachments.forEach {
@@ -142,14 +127,14 @@ open class EditPostViewModel : ViewModel() {
}
val notify =
if (editedFromNote?.author?.pubkeyHex == account?.userProfile()?.pubkeyHex) {
if (editedFromNote?.author?.pubkeyHex == account.userProfile().pubkeyHex) {
null
} else {
// notifies if it is not the logged in user
editedFromNote?.author?.pubkeyHex
}
account?.sendEdit(
account.sendEdit(
message = message.text,
originalNote = editedFromNote!!,
notify = notify,
@@ -191,7 +176,7 @@ open class EditPostViewModel : ViewModel() {
context: Context,
) {
viewModelScope.launch {
val myAccount = account ?: return@launch
val myAccount = account
val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true
@@ -218,7 +203,7 @@ open class EditPostViewModel : ViewModel() {
contentWarningReason = if (sensitiveContent) "" else null,
)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
@@ -102,7 +102,7 @@ class NewUserMetadataViewModel : ViewModel() {
fun create() {
// Tries to not delete any existing attribute that we do not work with.
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
val metadata =
account.userMetadata.sendNewUserMetadata(
name = displayName.value,
@@ -145,7 +145,7 @@ class NewUserMetadataViewModel : ViewModel() {
context: Context,
onError: (String, String) -> Unit,
) {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
upload(
uri,
context,
@@ -161,7 +161,7 @@ class NewUserMetadataViewModel : ViewModel() {
context: Context,
onError: (String, String) -> Unit,
) {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
upload(
uri,
context,
@@ -52,9 +52,12 @@ fun AllMediaServersScreen(
val nip96ServersViewModel: NIP96ServersViewModel = viewModel()
val blossomServersViewModel: BlossomServersViewModel = viewModel()
LaunchedEffect(key1 = Unit) {
nip96ServersViewModel.load(accountViewModel.account)
blossomServersViewModel.load(accountViewModel.account)
nip96ServersViewModel.init(accountViewModel)
blossomServersViewModel.init(accountViewModel)
LaunchedEffect(key1 = accountViewModel) {
nip96ServersViewModel.load()
blossomServersViewModel.load()
}
MediaServersScaffold(nip96ServersViewModel, blossomServersViewModel) {
@@ -23,23 +23,28 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.Rfc3986
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BlossomServersViewModel : ViewModel() {
lateinit var account: Account
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
private val _fileServers = MutableStateFlow<List<ServerName>>(emptyList())
val fileServers = _fileServers.asStateFlow()
private var isModified = false
fun load(account: Account) {
this.account = account
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun load() {
refresh()
}
@@ -102,7 +107,7 @@ class BlossomServersViewModel : ViewModel() {
serverUrl: String,
) {
viewModelScope.launch {
val serverName = if (name.isNotBlank()) name else Rfc3986.host(serverUrl)
val serverName = name.ifBlank { Rfc3986.host(serverUrl) }
_fileServers.update {
it.minus(
ServerName(serverName, serverUrl, ServerType.Blossom),
@@ -119,7 +124,7 @@ class BlossomServersViewModel : ViewModel() {
fun saveFileServers() {
if (isModified) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.launchSigner {
val serverList = _fileServers.value.map { it.baseUrl }
account.sendBlossomServersList(serverList)
refresh()
@@ -127,5 +132,5 @@ class BlossomServersViewModel : ViewModel() {
}
}
private fun obtainFileServers(): List<String>? = account.blossomServers.flow.value
private fun obtainFileServers(): List<String> = account.blossomServers.flow.value
}
@@ -23,23 +23,28 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.Rfc3986
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class NIP96ServersViewModel : ViewModel() {
lateinit var account: Account
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
private val _fileServers = MutableStateFlow<List<ServerName>>(emptyList())
val fileServers = _fileServers.asStateFlow()
private var isModified = false
fun load(account: Account) {
this.account = account
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun load() {
refresh()
}
@@ -114,7 +119,7 @@ class NIP96ServersViewModel : ViewModel() {
fun saveFileServers() {
if (isModified) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.launchSigner {
val serverList = _fileServers.value.map { it.baseUrl }
account.sendFileServersList(serverList)
refresh()
@@ -112,7 +112,7 @@ fun LoadOrCreateNote(
if (note == null) {
LaunchedEffect(key1 = event.id) {
accountViewModel.checkGetOrCreateNote(event) { note = it }
note = accountViewModel.noteFromEvent(event)
}
}
@@ -254,7 +254,7 @@ fun DisplayUser(
if (userBase == null) {
LaunchedEffect(key1 = userHex) {
accountViewModel.checkGetOrCreateUser(userHex) { userBase = it }
userBase = accountViewModel.checkGetOrCreateUser(userHex)
}
}
@@ -192,11 +192,11 @@ fun RenderRegularPreview() {
"",
1,
route = Route.EventRedirect(word.segmentText),
nav = EmptyNav,
nav = EmptyNav(),
)
}
is HashTagSegment -> HashTag(word, EmptyNav)
is HashTagSegment -> HashTag(word, EmptyNav())
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
@@ -225,7 +225,7 @@ fun RenderRegularPreview2() {
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
// is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, EmptyNav)
is HashTagSegment -> HashTag(word, EmptyNav())
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
@@ -267,7 +267,7 @@ fun RenderRegularPreview3() {
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
// is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, EmptyNav)
is HashTagSegment -> HashTag(word, EmptyNav())
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
@@ -746,7 +746,7 @@ fun LoadNote(
if (note == null) {
LaunchedEffect(key1 = baseNoteHex) {
accountViewModel.checkGetOrCreateNote(baseNoteHex) { note = it }
note = accountViewModel.checkGetOrCreateNote(baseNoteHex)
}
}
@@ -122,7 +122,7 @@ fun RenderContentAsMarkdown(
fun RenderContentAsMarkdownPreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
@@ -174,7 +174,7 @@ fun RenderContentAsMarkdownPreview() {
fun RenderContentAsMarkdownListsPreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
@@ -224,7 +224,7 @@ fun RenderContentAsMarkdownListsPreview() {
fun RenderContentAsMarkdownCodePreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
@@ -278,7 +278,7 @@ fun RenderContentAsMarkdownCodePreview() {
fun RenderContentAsMarkdownTablesPreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
@@ -319,7 +319,7 @@ fun RenderContentAsMarkdownTablesPreview() {
fun RenderContentAsMarkdownFootNotesPreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
ThemeComparisonRow {
val background = MaterialTheme.colorScheme.background
@@ -356,7 +356,7 @@ fun RenderContentAsMarkdownFootNotesPreview() {
fun RenderContentAsMarkdownUserPreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
runBlocking {
withContext(Dispatchers.IO) {
@@ -418,7 +418,7 @@ fun RenderContentAsMarkdownUserPreview() {
fun RenderContentAsMarkdownNotePreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
runBlocking {
withContext(Dispatchers.IO) {
@@ -90,7 +90,7 @@ fun ErrorListPreview() {
ErrorList(
model = model,
accountViewModel = accountViewModel,
nav = EmptyNav,
nav = EmptyNav(),
)
}
}
@@ -49,7 +49,7 @@ import kotlinx.coroutines.withContext
@Preview
fun MultiUserErrorMessageContentPreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
var user1: User? = null
var user2: User? = null
@@ -73,15 +73,19 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.EditPeopleListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
@@ -127,8 +131,12 @@ fun AppNavigation(
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.PeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.PeopleListManagement> { EditPeopleListScreen(it.userToAdd, accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.PeopleListManagement> { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) }
composableFromBottomArgs<Route.PeopleListMetadataEdit> { PeopleListMetadataScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.FollowPackMetadataEdit> { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) }
composableFromEnd<Route.BookmarkGroups> { ListOfBookmarkGroupsScreen(accountViewModel, nav) }
@@ -152,6 +160,7 @@ 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.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
@@ -525,7 +525,7 @@ fun ListContent(
NavigationRow(
title = R.string.user_preferences,
icons = listOf(Icons.Outlined.Translate),
icon = Icons.Outlined.Translate,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.UserSettings,
@@ -608,27 +608,10 @@ fun NavigationRow(
tint: Color,
nav: INav,
route: Route,
) {
NavigationRow(
title = title,
icons = listOf(icon),
tint = tint,
nav = nav,
route = route,
)
}
@Composable
fun NavigationRow(
title: Int,
icons: List<ImageVector>,
tint: Color,
nav: INav,
route: Route,
) {
IconRow(
title = title,
icons = icons,
icon = icon,
tint = tint,
onClick = {
nav.closeDrawer()
@@ -678,21 +661,6 @@ fun IconRow(
icon: ImageVector,
tint: Color,
onClick: () -> Unit,
) {
IconRow(
title = title,
icons = listOf(icon),
tint = tint,
onClick = onClick,
)
}
@Composable
fun IconRow(
title: Int,
icons: List<ImageVector>,
tint: Color,
onClick: () -> Unit,
) {
Row(
modifier =
@@ -707,14 +675,12 @@ fun IconRow(
modifier = IconRowModifier,
verticalAlignment = Alignment.CenterVertically,
) {
icons.forEach { icon ->
Icon(
imageVector = icon,
contentDescription = stringRes(title),
modifier = Size22Modifier.padding(end = 4.dp),
tint = tint,
)
}
Icon(
imageVector = icon,
contentDescription = stringRes(title),
modifier = Size22Modifier.padding(end = 4.dp),
tint = tint,
)
Text(
modifier = IconRowTextModifier,
@@ -29,7 +29,7 @@ import kotlinx.coroutines.runBlocking
import kotlin.reflect.KClass
@Stable
object EmptyNav : INav {
class EmptyNav : INav {
override val navigationScope: CoroutineScope get() = TODO("Not yet implemented")
override val drawerState = DrawerState(DrawerValue.Closed)
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
@@ -65,73 +66,82 @@ fun routeFor(
fun routeFor(
noteEvent: Event,
loggedIn: Account,
): Route? {
): Route? =
if (noteEvent is DraftWrapEvent) {
val innerEvent = loggedIn.draftsDecryptionCache.preCachedDraft(noteEvent)
if (innerEvent is IsInPublicChatChannel) {
innerEvent.channelId()?.let {
return Route.PublicChatChannel(it)
}
} else if (innerEvent is LiveActivitiesEvent) {
innerEvent.address().let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (innerEvent is LiveActivitiesChatMessageEvent) {
innerEvent.activityAddress()?.let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (innerEvent is EphemeralChatEvent) {
innerEvent.roomId()?.let {
return Route.EphemeralChat(it.id, it.relayUrl.url)
}
} else if (innerEvent is ChatroomKeyable) {
val room = innerEvent.chatroomKey(loggedIn.userProfile().pubkeyHex)
loggedIn.chatroomList.getOrCreatePrivateChatroom(room)
return Route.Room(room)
} else if (innerEvent is AddressableEvent) {
return Route.Note(noteEvent.aTag().toTag())
if (innerEvent != null) {
routeForInner(innerEvent, loggedIn)
} else {
return Route.Note(noteEvent.id)
Route.Note(noteEvent.id)
}
} else if (noteEvent is AppDefinitionEvent) {
return Route.ContentDiscovery(noteEvent.id)
} else if (noteEvent is IsInPublicChatChannel) {
noteEvent.channelId()?.let {
return Route.PublicChatChannel(it)
}
} else if (noteEvent is ChannelCreateEvent) {
return Route.PublicChatChannel(noteEvent.id)
} else if (noteEvent is LiveActivitiesEvent) {
noteEvent.address().let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (noteEvent is LiveActivitiesChatMessageEvent) {
noteEvent.activityAddress()?.let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (noteEvent is ChatroomKeyable) {
val room = noteEvent.chatroomKey(loggedIn.userProfile().pubkeyHex)
loggedIn.chatroomList.getOrCreatePrivateChatroom(room)
return Route.Room(room)
} 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 {
return Route.Note(noteEvent.id)
routeForInner(noteEvent, loggedIn)
}
return null
}
fun routeForInner(
noteEvent: Event,
loggedIn: Account,
): Route? =
when (noteEvent) {
is AppDefinitionEvent -> {
Route.ContentDiscovery(noteEvent.id)
}
is IsInPublicChatChannel -> {
noteEvent.channelId()?.let {
Route.PublicChatChannel(it)
}
}
is ChannelCreateEvent -> {
Route.PublicChatChannel(noteEvent.id)
}
is LiveActivitiesEvent -> {
noteEvent.address().let {
Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
}
is LiveActivitiesChatMessageEvent -> {
noteEvent.activityAddress()?.let {
Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
}
is EphemeralChatEvent -> {
noteEvent.roomId()?.let {
Route.EphemeralChat(it.id, it.relayUrl.url)
}
}
is FollowListEvent -> {
Route.FollowPack(noteEvent.address())
}
is ChatroomKeyable -> {
val room = noteEvent.chatroomKey(loggedIn.userProfile().pubkeyHex)
loggedIn.chatroomList.getOrCreatePrivateChatroom(room)
Route.Room(room)
}
is CommunityDefinitionEvent -> {
Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
is GiftWrapEvent -> {
noteEvent.innerEventId?.let {
routeFor(LocalCache.getOrCreateNote(it), loggedIn)
}
}
is SealedRumorEvent -> {
noteEvent.innerEventId?.let {
routeFor(LocalCache.getOrCreateNote(it), loggedIn)
}
}
is AddressableEvent -> {
Route.Note(noteEvent.aTag().toTag())
}
else -> {
Route.Note(noteEvent.id)
}
}
fun routeToMessage(
user: HexKey,
@@ -207,6 +217,8 @@ fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.rela
fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex)
fun routeForUser(userHex: HexKey): Route.Profile = Route.Profile(userHex)
fun authorRouteFor(note: Note): Route.Profile? = note.author?.pubkeyHex?.let { Route.Profile(it) }
fun routeReplyTo(
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.navigation.routes
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.toRoute
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.serialization.Serializable
@@ -56,10 +57,22 @@ sealed class Route {
@Serializable object Lists : Route()
@Serializable data class PeopleListView(
@Serializable data class MyPeopleListView(
val dTag: String,
) : Route()
@Serializable data class MyFollowPackView(
val dTag: String,
) : Route()
@Serializable data class PeopleListMetadataEdit(
val dTag: String? = null,
) : Route()
@Serializable data class FollowPackMetadataEdit(
val dTag: String? = null,
) : Route()
@Serializable data class PeopleListManagement(
val userToAdd: HexKey,
) : Route()
@@ -98,7 +111,25 @@ sealed class Route {
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route()
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable data class FollowPack(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable data class PublicChatChannel(
val id: String,
@@ -273,6 +304,15 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
dest.hasRoute<Route.GenericCommentPost>() -> entry.toRoute<Route.GenericCommentPost>()
dest.hasRoute<Route.NewPublicMessage>() -> entry.toRoute<Route.NewPublicMessage>()
dest.hasRoute<Route.Lists>() -> entry.toRoute<Route.Lists>()
dest.hasRoute<Route.MyPeopleListView>() -> entry.toRoute<Route.MyPeopleListView>()
dest.hasRoute<Route.MyFollowPackView>() -> entry.toRoute<Route.MyFollowPackView>()
dest.hasRoute<Route.PeopleListMetadataEdit>() -> entry.toRoute<Route.PeopleListMetadataEdit>()
dest.hasRoute<Route.FollowPackMetadataEdit>() -> entry.toRoute<Route.FollowPackMetadataEdit>()
dest.hasRoute<Route.PeopleListManagement>() -> entry.toRoute<Route.PeopleListManagement>()
dest.hasRoute<Route.NewGroupDM>() -> entry.toRoute<Route.NewGroupDM>()
dest.hasRoute<Route.UserSettings>() -> entry.toRoute<Route.UserSettings>()
else -> {
null
}
@@ -95,16 +95,13 @@ fun BlankNote(
@Composable
@Preview
fun HiddenNotePreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
ThemeComparisonColumn(
toPreview = {
HiddenNote(
reports = persistentSetOf<Note>(),
isHiddenAuthor = true,
accountViewModel = accountViewModel,
nav = nav,
accountViewModel = mockAccountViewModel(),
nav = EmptyNav(),
) {}
},
)
@@ -38,8 +38,10 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalLayoutApi::class)
@@ -81,3 +83,45 @@ fun Gallery(
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun GalleryUnloaded(
users: ImmutableList<HexKey>,
modifier: Modifier,
accountViewModel: AccountViewModel,
nav: INav,
maxPictures: Int = 6,
) {
FlowRow(
modifier,
verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy((-5).dp),
) {
users.take(maxPictures).forEach {
ClickableUserPicture(
it,
Size25dp,
accountViewModel,
onClick = {
nav.nav {
routeForUser(it)
}
},
)
}
if (users.size > maxPictures) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(Size25dp).clip(shape = CircleShape).background(MaterialTheme.colorScheme.secondaryContainer),
) {
Text(
text = "+" + showCount(users.size - maxPictures),
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
@@ -708,7 +708,7 @@ private fun RenderNoteRow(
is BadgeAwardEvent -> RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav)
is FhirResourceEvent -> RenderFhirResource(baseNote, accountViewModel, nav)
is PeopleListEvent -> DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
is FollowListEvent -> DisplayFollowList(baseNote, backgroundColor, accountViewModel, nav)
is FollowListEvent -> DisplayFollowList(baseNote, true, accountViewModel, nav)
is RelaySetEvent -> DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav)
is ChatMessageRelayListEvent -> DisplayDMRelayList(baseNote, backgroundColor, accountViewModel, nav)
is AdvertisedRelayListEvent -> DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav)
@@ -151,7 +151,7 @@ fun PollNotePreview() {
)
val accountViewModel = mockVitorAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
val baseNote: Note?
runBlocking {
@@ -205,7 +205,7 @@ fun PollNotePreview2() {
)
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
val baseNote: Note?
runBlocking {
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size17Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.noteComposeRelayBox
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.flow.mapNotNull
@Composable
fun RelayBadges(
@@ -122,12 +123,14 @@ fun WatchAndRenderRelay(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteRelays by baseNote
val relay by baseNote
.flow()
.relays.stateFlow
.collectAsStateWithLifecycle()
.mapNotNull {
it.note.relays.getOrNull(relayIndex)
}.collectAsStateWithLifecycle(baseNote.relays.getOrNull(relayIndex))
CrossfadeIfEnabled(targetState = noteRelays.note.relays.getOrNull(relayIndex), label = "RenderRelay", modifier = Size17Modifier, accountViewModel = accountViewModel) {
CrossfadeIfEnabled(targetState = relay, label = "RenderRelay", modifier = Size17Modifier, accountViewModel = accountViewModel) {
if (it != null) {
RenderRelay(it, accountViewModel, nav)
}
@@ -77,7 +77,7 @@ import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMapNotNull
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
@@ -370,7 +370,7 @@ private fun EmojiSelector(
accountViewModel,
) { emptyNote ->
emptyNote?.let { usersEmojiList ->
val collections by observeNoteEventAndMap(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent ->
val collections by observeNoteEventAndMapNotNull(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent ->
event.emojiPacks().toImmutableList()
}
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
@Composable
@@ -175,6 +176,40 @@ fun ClickableUserPicture(
BaseUserPicture(baseUser, size, accountViewModel, modifier, myModifier)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClickableUserPicture(
baseUserHex: HexKey,
size: Dp,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
onClick: ((HexKey) -> Unit)? = null,
onLongClick: ((HexKey) -> Unit)? = null,
) {
// BaseUser is the same reference as accountState.user
val myModifier =
remember(baseUserHex) {
if (onClick != null && onLongClick != null) {
Modifier
.size(size)
.combinedClickable(
onClick = { onClick(baseUserHex) },
onLongClick = { onLongClick(baseUserHex) },
)
} else if (onClick != null) {
Modifier
.size(size)
.clickable(
onClick = { onClick(baseUserHex) },
)
} else {
Modifier.size(size)
}
}
BaseUserPicture(baseUserHex, size, accountViewModel, modifier, myModifier)
}
@Composable
fun NonClickableUserPictures(
room: ChatroomKey,
@@ -322,6 +357,34 @@ fun BaseUserPicture(
}
}
@Composable
fun BaseUserPicture(
baseUserHex: HexKey,
size: Dp,
accountViewModel: AccountViewModel,
innerModifier: Modifier = Modifier,
outerModifier: Modifier = Modifier.size(size),
) {
Box(outerModifier, contentAlignment = Alignment.TopEnd) {
LoadUserProfilePicture(baseUserHex, accountViewModel) { userProfilePicture, userName ->
InnerUserPicture(
userHex = baseUserHex,
userPicture = userProfilePicture,
userName = userName,
size = size,
modifier = innerModifier,
accountViewModel = accountViewModel,
)
}
WatchUserFollows(baseUserHex, accountViewModel) { newFollowingState ->
if (newFollowingState) {
FollowingIcon(Modifier.size(size.div(3.5f)))
}
}
}
}
@Composable
fun LoadUserProfilePicture(
baseUser: User,
@@ -333,6 +396,23 @@ fun LoadUserProfilePicture(
innerContent(userProfile?.profilePicture(), userProfile?.bestName())
}
@Composable
fun LoadUserProfilePicture(
baseUserHex: HexKey,
accountViewModel: AccountViewModel,
innerContent: @Composable (String?, String?) -> Unit,
) {
LoadUser(baseUserHex, accountViewModel) {
if (it != null) {
val userProfile by observeUserInfo(it, accountViewModel)
innerContent(userProfile?.profilePicture(), userProfile?.bestName())
} else {
innerContent(null, null)
}
}
}
@Composable
fun InnerUserPicture(
userHex: String,
@@ -46,7 +46,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -104,7 +103,7 @@ fun RenderZapNotePreview() {
user1,
note1,
accountViewModel,
EmptyNav,
EmptyNav(),
)
}
}
@@ -122,7 +121,7 @@ fun RenderZapNoteSlimPreview() {
user1,
note1,
accountViewModel,
EmptyNav,
EmptyNav(),
)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.draftTags
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.update
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Stable
class DraftTagState {
var current: String by mutableStateOf(newTag())
var usedDraftTags by mutableStateOf(setOf<String>(current))
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMapNotNull
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
@@ -37,7 +37,7 @@ fun WatchAndLoadMyEmojiList(accountViewModel: AccountViewModel) {
accountViewModel,
) { emptyNote ->
emptyNote?.let { usersEmojiList ->
val collections by observeNoteEventAndMap(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent ->
val collections by observeNoteEventAndMapNotNull(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent ->
event.taggedAddresses().toImmutableList()
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.logTime
@@ -37,6 +38,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
@Stable
class UserSuggestionState(
val account: Account,
val requireAtSymbol: Boolean = true,
@@ -60,7 +60,7 @@ fun AddInboxRelayForDMCardPreview() {
ThemeComparisonColumn {
AddInboxRelayForDMCard(
accountViewModel = mockAccountViewModel(),
nav = EmptyNav,
nav = EmptyNav(),
)
}
}
@@ -138,7 +138,7 @@ fun GenericCommentPostScreen(
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
@@ -152,7 +152,7 @@ fun GenericCommentPostScreen(
onCancel = {
// uses the accountViewModel scope to avoid cancelling this
// function when the postViewModel is released
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
@@ -161,7 +161,7 @@ fun GenericCommentPostScreen(
onPost = {
// uses the accountViewModel scope to avoid cancelling this
// function when the postViewModel is released
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
postViewModel.sendPostSync()
nav.popBack()
}
@@ -21,139 +21,214 @@
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
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.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.GalleryUnloaded
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.FollowSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FollowSetImageModifier
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.blackTagModifier
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DisplayFollowList(
baseNote: Note,
backgroundColor: MutableState<Color>,
makeItShort: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? FollowListEvent ?: return
var members by remember { mutableStateOf<ImmutableList<User>>(persistentListOf()) }
var expanded by remember { mutableStateOf(false) }
val toMembersShow =
if (expanded) {
members
} else {
members.take(3)
}
val image = noteEvent.image()
image?.let {
MyAsyncImage(
imageUrl = it,
contentDescription =
stringRes(
R.string.preview_card_image_for,
it,
),
contentScale = ContentScale.Crop,
mainImageModifier = Modifier.fillMaxWidth(),
loadedImageModifier = FollowSetImageModifier,
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) },
onError = { DefaultImageHeader(baseNote, accountViewModel) },
)
} ?: run {
DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier)
}
Text(
text = noteEvent.title() ?: noteEvent.dTag(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 10.dp),
textAlign = TextAlign.Center,
)
LaunchedEffect(Unit) {
accountViewModel.loadUsers(noteEvent.taggedUserIds()) {
members = it
}
}
Box {
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
toMembersShow.forEach { user ->
Column(modifier = Modifier.fillMaxWidth()) {
UserCompose(
user,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(
thickness = DividerThickness,
)
}
val card =
observeNoteEventAndMap(baseNote, accountViewModel) { event: FollowListEvent? ->
if (event == null) {
FollowSetCard(
name = "",
media = "",
description = "",
users = persistentListOf(),
)
} else {
FollowSetCard(
name = event.title()?.ifBlank { null } ?: event.dTag(),
media = event.image()?.ifBlank { null },
description = event.description(),
users = accountViewModel.sortUsersSync(event.followIds()).toImmutableList(),
)
}
}
if (members.size > 3 && !expanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(getGradient(backgroundColor)),
) {
ShowMoreButton { expanded = !expanded }
RenderFollowSetThumbEmbed(
card.value,
baseNote,
makeItShort,
accountViewModel,
nav,
)
}
@Composable
fun RenderFollowSetThumbEmbed(
card: FollowSetCard,
baseNote: Note,
makeItShort: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(
modifier =
Modifier.fillMaxWidth().clickable {
nav.nav { routeFor(baseNote, accountViewModel.account) }
},
verticalArrangement = SpacedBy5dp,
) {
Box(
contentAlignment = Alignment.BottomStart,
) {
card.media?.let {
MyAsyncImage(
imageUrl = it,
contentDescription = stringRes(R.string.preview_card_image_for, it),
contentScale = ContentScale.Crop,
mainImageModifier = Modifier,
loadedImageModifier = FollowSetImageModifier,
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) },
onError = { DefaultImageHeader(baseNote, accountViewModel) },
)
} ?: run { DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier) }
GalleryUnloaded(card.users, StdPadding, accountViewModel, nav)
}
Row(
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = card.name,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringRes(R.string.follow_list_item_label),
color = MaterialTheme.colorScheme.background,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = MaterialTheme.colorScheme.blackTagModifier,
)
}
if (!makeItShort) {
card.description?.let {
val defaultBackground = MaterialTheme.colorScheme.background
val background = remember { mutableStateOf(defaultBackground) }
TranslatableRichTextViewer(
content = it,
canPreview = true,
quotesLeft = 2,
modifier = Modifier.fillMaxWidth(),
tags = baseNote.event?.tags?.toImmutableListOfLists() ?: EmptyTagList,
backgroundColor = background,
id = it,
callbackUri = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Preview
@Composable
fun RenderFollowSetThumbPreview() {
val accountViewModel = mockAccountViewModel()
val followCard =
FollowListEvent(
id = "eca31634fce7c9068b56fa8db9f387da70bdcceb3986a77ca1a9844f3128eb5f",
pubKey = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da",
createdAt = 1761736286,
tags =
arrayOf(
arrayOf("title", "Retro Computer Fans"),
arrayOf("d", "xmbspe8rddsq"),
arrayOf("image", "https://blog.johnnovak.net/2022/04/15/achieving-period-correct-graphics-in-personal-computer-emulators-part-1-the-amiga/img/dream-setup.jpg"),
arrayOf("p", "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da"),
arrayOf("p", "9a9a4aa0e43e57873380ab22e8a3df12f3c4cf5bb3a804c6e3fed0069a6e2740"),
arrayOf("p", "4f5dd82517b11088ce00f23d99f06fe8f3e2e45ecf47bc9c2f90f34d5c6f7382"),
arrayOf("p", "ac92102a2ecb873c488e0125354ef5a97075a16198668c360eda050007ed42cd"),
arrayOf("p", "47f54409a4620eb35208a3bc1b53555bf3d0656b246bf0471a93208e20672f6f"),
arrayOf("p", "2624911545afb7a2b440cf10f5c69308afa33aae26fca664d8c94623dc0f1baf"),
arrayOf("p", "6641f26f5c59f7010dbe3e42e4593398e27c087497cb7d20e0e7633a17e48a94"),
arrayOf("description", "Retro computer fans and enthusiasts "),
),
content = "",
sig = "3aa388edafad151e81cb0228fe04e115dbbcaa851c666bfe3c8740b6cd99575f0fc3ba2d47acda86f7626564a05e9dbc05ef452a7bd0ac00f828dbad0e1bae6c",
)
LocalCache.justConsume(followCard, null, false)
val card =
FollowSetCard(
name = followCard.title()?.ifBlank { null } ?: followCard.dTag(),
media = followCard.image()?.ifBlank { null },
description = followCard.description()?.ifBlank { null },
users = followCard.followIds().toImmutableList(),
)
ThemeComparisonColumn {
RenderFollowSetThumbEmbed(
card = card,
baseNote = LocalCache.getOrCreateNote(followCard.id),
makeItShort = false,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.types
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
@@ -36,6 +37,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -48,9 +50,12 @@ import com.vitorpamplona.amethyst.ui.components.DisplayEvent
import com.vitorpamplona.amethyst.ui.components.RenderUserAsClickableText
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor
@@ -61,6 +66,7 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
import java.util.UUID
@Composable
fun RenderHighlight(
@@ -78,7 +84,7 @@ fun RenderHighlight(
comment = noteEvent.comment(),
highlight = noteEvent.quote(),
context = noteEvent.context(),
authorHex = noteEvent.pubKey,
authorHex = noteEvent.author(),
url = noteEvent.inUrl(),
postAddress = noteEvent.inPostAddress(),
postVersion = noteEvent.inPostVersion(),
@@ -91,6 +97,54 @@ fun RenderHighlight(
)
}
@Preview
@Composable
fun DisplayHighlightPreview() {
ThemeComparisonColumn {
Column {
DisplayHighlight(
comment = null,
highlight = "new architectures of freedom",
context = "He never wrote a line of cryptographic code and never lectured on Austrian economics. Yet the cultural terrain he helped seed, particularly the psychedelic, post-industrial counterculture of the 1960s and 70s, became the moral and metaphysical groundwork from which new architectures of freedom would later emerge.",
authorHex = "eaa06714ac905aa5583860391e161edc7a815359b7c3e9b9b202c0558aefbeac",
url = null,
postAddress = Address(30023, "eaa06714ac905aa5583860391e161edc7a815359b7c3e9b9b202c0558aefbeac", "bitcoin-here-now"),
postVersion = null,
makeItShort = false,
canPreview = true,
quotesLeft = 3,
backgroundColor = mutableStateOf(Color.White),
accountViewModel = mockAccountViewModel(),
nav = EmptyNav(),
)
}
}
}
@Preview
@Composable
fun DisplayHighlightPreviewNewLine() {
ThemeComparisonColumn {
Column {
DisplayHighlight(
comment = null,
highlight = "He never wrote a line of cryptographic code and never lectured on Austrian economics.\nYet the cultural terrain he helped seed, particularly the psychedelic",
context = "He never wrote a line of cryptographic code and never lectured on Austrian economics.\nYet the cultural terrain he helped seed, particularly the psychedelic, post-industrial counterculture of the 1960s and 70s, became the moral and metaphysical groundwork from which new architectures of freedom would later emerge.",
authorHex = "eaa06714ac905aa5583860391e161edc7a815359b7c3e9b9b202c0558aefbeac",
url = null,
postAddress = Address(30023, "eaa06714ac905aa5583860391e161edc7a815359b7c3e9b9b202c0558aefbeac", "bitcoin-here-now"),
postVersion = null,
makeItShort = false,
canPreview = true,
quotesLeft = 3,
backgroundColor = mutableStateOf(Color.White),
accountViewModel = mockAccountViewModel(),
nav = EmptyNav(),
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DisplayHighlight(
@@ -124,8 +178,24 @@ fun DisplayHighlight(
}
val quote =
remember {
highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" }
remember(highlight) {
val uuid = UUID.randomUUID().toString()
if (context != null) {
if (context.contains(highlight)) {
val cleanContext = context.replace(highlight, uuid)
val quotedContext = cleanContext.split("\n").joinToString("\n") { "> ${it.removeSuffix(" ")}" }
val quotedSplit = highlight.split("\n")
val quotedHighlight = quotedSplit.joinToString("\n >") { "**${it.removeSuffix(" ")}**" }
quotedContext.replace(uuid, quotedHighlight)
} else {
highlight.split("\n").joinToString("\n") { "> ${it.removeSuffix(" ")}" }
}
} else {
highlight.split("\n").joinToString("\n") { "> ${it.removeSuffix(" ")}" }
}
}
TranslatableRichTextViewer(
@@ -173,9 +243,7 @@ private fun DisplayQuoteAuthor(
if (userBase == null && authorHex != null) {
LaunchedEffect(authorHex) {
accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase ->
userBase = newUserBase
}
userBase = accountViewModel.checkGetOrCreateUser(authorHex)
}
}
@@ -89,7 +89,7 @@ import java.util.Locale
fun RenderLiveActivityEventPreview() {
val event = Event.fromJson("{\"id\":\"19406ad34ce3c653d62eb73c1816ac27dcf473c2ccdccf5af7d90d2633c62561\",\"pubkey\":\"6b66886b3add72c779d205be574ec2d7cec619061ac3e75717389b26445989e4\",\"created_at\":1719084750,\"kind\":30311,\"tags\":[[\"r\",\"podcast:guid:72d5e069-f907-5ee7-b0d7-45404f4f0aa5\"],[\"r\",\"podcast:item:guid:bfc33d6e-e00f-4f11-a2ff-94865b7867aa\"],[\"d\",\"bfc33d6e-e00f-4f11-a2ff-94865b7867aa\"],[\"title\",\"The Online Identity Time Bomb\"],[\"summary\",\"Online identity is a ticking time bomb. But are trustworthy, open-source solutions ready to disarm it, or will we be stuck with lackluster, proprietary systems?\\n Live chat: https:/jblive.tv\"],[\"streaming\",\"https://jblive.fm\"],[\"starts\",\"1719167400\"],[\"status\",\"planned\"],[\"image\",\"https://station.us-iad-1.linodeobjects.com/art/lup-mp3.jpg\"]],\"content\":\"\",\"sig\":\"2ce3fae9ad4512541aaae4dbd9484f50df62ab95ba935d7512b736f087f151c1d15ec2bf62d3135474f16c3e070f335b24349c5493461873ddca3051804ca944\"}") as LiveActivitiesEvent
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
val baseNote: Note?
runBlocking {
@@ -38,6 +38,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -71,7 +72,7 @@ fun DisplayPeopleList(
var members by remember { mutableStateOf<ImmutableList<User>>(persistentListOf()) }
var expanded by remember { mutableStateOf(false) }
var expanded by rememberSaveable { mutableStateOf(false) }
val toMembersShow =
if (expanded) {
@@ -76,7 +76,7 @@ import kotlin.coroutines.cancellation.CancellationException
@Composable
fun TorrentPreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
val torrent =
runBlocking {
@@ -69,7 +69,7 @@ import kotlinx.coroutines.withContext
@Composable
fun TorrentCommentPreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
val comment =
runBlocking {
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.stringRes
@@ -72,7 +73,7 @@ class TopNavFilterState(
val account: Account,
val scope: CoroutineScope,
) {
val kind3Follow =
val allFollows =
PeopleListOutBoxFeedDefinition(
code = ALL_FOLLOWS,
name = ResourceName(R.string.follow_list_kind3follows),
@@ -81,7 +82,7 @@ class TopNavFilterState(
unpackList = listOf(ContactListEvent.blockListFor(account.signer.pubKey)),
)
val kind3FollowUsers =
val userFollows =
PeopleListOutBoxFeedDefinition(
code = ALL_USER_FOLLOWS,
name = ResourceName(R.string.follow_list_kind3follows_users_only),
@@ -90,6 +91,15 @@ class TopNavFilterState(
unpackList = listOf(ContactListEvent.blockListFor(account.signer.pubKey)),
)
val kind3Follows =
PeopleListOutBoxFeedDefinition(
code = KIND3_FOLLOWS,
name = ResourceName(R.string.follow_list_kind3_follows_users_only),
type = CodeNameType.HARDCODED,
kinds = DEFAULT_FEED_KINDS,
unpackList = listOf(ContactListEvent.blockListFor(account.signer.pubKey)),
)
val globalFollow =
GlobalFeedDefinition(
code = GLOBAL_FOLLOWS,
@@ -115,7 +125,7 @@ class TopNavFilterState(
unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)),
)
val defaultLists = persistentListOf(kind3Follow, kind3FollowUsers, aroundMe, globalFollow, muteListFollow)
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
fun mergePeopleLists(
peopleLists: List<AddressableNote>,
@@ -229,7 +239,7 @@ class TopNavFilterState(
checkNotInMainThread()
emit(
listOf(
listOf(kind3Follow, kind3FollowUsers, aroundMe, globalFollow),
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow),
myLivePeopleListsFlow,
myLiveKind3FollowsFlow,
listOf(muteListFollow),
@@ -245,7 +255,7 @@ class TopNavFilterState(
checkNotInMainThread()
emit(
listOf(
listOf(kind3Follow, kind3FollowUsers, aroundMe, globalFollow),
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow),
myLivePeopleListsFlow,
listOf(muteListFollow),
).flatten().toImmutableList(),
@@ -21,12 +21,14 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
@@ -85,6 +87,7 @@ private fun FeedLoaded(
val listState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
state = listState,
) {
@@ -313,7 +313,7 @@ class AccountViewModel(
note: Note,
reaction: String,
) {
runIOCatching {
launchSigner {
val currentReactions = note.allReactionsOfContentByAuthor(userProfile(), reaction)
if (currentReactions.isNotEmpty()) {
account.delete(currentReactions)
@@ -679,7 +679,7 @@ class AccountViewModel(
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
zapType: LnZapEvent.ZapType? = null,
) = runIOCatching {
) = launchSigner {
ZapPaymentHandler(account).zap(
note = note,
amountMilliSats = amountInMillisats,
@@ -699,23 +699,23 @@ class AccountViewModel(
note: Note,
type: ReportType,
content: String = "",
) = runIOCatching { account.report(note, type, content) }
) = launchSigner { account.report(note, type, content) }
fun report(
user: User,
type: ReportType,
) {
runIOCatching {
launchSigner {
account.report(user, type)
account.hideUser(user.pubkeyHex)
}
}
fun boost(note: Note) = runIOCatching { account.boost(note) }
fun boost(note: Note) = launchSigner { account.boost(note) }
fun removeEmojiPack(emojiPack: Note) = runIOCatching { account.removeEmojiPack(emojiPack) }
fun removeEmojiPack(emojiPack: Note) = launchSigner { account.removeEmojiPack(emojiPack) }
fun addEmojiPack(emojiPack: Note) = runIOCatching { account.addEmojiPack(emojiPack) }
fun addEmojiPack(emojiPack: Note) = launchSigner { account.addEmojiPack(emojiPack) }
fun addMediaToGallery(
hex: String,
@@ -725,40 +725,40 @@ class AccountViewModel(
dim: DimensionTag?,
hash: String?,
mimeType: String?,
) = runIOCatching { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) }
) = launchSigner { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) }
fun removeFromMediaGallery(note: Note) = runIOCatching { account.removeFromGallery(note) }
fun removeFromMediaGallery(note: Note) = launchSigner { account.removeFromGallery(note) }
fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex))
fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex))
fun addPrivateBookmark(note: Note) = runIOCatching { account.addBookmark(note, true) }
fun addPrivateBookmark(note: Note) = launchSigner { account.addBookmark(note, true) }
fun addPublicBookmark(note: Note) = runIOCatching { account.addBookmark(note, false) }
fun addPublicBookmark(note: Note) = launchSigner { account.addBookmark(note, false) }
fun removePrivateBookmark(note: Note) = runIOCatching { account.removeBookmark(note, true) }
fun removePrivateBookmark(note: Note) = launchSigner { account.removeBookmark(note, true) }
fun removePublicBookmark(note: Note) = runIOCatching { account.removeBookmark(note, false) }
fun removePublicBookmark(note: Note) = launchSigner { account.removeBookmark(note, false) }
fun broadcast(note: Note) = runIOCatching { account.broadcast(note) }
fun broadcast(note: Note) = launchSigner { account.broadcast(note) }
fun timestamp(note: Note) = runIOCatching { account.otsState.timestamp(note) }
fun timestamp(note: Note) = launchSigner { account.otsState.timestamp(note) }
fun delete(notes: List<Note>) = runIOCatching { account.delete(notes) }
fun delete(notes: List<Note>) = launchSigner { account.delete(notes) }
fun delete(note: Note) = runIOCatching { account.delete(note) }
fun delete(note: Note) = launchSigner { account.delete(note) }
fun cachedDecrypt(note: Note): String? = account.cachedDecryptContent(note)
fun decrypt(
note: Note,
onReady: (String) -> Unit,
) = runIOCatching {
) = launchSigner {
account.decryptContent(note)?.let { onReady(it) }
}
inline fun runIOCatching(crossinline action: suspend () -> Unit) {
inline fun launchSigner(crossinline action: suspend () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
try {
action()
@@ -796,35 +796,35 @@ class AccountViewModel(
fun approveCommunityPost(
post: Note,
community: AddressableNote,
) = runIOCatching { account.approveCommunityPost(post, community) }
) = launchSigner { account.approveCommunityPost(post, community) }
fun follow(community: AddressableNote) = runIOCatching { account.follow(community) }
fun follow(community: AddressableNote) = launchSigner { account.follow(community) }
fun follow(channel: PublicChatChannel) = runIOCatching { account.follow(channel) }
fun follow(channel: PublicChatChannel) = launchSigner { account.follow(channel) }
fun follow(channel: EphemeralChatChannel) = runIOCatching { account.follow(channel) }
fun follow(channel: EphemeralChatChannel) = launchSigner { account.follow(channel) }
fun unfollow(community: AddressableNote) = runIOCatching { account.unfollow(community) }
fun unfollow(community: AddressableNote) = launchSigner { account.unfollow(community) }
fun unfollow(channel: PublicChatChannel) = runIOCatching { account.unfollow(channel) }
fun unfollow(channel: PublicChatChannel) = launchSigner { account.unfollow(channel) }
fun unfollow(channel: EphemeralChatChannel) = runIOCatching { account.unfollow(channel) }
fun unfollow(channel: EphemeralChatChannel) = launchSigner { account.unfollow(channel) }
fun follow(user: User) = runIOCatching { account.follow(user) }
fun follow(user: User) = launchSigner { account.follow(user) }
fun unfollow(user: User) = runIOCatching { account.unfollow(user) }
fun unfollow(user: User) = launchSigner { account.unfollow(user) }
fun followGeohash(tag: String) = runIOCatching { account.followGeohash(tag) }
fun followGeohash(tag: String) = launchSigner { account.followGeohash(tag) }
fun unfollowGeohash(tag: String) = runIOCatching { account.unfollowGeohash(tag) }
fun unfollowGeohash(tag: String) = launchSigner { account.unfollowGeohash(tag) }
fun followHashtag(tag: String) = runIOCatching { account.followHashtag(tag) }
fun followHashtag(tag: String) = launchSigner { account.followHashtag(tag) }
fun unfollowHashtag(tag: String) = runIOCatching { account.unfollowHashtag(tag) }
fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) }
fun showWord(word: String) = runIOCatching { account.showWord(word) }
fun showWord(word: String) = launchSigner { account.showWord(word) }
fun hideWord(word: String) = runIOCatching { account.hideWord(word) }
fun hideWord(word: String) = launchSigner { account.hideWord(word) }
fun isLoggedUser(pubkeyHex: HexKey?): Boolean = account.signer.pubKey == pubkeyHex
@@ -859,21 +859,21 @@ class AccountViewModel(
fun filterSpamFromStrangers() = account.settings.syncedSettings.security.filterSpamFromStrangers
fun updateWarnReports(warnReports: Boolean) = runIOCatching { account.updateWarnReports(warnReports) }
fun updateWarnReports(warnReports: Boolean) = launchSigner { account.updateWarnReports(warnReports) }
fun updateFilterSpam(filterSpam: Boolean) =
runIOCatching {
launchSigner {
if (account.updateFilterSpam(filterSpam)) {
LocalCache.antiSpam.active = filterSpamFromStrangers().value
}
}
fun updateShowSensitiveContent(show: Boolean?) = runIOCatching { account.updateShowSensitiveContent(show) }
fun updateShowSensitiveContent(show: Boolean?) = launchSigner { account.updateShowSensitiveContent(show) }
fun changeReactionTypes(
reactionSet: List<String>,
onDone: () -> Unit,
) = runIOCatching {
) = launchSigner {
account.changeReactionTypes(reactionSet)
onDone()
}
@@ -882,37 +882,37 @@ class AccountViewModel(
amountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
nip47Update: Nip47WalletConnect.Nip47URINorm?,
) = runIOCatching { account.updateZapAmounts(amountSet, selectedZapType, nip47Update) }
) = launchSigner { account.updateZapAmounts(amountSet, selectedZapType, nip47Update) }
fun toggleDontTranslateFrom(languageCode: String) = runIOCatching { account.toggleDontTranslateFrom(languageCode) }
fun toggleDontTranslateFrom(languageCode: String) = launchSigner { account.toggleDontTranslateFrom(languageCode) }
fun updateTranslateTo(languageCode: Locale) = runIOCatching { account.updateTranslateTo(languageCode) }
fun updateTranslateTo(languageCode: Locale) = launchSigner { account.updateTranslateTo(languageCode) }
fun prefer(
source: String,
target: String,
preference: String,
) = runIOCatching { account.prefer(source, target, preference) }
) = launchSigner { account.prefer(source, target, preference) }
fun show(user: User) = runIOCatching { account.showUser(user.pubkeyHex) }
fun show(user: User) = launchSigner { account.showUser(user.pubkeyHex) }
fun hide(user: User) = runIOCatching { account.hideUser(user.pubkeyHex) }
fun hide(user: User) = launchSigner { account.hideUser(user.pubkeyHex) }
fun hide(word: String) = runIOCatching { account.hideWord(word) }
fun hide(word: String) = launchSigner { account.hideWord(word) }
fun showUser(pubkeyHex: String) = runIOCatching { account.showUser(pubkeyHex) }
fun showUser(pubkeyHex: String) = launchSigner { account.showUser(pubkeyHex) }
fun createStatus(newStatus: String) = runIOCatching { account.createStatus(newStatus) }
fun createStatus(newStatus: String) = launchSigner { account.createStatus(newStatus) }
fun updateStatus(
address: Address,
newStatus: String,
) = runIOCatching {
) = launchSigner {
account.updateStatus(LocalCache.getOrCreateAddressableNote(address), newStatus)
}
fun deleteStatus(address: Address) =
runIOCatching {
launchSigner {
account.deleteStatus(LocalCache.getOrCreateAddressableNote(address))
}
@@ -977,53 +977,27 @@ class AccountViewModel(
override suspend fun getOrCreateUser(hex: HexKey): User = LocalCache.getOrCreateUser(hex)
fun checkGetOrCreateUser(
key: HexKey,
onResult: (User?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateUser(key)) }
}
fun getUserIfExists(hex: HexKey): User? = LocalCache.getUserIfExists(hex)
fun checkGetOrCreateNote(key: HexKey): Note? = LocalCache.checkGetOrCreateNote(key)
override suspend fun getOrCreateNote(hex: HexKey): Note = LocalCache.getOrCreateNote(hex)
fun checkGetOrCreateNote(
key: HexKey,
onResult: (Note?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateNote(key)) }
}
fun noteFromEvent(event: Event): Note? {
var note = checkGetOrCreateNote(event.id)
fun checkGetOrCreateNote(
event: Event,
onResult: (Note?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
var note = checkGetOrCreateNote(event.id)
if (note == null) {
LocalCache.justConsume(event, null, false)
note = checkGetOrCreateNote(event.id)
}
onResult(note)
if (note == null) {
LocalCache.justConsume(event, null, false)
note = checkGetOrCreateNote(event.id)
}
return note
}
fun getNoteIfExists(hex: HexKey): Note? = LocalCache.getNoteIfExists(hex)
override suspend fun getOrCreateAddressableNote(address: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(address)
fun getOrCreateAddressableNote(
key: Address,
onResult: (AddressableNote?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) { onResult(getOrCreateAddressableNote(key)) }
}
fun getAddressableNoteIfExists(key: String): AddressableNote? = LocalCache.getAddressableNoteIfExists(key)
fun getAddressableNoteIfExists(key: Address): AddressableNote? = LocalCache.getAddressableNoteIfExists(key)
@@ -1076,11 +1050,12 @@ class AccountViewModel(
}
}
fun sortUsersSync(hexList: List<HexKey>): List<HexKey> = hexList.sortedByDescending { account.isKnown(it) }
fun loadUsersSync(hexList: List<String>): List<User> =
hexList
.mapNotNull { hex -> checkGetOrCreateUser(hex) }
.sortedBy { account.isFollowing(it) }
.reversed()
.sortedByDescending { account.isKnown(it) }
suspend fun checkVideoIsOnline(videoUrl: String): Boolean =
withContext(Dispatchers.IO) {
@@ -1177,10 +1152,9 @@ class AccountViewModel(
context: Context,
) {
if (isWriteable()) {
val hint = note.toEventHint<VoiceEvent>()
if (hint == null) return
val hint = note.toEventHint<VoiceEvent>() ?: return
runIOCatching {
launchSigner {
val uploader = UploadOrchestrator()
val result =
uploader.upload(
@@ -1273,7 +1247,7 @@ class AccountViewModel(
if (isWriteable()) {
val boosts = baseNote.boostedBy(userProfile())
if (boosts.isNotEmpty()) {
runIOCatching {
launchSigner {
account.delete(boosts)
}
} else {
@@ -1335,9 +1309,7 @@ class AccountViewModel(
if (existingNoteEvent != null) {
unwrapIfNeeded(existingNoteEvent)
} else {
val newEvent = event.unwrapOrNull(account.signer)
if (newEvent == null) return null
val newEvent = event.unwrapOrNull(account.signer) ?: return null
// clear the encrypted payload to save memory
LocalCache.getOrCreateNote(event.id).event = event.copyNoContent()
@@ -1403,7 +1375,7 @@ class AccountViewModel(
fun unwrapIfNeeded(
note: Note?,
onReady: (Note) -> Unit = {},
) = runIOCatching {
) = launchSigner {
val noteEvent = note?.event
if (noteEvent != null) {
val resultingNote = unwrapIfNeeded(noteEvent)
@@ -1435,7 +1407,7 @@ class AccountViewModel(
dvmPublicKey: User,
onReady: (event: Note) -> Unit,
) {
runIOCatching {
launchSigner {
account.requestDVMContentDiscovery(dvmPublicKey) {
onReady(LocalCache.getOrCreateNote(it.id))
}
@@ -1469,7 +1441,7 @@ class AccountViewModel(
zappedNote: Note?,
onSent: () -> Unit = {},
onResponse: (Response?) -> Unit,
) = runIOCatching {
) = launchSigner {
account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
onSent()
}
@@ -1480,7 +1452,7 @@ class AccountViewModel(
root: InteractiveStoryBaseEvent,
readingScene: InteractiveStoryBaseEvent,
) {
runIOCatching {
launchSigner {
val sceneNoteRelayHint = LocalCache.getOrCreateAddressableNote(readingScene.address()).relayHintUrl()
val readingState = getInteractiveStoryReadingState(root.addressTag())
@@ -117,7 +117,7 @@ fun RenderChannelDataPreview() {
tags = EmptyTagList,
bgColor = remember { mutableStateOf(Color.Transparent) },
accountViewModel = mockAccountViewModel(),
nav = EmptyNav,
nav = EmptyNav(),
)
}
}
@@ -72,19 +72,17 @@ fun ChatroomView(
if (replyToNote != null) {
LaunchedEffect(key1 = replyToNote, newPostModel, accountViewModel) {
accountViewModel.checkGetOrCreateNote(replyToNote) {
if (it != null) {
newPostModel.reply(it)
}
val replyNote = accountViewModel.checkGetOrCreateNote(replyToNote)
if (replyNote != null) {
newPostModel.reply(replyNote)
}
}
}
if (editFromDraft != null) {
LaunchedEffect(editFromDraft, newPostModel, accountViewModel) {
accountViewModel.checkGetOrCreateNote(editFromDraft) {
if (it != null) {
newPostModel.editFromDraft(it)
}
val draftNote = accountViewModel.checkGetOrCreateNote(editFromDraft)
if (draftNote != null) {
newPostModel.editFromDraft(draftNote)
}
}
}
@@ -98,7 +98,7 @@ fun NewChatroomSubjectDialog(
PostButton(
onPost = {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
val template =
ChatMessageEvent.build(
message.value,
@@ -114,7 +114,7 @@ class ChatNewMessageViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
sendDraftSync()
}
}
@@ -325,9 +325,7 @@ class ChatNewMessageViewModel :
}
if (replyId != null) {
accountViewModel.checkGetOrCreateNote(replyId) {
replyTo.value = it
}
replyTo.value = accountViewModel.checkGetOrCreateNote(replyId)
}
} else if (draftEvent is PrivateDmEvent) {
val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() }
@@ -335,9 +333,7 @@ class ChatNewMessageViewModel :
val replyId = draftEvent.replyTo()
if (replyId != null) {
accountViewModel.checkGetOrCreateNote(replyId) {
replyTo.value = it
}
replyTo.value = accountViewModel.checkGetOrCreateNote(replyId)
}
}
@@ -393,7 +389,7 @@ class ChatNewMessageViewModel :
) {
val uploadState = uploadState ?: return
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
if (nip17) {
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) {
uploadsWaitingToBeSent += it
@@ -418,7 +414,7 @@ class ChatNewMessageViewModel :
val room = room ?: return
val uploadState = uploadState ?: return
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
if (nip17) {
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) {
ChatFileSender(room, account).sendNIP17(it)
@@ -169,7 +169,7 @@ fun NewGroupDMScreen(
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
@@ -184,7 +184,7 @@ fun NewGroupDMScreen(
onCancel = {
// uses the accountViewModel scope to avoid cancelling this
// function when the postViewModel is released
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
@@ -193,7 +193,7 @@ fun NewGroupDMScreen(
onPost = {
// uses the accountViewModel scope to avoid cancelling this
// function when the postViewModel is released
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
postViewModel.sendPostSync()
postViewModel.room?.let {
nav.nav(routeToMessage(it, null, null, null, null, accountViewModel))
@@ -72,7 +72,7 @@ fun PrivateMessageEditFieldRowPreview() {
channelScreenModel = channelScreenModel,
accountViewModel = accountViewModel,
onSendNewMessage = {},
nav = EmptyNav,
nav = EmptyNav(),
)
}
}
@@ -85,7 +85,7 @@ fun PrivateMessageEditFieldRow(
nav: INav,
) {
BackHandler {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
channelScreenModel.cancel()
}
@@ -170,7 +170,7 @@ fun EditField(
isActive = channelScreenModel.canPost(),
modifier = EditFieldTrailingIconModifier,
) {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
channelScreenModel.sendPostSync()
onSendNewMessage()
}
@@ -81,7 +81,7 @@ private fun DialogContentPreview() {
ChannelMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = EmptyNav,
nav = EmptyNav(),
)
}
}
@@ -36,6 +36,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -91,7 +92,17 @@ fun ChannelMetadataScreen(
nav: INav,
) {
val postViewModel: ChannelMetadataViewModel = viewModel()
postViewModel.load(accountViewModel.account, channel)
postViewModel.init(accountViewModel)
if (channel != null) {
LaunchedEffect(postViewModel) {
postViewModel.load(channel)
}
} else {
LaunchedEffect(postViewModel) {
postViewModel.new()
}
}
ChannelMetadataScaffold(
postViewModel = postViewModel,
@@ -105,13 +116,13 @@ fun ChannelMetadataScreen(
private fun DialogContentPreview() {
val accountViewModel = mockAccountViewModel()
val postViewModel: ChannelMetadataViewModel = viewModel()
postViewModel.load(accountViewModel.account, null)
postViewModel.init(accountViewModel)
ThemeComparisonColumn {
ChannelMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = EmptyNav,
nav = EmptyNav(),
)
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -39,6 +40,7 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.amethyst.ui.stringRes
@@ -54,8 +56,11 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class ChannelMetadataViewModel : ViewModel() {
private var account: Account? = null
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
private var originalChannel: PublicChatChannel? = null
val channelName = mutableStateOf(TextFieldValue())
@@ -71,24 +76,28 @@ class ChannelMetadataViewModel : ViewModel() {
channelName.value.text.isNotBlank()
}
fun load(
account: Account,
channel: PublicChatChannel?,
) {
this.account = account
if (channel != null) {
originalChannel = channel
channelName.value = TextFieldValue(channel.info.name ?: "")
channelPicture.value = TextFieldValue(channel.info.picture ?: "")
channelDescription.value = TextFieldValue(channel.info.about ?: "")
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
val relays =
channel.info.relays
?.map { relaySetupInfoBuilder(it) }
?.distinctBy { it.relay }
fun new() {
originalChannel = null
clear()
}
_channelRelays.update { relays ?: emptyList() }
}
fun load(channel: PublicChatChannel) {
originalChannel = channel
channelName.value = TextFieldValue(channel.info.name ?: "")
channelPicture.value = TextFieldValue(channel.info.picture ?: "")
channelDescription.value = TextFieldValue(channel.info.about ?: "")
val relays =
channel.info.relays
?.map { relaySetupInfoBuilder(it) }
?.distinctBy { it.relay }
_channelRelays.update { relays ?: emptyList() }
}
fun isNewChannel() = originalChannel == null && _channelRelays.value.isNotEmpty()
@@ -107,7 +107,7 @@ open class ChannelNewMessageViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
sendDraftSync()
}
}
@@ -242,16 +242,12 @@ open class ChannelNewMessageViewModel :
if (draftEvent as? ChannelMessageEvent != null) {
val replyId = draftEvent.reply()?.eventId
if (replyId != null) {
accountViewModel.checkGetOrCreateNote(replyId) {
replyTo.value = it
}
replyTo.value = accountViewModel.checkGetOrCreateNote(replyId)
}
} else if (draftEvent as? LiveActivitiesChatMessageEvent != null) {
val replyId = draftEvent.reply()?.eventId
if (replyId != null) {
accountViewModel.checkGetOrCreateNote(replyId) {
replyTo.value = it
}
replyTo.value = accountViewModel.checkGetOrCreateNote(replyId)
}
}
@@ -263,7 +259,7 @@ open class ChannelNewMessageViewModel :
}
fun sendPost(onDone: suspend () -> Unit) {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
sendPostSync()
onDone()
}
@@ -62,7 +62,7 @@ fun EditFieldRow(
nav: INav,
) {
BackHandler {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
channelScreenModel.cancel()
}
@@ -327,11 +327,7 @@ fun LoadUser(
if (user == null) {
LaunchedEffect(key1 = baseUserHex) {
accountViewModel.checkGetOrCreateUser(baseUserHex) { newUser ->
if (user != newUser) {
user = newUser
}
}
user = accountViewModel.checkGetOrCreateUser(baseUserHex)
}
}
@@ -63,7 +63,7 @@ private fun TopNavFilterBar(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) },
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
accountViewModel = accountViewModel,
)
}
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -39,13 +38,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.Gallery
import com.vitorpamplona.amethyst.ui.note.GalleryUnloaded
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -58,13 +57,13 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.FollowSetImageModifier
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Immutable
@@ -72,7 +71,7 @@ data class FollowSetCard(
val name: String,
val media: String?,
val description: String?,
val users: ImmutableList<User>,
val users: ImmutableList<HexKey>,
)
@Composable
@@ -90,7 +89,7 @@ fun RenderFollowSetThumb(
description = noteEvent?.description(),
users =
accountViewModel
.loadUsersSync(
.sortUsersSync(
noteEvent?.followIds() ?: emptyList(),
).toImmutableList(),
)
@@ -108,30 +107,49 @@ fun RenderFollowSetThumb(
@Composable
fun RenderFollowSetThumbPreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val nav = EmptyNav()
ThemeComparisonColumn(
toPreview = {
RenderFollowSetThumb(
card =
FollowSetCard(
"Orange Pill Perú",
"https://i.postimg.cc/GtDgGY5v/5062563795762785335.jpg",
"Desc",
persistentListOf(
accountViewModel.userProfile(),
accountViewModel.userProfile(),
accountViewModel.userProfile(),
accountViewModel.userProfile(),
accountViewModel.userProfile(),
),
),
baseNote = Note(""),
accountViewModel = accountViewModel,
nav = nav,
)
},
)
val followCard =
FollowListEvent(
id = "eca31634fce7c9068b56fa8db9f387da70bdcceb3986a77ca1a9844f3128eb5f",
pubKey = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da",
createdAt = 1761736286,
tags =
arrayOf(
arrayOf("title", "Retro Computer Fans"),
arrayOf("d", "xmbspe8rddsq"),
arrayOf("image", "https://blog.johnnovak.net/2022/04/15/achieving-period-correct-graphics-in-personal-computer-emulators-part-1-the-amiga/img/dream-setup.jpg"),
arrayOf("p", "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da"),
arrayOf("p", "9a9a4aa0e43e57873380ab22e8a3df12f3c4cf5bb3a804c6e3fed0069a6e2740"),
arrayOf("p", "4f5dd82517b11088ce00f23d99f06fe8f3e2e45ecf47bc9c2f90f34d5c6f7382"),
arrayOf("p", "ac92102a2ecb873c488e0125354ef5a97075a16198668c360eda050007ed42cd"),
arrayOf("p", "47f54409a4620eb35208a3bc1b53555bf3d0656b246bf0471a93208e20672f6f"),
arrayOf("p", "2624911545afb7a2b440cf10f5c69308afa33aae26fca664d8c94623dc0f1baf"),
arrayOf("p", "6641f26f5c59f7010dbe3e42e4593398e27c087497cb7d20e0e7633a17e48a94"),
arrayOf("description", "Retro computer fans and enthusiasts "),
),
content = "",
sig = "3aa388edafad151e81cb0228fe04e115dbbcaa851c666bfe3c8740b6cd99575f0fc3ba2d47acda86f7626564a05e9dbc05ef452a7bd0ac00f828dbad0e1bae6c",
)
LocalCache.justConsume(followCard, null, false)
val card =
FollowSetCard(
name = followCard.title()?.ifBlank { null } ?: followCard.dTag(),
media = followCard.image()?.ifBlank { null },
description = followCard.description(),
users = followCard.followIds().toImmutableList(),
)
ThemeComparisonColumn {
RenderFollowSetThumb(
card = card,
baseNote = LocalCache.getOrCreateNote(followCard.id),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
@@ -164,7 +182,7 @@ fun RenderFollowSetThumb(
)
} ?: run { DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier) }
Gallery(card.users, Modifier.padding(Size10dp), accountViewModel, nav)
GalleryUnloaded(card.users, StdPadding, accountViewModel, nav)
}
Spacer(modifier = DoubleVertSpacer)
@@ -45,7 +45,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
@@ -81,11 +80,9 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
@@ -139,7 +136,7 @@ fun NewProductScreen(
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
@@ -154,23 +151,16 @@ fun NewProductScreen(
onCancel = {
// uses the accountViewModel scope to avoid cancelling this
// function when the postViewModel is released
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
nav.popBack()
},
onPost = {
try {
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.sendPostSync()
nav.popBack()
}
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
accountViewModel.launchSigner {
postViewModel.sendPostSync()
nav.popBack()
}
},
)
@@ -104,15 +104,15 @@ open class NewProductViewModel :
IZapRaiser {
val draftTag = DraftTagState()
var accountViewModel: AccountViewModel? = null
var account: Account? = null
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
init {
viewModelScope.launch(Dispatchers.IO) {
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
accountViewModel?.runIOCatching {
accountViewModel.launchSigner {
sendDraftSync()
}
}
@@ -189,8 +189,6 @@ open class NewProductViewModel :
}
fun editFromDraft(draft: Note) {
val accountViewModel = accountViewModel ?: return
val noteEvent = draft.event
val noteAuthor = draft.author
@@ -0,0 +1,329 @@
/**
* 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.followPacks.feed
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.ReplyReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackFeedConversationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackFeedNewThreadFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackMembersUserFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FollowPackFeedScreen(
address: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadAddressableNote(address, accountViewModel) {
it?.let {
PrepareViewModelsFollowPackScreen(
note = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
fun PrepareViewModelsFollowPackScreen(
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val conversationsFeedViewModel: FollowPackFeedConversationsFeedViewModel =
viewModel(
key = note.idHex + "ConversationsFeedViewModel",
factory =
FollowPackFeedConversationsFeedViewModel.Factory(
note,
accountViewModel.account,
),
)
val newThreadFeedViewModel: FollowPackFeedNewThreadFeedViewModel =
viewModel(
key = note.idHex + "NewThreadFeedViewModel",
factory =
FollowPackFeedNewThreadFeedViewModel.Factory(
note,
accountViewModel.account,
),
)
val membersFeedViewModel: FollowPackMembersUserFeedViewModel =
viewModel(
key = note.idHex + "MembersFeedViewModel",
factory =
FollowPackMembersUserFeedViewModel.Factory(
note,
accountViewModel.account,
),
)
FollowPackFeedScreen(note, newThreadFeedViewModel, conversationsFeedViewModel, membersFeedViewModel, accountViewModel, nav)
}
@Composable
fun FollowPackFeedScreen(
note: AddressableNote,
newThreadFeedViewModel: FollowPackFeedNewThreadFeedViewModel,
conversationsFeedViewModel: FollowPackFeedConversationsFeedViewModel,
membersFeedViewModel: FollowPackMembersUserFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(newThreadFeedViewModel)
WatchLifecycleAndUpdateModel(conversationsFeedViewModel)
WatchLifecycleAndUpdateModel(membersFeedViewModel)
FollowPackFeedFilterAssemblerSubscription(note, accountViewModel)
val pagerState = rememberForeverPagerState(note.idHex + "FollowPackScreenPagerState") { 3 }
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
FollowPackFeedTopBar(
note,
pagerState,
accountViewModel,
nav,
)
},
accountViewModel = accountViewModel,
) {
HorizontalPager(
contentPadding = it,
state = pagerState,
) { page ->
when (page) {
0 ->
RefresheableFeedView(
newThreadFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
1 ->
RefresheableFeedView(
conversationsFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
2 ->
UserFeedView(
membersFeedViewModel,
accountViewModel,
nav,
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FollowPackFeedTopBar(
note: AddressableNote,
pagerState: PagerState,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column {
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val modifier = Modifier.fillMaxWidth().height(TopBarSize + statusBarHeight)
Box(
modifier = modifier, // Adjust height as needed for your banner
) {
DisplayBanner(note, Modifier.fillMaxSize(), accountViewModel)
ShorterTopAppBar(
title = {
FollowPackHeader(note, accountViewModel, nav)
},
navigationIcon = {
Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = nav::popBack,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
}
},
actions = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = SpacedBy2dp) {
ReplyReaction(
baseNote = note,
grayTint = MaterialTheme.colorScheme.onBackground,
accountViewModel = accountViewModel,
iconSizeModifier = Size18Modifier,
) {
nav.nav {
Route.Note(note.idHex)
}
}
Spacer(modifier = HalfHorzSpacer)
LikeReaction(
baseNote = note,
grayTint = MaterialTheme.colorScheme.onBackground,
accountViewModel = accountViewModel,
nav,
)
Spacer(modifier = HalfHorzSpacer)
ZapReaction(
baseNote = note,
grayTint = MaterialTheme.colorScheme.onBackground,
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = HalfHorzSpacer)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.6f), // Make TopAppBar background transparent
),
)
}
TabRow(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
modifier = TabRowHeight,
selectedTabIndex = pagerState.currentPage,
) {
val coroutineScope = rememberCoroutineScope()
Tab(
selected = pagerState.currentPage == 0,
text = { Text(text = stringRes(R.string.new_threads)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
)
Tab(
selected = pagerState.currentPage == 1,
text = { Text(text = stringRes(R.string.conversations)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
)
Tab(
selected = pagerState.currentPage == 2,
text = { Text(text = stringRes(R.string.members)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
)
}
}
}
@Composable
private fun DisplayBanner(
baseNote: AddressableNote,
modifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
) {
val noteEvent by observeNoteEvent<FollowListEvent>(baseNote, accountViewModel)
noteEvent?.image()?.let {
AsyncImage(
model = it,
contentDescription = stringRes(R.string.preview_card_image_for, it),
contentScale = ContentScale.Crop,
modifier = Modifier,
)
}
}
@Composable
fun FollowPackHeader(
baseNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent by observeNoteEvent<FollowListEvent>(baseNote, accountViewModel)
Text(
text = noteEvent?.title() ?: baseNote.dTag(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@@ -0,0 +1,118 @@
/**
* 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.followPacks.feed.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
class FollowPackFeedConversationsFeedFilter(
val followPackNote: AddressableNote,
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value
override fun showHiddenKey(): Boolean =
account.liveHomeFollowLists.value is MutedAuthorsByOutboxTopNavFilter ||
account.liveHomeFollowLists.value is MutedAuthorsByProxyTopNavFilter
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
return sort(
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, filterParams)
},
)
}
val followPackEvent = followPackNote.event as? FollowListEvent
val follows = followPackEvent?.followIdSet() ?: emptySet()
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
followLists =
if (account.proxyRelayList.flow.value
.isEmpty()
) {
AllUserFollowsByOutboxTopNavFilter(
authors = follows,
defaultRelays = account.defaultGlobalRelays.flow,
blockedRelays = account.blockedRelayList.flow,
)
} else {
AllUserFollowsByProxyTopNavFilter(
authors = follows,
proxyRelays = account.proxyRelayList.flow.value,
)
},
hiddenUsers = account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams = buildFilterParams(account)
return collection.filterTo(HashSet()) {
acceptableEvent(it, filterParams)
}
}
fun acceptableEvent(
event: Event?,
filterParams: FilterByListParams,
): Boolean =
(
event is TextNoteEvent ||
event is PollNoteEvent ||
event is ChannelMessageEvent ||
event is CommentEvent ||
event is VoiceReplyEvent ||
event is PublicMessageEvent ||
event is LiveActivitiesChatMessageEvent
) &&
filterParams.match(event)
fun acceptableEvent(
note: Note,
filterParams: FilterByListParams,
): Boolean = acceptableEvent(note.event, filterParams) && !note.isNewThread()
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
class FollowPackFeedConversationsFeedViewModel(
val note: AddressableNote,
val account: Account,
) : FeedViewModel(FollowPackFeedConversationsFeedFilter(note, account)) {
class Factory(
val note: AddressableNote,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = FollowPackFeedConversationsFeedViewModel(note, account) as T
}
}
@@ -0,0 +1,152 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
class FollowPackFeedNewThreadFeedFilter(
val followPackNote: AddressableNote,
val account: Account,
) : AdditiveFeedFilter<Note>() {
companion object Companion {
val ADDRESSABLE_KINDS =
listOf(
AudioTrackEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
WikiNoteEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
)
}
val followPackEvent = followPackNote.event as? FollowListEvent
val follows = followPackEvent?.followIdSet() ?: emptySet()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followPackNote.idHex
override fun showHiddenKey(): Boolean = false
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
followLists =
if (account.proxyRelayList.flow.value
.isEmpty()
) {
AllUserFollowsByOutboxTopNavFilter(
authors = follows,
defaultRelays = account.defaultGlobalRelays.flow,
blockedRelays = account.blockedRelayList.flow,
)
} else {
AllUserFollowsByProxyTopNavFilter(
authors = follows,
proxyRelays = account.proxyRelayList.flow.value,
)
},
hiddenUsers = account.hiddenUsers.flow.value,
)
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, note ->
// Avoids processing addressables twice.
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams)
}
val longFormNotes =
LocalCache.addressables.filterIntoSet(
kinds = ADDRESSABLE_KINDS,
) { _, note ->
acceptableEvent(note, filterParams)
}
return sort(notes + longFormNotes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams = buildFilterParams(account)
return collection.filterTo(HashSet()) {
acceptableEvent(it, filterParams)
}
}
private fun acceptableEvent(
it: Note,
filterParams: FilterByListParams,
): Boolean {
val noteEvent = it.event
return (
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
noteEvent is RepostEvent ||
noteEvent is GenericRepostEvent ||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
noteEvent is PollNoteEvent ||
noteEvent is HighlightEvent ||
noteEvent is InteractiveStoryPrologueEvent ||
noteEvent is CommentEvent ||
noteEvent is AudioTrackEvent ||
noteEvent is VoiceEvent ||
noteEvent is AudioHeaderEvent
) &&
filterParams.match(noteEvent, it.relays) &&
it.isNewThread()
}
override fun sort(items: Set<Note>): List<Note> =
items
.distinctBy {
if (it.event is RepostEvent || it.event is GenericRepostEvent) {
it.replyTo?.lastOrNull()?.idHex ?: it.idHex // only the most recent repost per feed.
} else {
it.idHex
}
}.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
class FollowPackFeedNewThreadFeedViewModel(
val note: AddressableNote,
val account: Account,
) : FeedViewModel(FollowPackFeedNewThreadFeedFilter(note, account)) {
class Factory(
val note: AddressableNote,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = FollowPackFeedNewThreadFeedViewModel(note, account) as T
}
}
@@ -0,0 +1,54 @@
/**
* 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.followPacks.feed.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache.checkGetOrCreateUser
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
class FollowPackMembersFeedFilter(
val followPackNote: AddressableNote,
val account: Account,
) : FeedFilter<User>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followPackNote.idHex
val cache: MutableMap<FollowListEvent, List<User>> = mutableMapOf()
override fun feed(): List<User> {
val followPackEvent = followPackNote.event as? FollowListEvent ?: return emptyList()
val previousList = cache[followPackEvent]
if (previousList != null) return previousList
val follows =
followPackEvent
.followIdSet()
.mapNotNull { hex -> checkGetOrCreateUser(hex) }
.filter { !account.isHidden(it) }
.sortedByDescending { account.isKnown(it) }
cache[followPackEvent] = follows
return follows
}
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
class FollowPackMembersUserFeedViewModel(
val followPackNote: AddressableNote,
val account: Account,
) : UserFeedViewModel(FollowPackMembersFeedFilter(followPackNote, account)) {
class Factory(
val followPackNote: AddressableNote,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = FollowPackMembersUserFeedViewModel(followPackNote, account) as T
}
}
@@ -0,0 +1,49 @@
/**
* 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.followPacks.feed.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
// This allows multiple screen to be listening to tags, even the same tag
class FollowPackFeedQueryState(
var followPack: AddressableNote,
var account: Account,
)
@Stable
class FollowPackFeedFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<FollowPackFeedQueryState>() {
val group =
listOf(
FollowPackFeedFilterSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,42 @@
/**
* 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.followPacks.feed.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun FollowPackFeedFilterAssemblerSubscription(
pack: AddressableNote,
accountViewModel: AccountViewModel,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
remember(pack) {
FollowPackFeedQueryState(pack, accountViewModel.account)
}
KeyDataSourceSubscription(state, accountViewModel.dataSources().followPacks)
}
@@ -0,0 +1,68 @@
/**
* 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.followPacks.feed.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.filterHomePostsByAuthors
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
class FollowPackFeedFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<FollowPackFeedQueryState>,
) : SingleSubEoseManager<FollowPackFeedQueryState>(client, allKeys) {
override fun updateFilter(
keys: List<FollowPackFeedQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (keys.isEmpty()) return emptyList()
return keys.flatMap {
val followPack = it.followPack.event
if (followPack is FollowListEvent) {
val filter =
if (it.account.proxyRelayList.flow.value
.isEmpty()
) {
AllUserFollowsByOutboxTopNavFilter(
authors = followPack.followIdSet(),
defaultRelays = it.account.defaultGlobalRelays.flow,
blockedRelays = it.account.blockedRelayList.flow,
).startValue(it.account.cache)
} else {
AllUserFollowsByProxyTopNavFilter(
authors = followPack.followIdSet(),
proxyRelays = it.account.proxyRelayList.flow.value,
).startValue(it.account.cache)
}
filterHomePostsByAuthors(filter, since, null, null)
} else {
emptyList()
}
}
}
override fun distinct(key: FollowPackFeedQueryState) = key.followPack.idHex
}
@@ -57,7 +57,7 @@ fun AddOutboxRelayCardPreview() {
ThemeComparisonColumn {
AddInboxRelayCard(
accountViewModel = mockAccountViewModel(),
nav = EmptyNav,
nav = EmptyNav(),
)
}
}
@@ -68,7 +68,7 @@ private fun TopNavFilterBar(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) },
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
accountViewModel = accountViewModel,
)
}
@@ -167,7 +167,7 @@ private fun NewPostScreenInner(
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
@@ -181,7 +181,7 @@ private fun NewPostScreenInner(
onPost = {
// uses the accountViewModel scope to avoid cancelling this
// function when the postViewModel is released
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
postViewModel.sendPostSync()
nav.popBack()
}
@@ -189,7 +189,7 @@ private fun NewPostScreenInner(
onCancel = {
// uses the accountViewModel scope to avoid cancelling this
// function when the postViewModel is released
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
@@ -146,7 +146,7 @@ open class ShortNotePostViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
sendDraftSync()
}
}
@@ -483,7 +483,7 @@ open class ShortNotePostViewModel :
cancel()
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
@@ -0,0 +1,281 @@
/**
* 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.lists.display
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
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.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.LightRedColor
import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
@Composable
fun RenderAddUserFieldAndSuggestions(
userSuggestions: UserSuggestionState,
hasUserFlow: (User) -> Flow<Boolean>,
addUserToSet: (User) -> Unit,
removeUserFromSet: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
UserSearchDataSourceSubscription(userSuggestions, accountViewModel)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
userSuggestions.invalidateData()
}
}
launch(Dispatchers.IO) {
LocalCache.live.deletedEventBundles.collect {
userSuggestions.invalidateData()
}
}
}
Spacer(HalfVertSpacer)
var userName by remember(userSuggestions) { mutableStateOf(TextFieldValue(userSuggestions.currentWord.value)) }
val focusManager = LocalFocusManager.current
OutlinedTextField(
label = { Text(text = stringRes(R.string.search_and_add_a_user)) },
modifier = Modifier.padding(horizontal = Size10dp).fillMaxWidth(),
value = userName,
onValueChange = {
userName = it
userSuggestions.processCurrentWord(it.text)
},
singleLine = true,
trailingIcon = {
IconButton(
onClick = {
userName = TextFieldValue("")
userSuggestions.processCurrentWord("")
focusManager.clearFocus()
},
) {
ClearTextIcon()
}
},
)
ShowUserSuggestions(
userSuggestions = userSuggestions,
hasUserFlow = hasUserFlow,
onSelect = { user ->
addUserToSet(user)
userName =
userName.copy(
selection = TextRange(0, userName.text.length),
)
},
onDelete = { user ->
removeUserFromSet(user)
userName =
userName.copy(
selection = TextRange(0, userName.text.length),
)
},
accountViewModel = accountViewModel,
)
}
@Composable
fun ShowUserSuggestions(
userSuggestions: UserSuggestionState,
hasUserFlow: (User) -> Flow<Boolean>,
onSelect: (User) -> Unit,
onDelete: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
val listState = rememberLazyListState()
AnimateOnNewSearch(userSuggestions, listState)
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
if (suggestions.isNotEmpty()) {
Card(
modifier = Modifier.padding(start = 11.dp, end = 11.dp),
elevation = cardElevation(5.dp),
shape = PopupUpEffect,
) {
LazyColumn(
contentPadding = PaddingValues(top = 10.dp),
modifier =
Modifier
.heightIn(0.dp, 200.dp),
state = listState,
) {
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser ->
DrawUser(baseUser, hasUserFlow, onSelect, onDelete, accountViewModel)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
Spacer(StdVertSpacer)
}
@Composable
fun DrawUser(
baseUser: User,
hasUserFlow: (User) -> Flow<Boolean>,
onSelect: (User) -> Unit,
onDelete: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = { onSelect(baseUser) })
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null)
Column(
modifier =
Modifier
.padding(start = 10.dp)
.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(
baseUser,
accountViewModel = accountViewModel,
)
HasUserTag(baseUser, hasUserFlow, onDelete)
}
AboutDisplay(baseUser, accountViewModel)
}
}
}
@Composable
private fun RowScope.HasUserTag(
baseUser: User,
hasUserFlow: (User) -> Flow<Boolean>,
onDelete: (User) -> Unit,
) {
val hasUserState by hasUserFlow(baseUser).collectAsStateWithLifecycle(false)
if (hasUserState) {
Spacer(StdHorzSpacer)
Text(
text = stringRes(id = R.string.in_the_list),
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
remember {
Modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 5.dp)
},
)
Spacer(Modifier.weight(1f))
IconButton(
modifier = Modifier.size(30.dp).padding(start = 10.dp),
onClick = { onDelete(baseUser) },
) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = stringRes(id = R.string.remove),
modifier = Modifier.size(15.dp),
tint = LightRedColor,
)
}
}
}
@@ -18,16 +18,13 @@
* 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.lists.display
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -38,12 +35,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation
@@ -51,7 +45,6 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -62,60 +55,48 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.DrawUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.RenderAddUserFieldAndSuggestions
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.LightRedColor
import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@@ -151,24 +132,7 @@ fun PeopleListScreen(
containerColor = MaterialTheme.colorScheme.surface,
),
)
TabRow(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
selectedTabIndex = pagerState.currentPage,
modifier = TabRowHeight,
) {
val scope = rememberCoroutineScope()
Tab(
selected = pagerState.currentPage == 0,
onClick = { scope.launch { pagerState.animateScrollToPage(0) } },
text = { Text(text = stringRes(R.string.private_members)) },
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
text = { Text(text = stringRes(R.string.public_members)) },
)
}
TopAppTabs(viewModel, pagerState)
}
},
) { padding ->
@@ -190,7 +154,46 @@ fun PeopleListScreen(
}
@Composable
fun TitleAndDescription(viewModel: PeopleListViewModel) {
private fun TopAppTabs(
viewModel: PeopleListViewModel,
pagerState: PagerState,
) {
TabRow(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
selectedTabIndex = pagerState.currentPage,
modifier = TabRowHeight,
) {
val scope = rememberCoroutineScope()
Tab(
selected = pagerState.currentPage == 0,
onClick = { scope.launch { pagerState.animateScrollToPage(0) } },
text = {
val list = viewModel.selectedList.collectAsStateWithLifecycle()
val labelPublic =
list.value?.let {
stringRes(R.string.public_members_count, it.publicMembers.size)
} ?: stringRes(R.string.public_members)
Text(labelPublic)
},
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
text = {
val list = viewModel.selectedList.collectAsStateWithLifecycle()
val labelPrivate =
list.value?.let {
stringRes(R.string.private_members_count, it.privateMembersList.size)
} ?: stringRes(R.string.private_members)
Text(labelPrivate)
},
)
}
}
@Composable
private fun TitleAndDescription(viewModel: PeopleListViewModel) {
val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle()
selectedSetState.value?.let { selectedSet ->
Text(
@@ -215,7 +218,7 @@ private fun ListViewAndEditColumn(
pagerState = pagerState,
modifier = Modifier.weight(1f),
onDeleteUser = { user, isPrivate ->
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
viewModel.removeUserFromSet(user, isPrivate)
}
},
@@ -233,70 +236,20 @@ private fun RenderAddUserFieldAndSuggestions(
pagerState: PagerState,
accountViewModel: AccountViewModel,
) {
UserSearchDataSourceSubscription(viewModel.userSuggestions, accountViewModel)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
viewModel.userSuggestions.invalidateData()
}
}
launch(Dispatchers.IO) {
LocalCache.live.deletedEventBundles.collect {
viewModel.userSuggestions.invalidateData()
}
}
}
Spacer(HalfVertSpacer)
var userName by remember(viewModel) { mutableStateOf(TextFieldValue(viewModel.userSuggestions.currentWord.value)) }
val focusManager = LocalFocusManager.current
OutlinedTextField(
label = { Text(text = stringRes(R.string.search_and_add_a_user)) },
modifier = Modifier.padding(horizontal = Size10dp).fillMaxWidth(),
value = userName,
onValueChange = {
userName = it
viewModel.userSuggestions.processCurrentWord(it.text)
},
singleLine = true,
trailingIcon = {
IconButton(
onClick = {
userName = TextFieldValue("")
viewModel.userSuggestions.processCurrentWord("")
focusManager.clearFocus()
},
) {
ClearTextIcon()
}
},
)
ShowUserSuggestions(
userSuggestions = viewModel.userSuggestions,
RenderAddUserFieldAndSuggestions(
viewModel.userSuggestions,
hasUserFlow = { user ->
viewModel.hasUserFlow(user, pagerState.currentPage == 0)
viewModel.hasUserFlow(user, pagerState.currentPage == 1)
},
onSelect = { user ->
accountViewModel.runIOCatching {
viewModel.addUserToSet(user, pagerState.currentPage == 0)
addUserToSet = { user ->
accountViewModel.launchSigner {
viewModel.addUserToSet(user, pagerState.currentPage == 1)
}
userName =
userName.copy(
selection = TextRange(0, userName.text.length),
)
},
onDelete = { user ->
accountViewModel.runIOCatching {
viewModel.removeUserFromSet(user, pagerState.currentPage == 0)
removeUserFromSet = { user ->
accountViewModel.launchSigner {
viewModel.removeUserFromSet(user, pagerState.currentPage == 1)
}
userName =
userName.copy(
selection = TextRange(0, userName.text.length),
)
},
accountViewModel = accountViewModel,
)
@@ -317,9 +270,9 @@ private fun PeopleListPager(
when (page) {
0 ->
PeopleListView(
memberList = selectedSet.privateMembersList,
memberList = selectedSet.publicMembersList,
onDeleteUser = { user ->
onDeleteUser(user, true)
onDeleteUser(user, false)
},
modifier = Modifier.fillMaxSize(),
accountViewModel = accountViewModel,
@@ -328,9 +281,9 @@ private fun PeopleListPager(
1 ->
PeopleListView(
memberList = selectedSet.publicMembersList,
memberList = selectedSet.privateMembersList,
onDeleteUser = { user ->
onDeleteUser(user, false)
onDeleteUser(user, true)
},
modifier = Modifier.fillMaxSize(),
accountViewModel = accountViewModel,
@@ -343,7 +296,7 @@ private fun PeopleListPager(
@Composable
@Preview(device = "spec:width=2160px,height=2940px,dpi=440")
fun FollowSetListViewPreview() {
private fun PeopleListViewPreview() {
val accountViewModel = mockAccountViewModel()
val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
@@ -356,7 +309,7 @@ fun FollowSetListViewPreview() {
memberList = persistentListOf(user1, user2, user3),
onDeleteUser = { user -> },
accountViewModel = accountViewModel,
nav = EmptyNav,
nav = EmptyNav(),
)
Spacer(HalfVertSpacer)
@@ -411,144 +364,25 @@ fun FollowSetListViewPreview() {
}
@Composable
fun ShowUserSuggestions(
userSuggestions: UserSuggestionState,
hasUserFlow: (User) -> Flow<Boolean>,
onSelect: (User) -> Unit,
onDelete: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
val listState = rememberLazyListState()
AnimateOnNewSearch(userSuggestions, listState)
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
if (suggestions.isNotEmpty()) {
Card(
modifier = Modifier.padding(start = 11.dp, end = 11.dp),
elevation = cardElevation(5.dp),
shape = PopupUpEffect,
) {
LazyColumn(
contentPadding = PaddingValues(top = 10.dp),
modifier =
Modifier
.heightIn(0.dp, 200.dp),
state = listState,
) {
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser ->
DrawUser(baseUser, hasUserFlow, onSelect, onDelete, accountViewModel)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
Spacer(StdVertSpacer)
}
@Composable
private fun DrawUser(
baseUser: User,
hasUserFlow: (User) -> Flow<Boolean>,
onSelect: (User) -> Unit,
onDelete: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = { onSelect(baseUser) })
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null)
Column(
modifier =
Modifier
.padding(start = 10.dp)
.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(
baseUser,
accountViewModel = accountViewModel,
)
HasUserTag(baseUser, hasUserFlow, onDelete)
}
AboutDisplay(baseUser, accountViewModel)
}
}
}
@Composable
fun RowScope.HasUserTag(
baseUser: User,
hasUserFlow: (User) -> Flow<Boolean>,
onDelete: (User) -> Unit,
) {
val hasUserState by hasUserFlow(baseUser).collectAsStateWithLifecycle(false)
if (hasUserState) {
Spacer(StdHorzSpacer)
Text(
text = stringRes(id = R.string.in_the_list),
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
remember {
Modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 5.dp)
},
)
Spacer(Modifier.weight(1f))
IconButton(
modifier = Modifier.size(30.dp).padding(start = 10.dp),
onClick = { onDelete(baseUser) },
) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = stringRes(id = R.string.remove),
modifier = Modifier.size(15.dp),
tint = LightRedColor,
)
}
}
}
@Composable
fun ListActionsMenuButton(
private fun ListActionsMenuButton(
viewModel: PeopleListViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
ListActionsMenuButton(
note = viewModel::selectedNote,
onEditList = {
nav.nav { Route.PeopleListMetadataEdit(viewModel.selectedDTag.value) }
},
onBroadcastList = {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
viewModel.loadNote()?.let { updatedSetNote ->
accountViewModel.broadcast(updatedSetNote)
}
}
},
onDeleteList = {
accountViewModel.runIOCatching {
accountViewModel.launchSigner {
viewModel.deleteFollowSet()
}
nav.popBack()
@@ -557,7 +391,9 @@ fun ListActionsMenuButton(
}
@Composable
fun ListActionsMenuButton(
private fun ListActionsMenuButton(
note: () -> AddressableNote,
onEditList: () -> Unit,
onBroadcastList: () -> Unit,
onDeleteList: () -> Unit,
) {
@@ -578,44 +414,65 @@ fun ListActionsMenuButton(
onClick = { isActionListOpen.value = true },
) {
VerticalDotsIcon()
ListActionsMenu(
onCloseMenu = { isActionListOpen.value = false },
isOpen = isActionListOpen.value,
onBroadcastList = onBroadcastList,
onDeleteList = onDeleteList,
)
}
}
@Composable
fun ListActionsMenu(
onCloseMenu: () -> Unit,
isOpen: Boolean,
onBroadcastList: () -> Unit,
onDeleteList: () -> Unit,
) {
DropdownMenu(
expanded = isOpen,
onDismissRequest = onCloseMenu,
) {
DropdownMenuItem(
text = {
Text("Broadcast List")
},
onClick = {
onBroadcastList()
onCloseMenu()
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text("Delete List")
},
onClick = {
onDeleteList()
onCloseMenu()
},
)
DropdownMenu(
expanded = isActionListOpen.value,
onDismissRequest = { isActionListOpen.value = false },
) {
val context = LocalContext.current
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note()),
)
putExtra(
Intent.EXTRA_TITLE,
stringRes(context, R.string.quick_action_share_browser_link),
)
}
val shareIntent =
Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share))
ContextCompat.startActivity(context, shareIntent, null)
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_set_edit_list_metadata))
},
onClick = {
onEditList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_set_broadcast))
},
onClick = {
onBroadcastList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_set_delete))
},
onClick = {
onDeleteList()
isActionListOpen.value = false
},
)
}
}
}
@@ -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.lists.display
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -60,6 +61,10 @@ class PeopleListViewModel : ViewModel() {
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
fun selectedAddress() = PeopleListEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value)
fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress())
fun init(
account: Account,
selectedDTag: String,
@@ -0,0 +1,404 @@
/**
* 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.lists.display.packs
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.DrawUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.RenderAddUserFieldAndSuggestions
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FollowPackScreen(
selectedDTag: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: FollowPackViewModel = viewModel()
viewModel.init(accountViewModel.account, selectedDTag)
Scaffold(
topBar = {
Column {
TopAppBar(
title = {
TitleAndDescription(viewModel)
},
navigationIcon = {
IconButton(nav::popBack) {
ArrowBackIcon()
}
},
actions = {
ListActionsMenuButton(viewModel, accountViewModel, nav)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
}
},
) { padding ->
ListViewAndEditColumn(
viewModel = viewModel,
modifier =
Modifier
.fillMaxSize()
.padding(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding(),
).consumeWindowInsets(padding)
.imePadding(),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun TitleAndDescription(viewModel: FollowPackViewModel) {
val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle()
selectedSetState.value?.let { selectedSet ->
Text(
text = selectedSet.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ListViewAndEditColumn(
viewModel: FollowPackViewModel,
modifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(modifier = modifier) {
PeopleListPager(
viewModel = viewModel,
modifier = Modifier.weight(1f),
onDeleteUser = { user ->
accountViewModel.launchSigner {
viewModel.removeUserFromSet(user)
}
},
accountViewModel = accountViewModel,
nav = nav,
)
RenderAddUserFieldAndSuggestions(viewModel, accountViewModel)
}
}
@Composable
private fun RenderAddUserFieldAndSuggestions(
viewModel: FollowPackViewModel,
accountViewModel: AccountViewModel,
) {
RenderAddUserFieldAndSuggestions(
viewModel.userSuggestions,
hasUserFlow = { user ->
viewModel.hasUserFlow(user)
},
addUserToSet = { user ->
accountViewModel.launchSigner {
viewModel.addUserToSet(user)
}
},
removeUserFromSet = { user ->
accountViewModel.launchSigner {
viewModel.removeUserFromSet(user)
}
},
accountViewModel = accountViewModel,
)
}
@Composable
private fun PeopleListPager(
viewModel: FollowPackViewModel,
modifier: Modifier,
onDeleteUser: (User) -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle()
selectedSetState.value?.let { selectedSet ->
PeopleListView(
memberList = selectedSet.publicMembersList,
onDeleteUser = onDeleteUser,
modifier = modifier,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun ListActionsMenuButton(
viewModel: FollowPackViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
ListActionsMenuButton(
note = viewModel::selectedNote,
onEditList = {
nav.nav { Route.FollowPackMetadataEdit(viewModel.selectedDTag.value) }
},
onBroadcastList = {
accountViewModel.launchSigner {
viewModel.loadNote()?.let { updatedSetNote ->
accountViewModel.broadcast(updatedSetNote)
}
}
},
onDeleteList = {
accountViewModel.launchSigner {
viewModel.deleteFollowSet()
}
nav.popBack()
},
)
}
@Composable
private fun ListActionsMenuButton(
note: () -> AddressableNote,
onEditList: () -> Unit,
onBroadcastList: () -> Unit,
onDeleteList: () -> Unit,
) {
val isActionListOpen = remember { mutableStateOf(false) }
ClickableBox(
modifier =
StdPadding
.size(30.dp)
.border(
width = Dp.Hairline,
color = ButtonDefaults.filledTonalButtonColors().containerColor,
shape = ButtonBorder,
).background(
color = ButtonDefaults.filledTonalButtonColors().containerColor,
shape = ButtonBorder,
),
onClick = { isActionListOpen.value = true },
) {
VerticalDotsIcon()
DropdownMenu(
expanded = isActionListOpen.value,
onDismissRequest = { isActionListOpen.value = false },
) {
val context = LocalContext.current
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note()),
)
putExtra(
Intent.EXTRA_TITLE,
stringRes(context, R.string.quick_action_share_browser_link),
)
}
val shareIntent =
Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share))
ContextCompat.startActivity(context, shareIntent, null)
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_pack_edit_list_metadata))
},
onClick = {
onEditList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_pack_broadcast))
},
onClick = {
onBroadcastList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_pack_delete))
},
onClick = {
onDeleteList()
isActionListOpen.value = false
},
)
}
}
}
@Composable
@Preview(device = "spec:width=2160px,height=2940px,dpi=440")
fun FollowPackViewPreview() {
val accountViewModel = mockAccountViewModel()
val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
val user2: User = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b")
val user3: User = LocalCache.getOrCreateUser("7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf")
ThemeComparisonRow {
Column {
PeopleListView(
memberList = persistentListOf(user1, user2, user3),
onDeleteUser = { user -> },
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
Spacer(HalfVertSpacer)
var userName by remember { mutableStateOf("") }
OutlinedTextField(
label = { Text(text = stringRes(R.string.search_and_add_a_user)) },
modifier =
Modifier
.padding(horizontal = Size10dp)
.fillMaxWidth(),
value = userName,
onValueChange = {
userName = it
},
singleLine = true,
trailingIcon = {
IconButton(
onClick = {},
) {
ClearTextIcon()
}
},
)
Card(
modifier = Modifier.padding(horizontal = 10.dp),
elevation = cardElevation(5.dp),
shape = PopupUpEffect,
) {
LazyColumn(
contentPadding = PaddingValues(top = 10.dp),
modifier = Modifier.heightIn(0.dp, 200.dp),
) {
itemsIndexed(persistentListOf(user1, user2, user3), key = { _, item -> item.pubkeyHex }) { _, baseUser ->
DrawUser(
baseUser,
{ MutableStateFlow(false) },
{},
{},
accountViewModel,
)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
}
}
@@ -0,0 +1,102 @@
/**
* 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.lists.display.packs
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
@Stable
class FollowPackViewModel : ViewModel() {
lateinit var account: Account
lateinit var userSuggestions: UserSuggestionState
var userSuggestionFocus by mutableStateOf<UserSuggestionState?>(null)
val selectedDTag = MutableStateFlow("")
@OptIn(ExperimentalCoroutinesApi::class)
val selectedList =
selectedDTag
.transformLatest {
emitAll(
account.followLists.selectListFlow(it).flowOn(Dispatchers.IO),
)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
fun selectedAddress() = FollowListEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value)
fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress())
fun init(
account: Account,
selectedDTag: String,
) {
if (!this::account.isInitialized || this.account != account) {
this.account = account
this.userSuggestions = UserSuggestionState(account, false)
}
this.selectedDTag.tryEmit(selectedDTag)
}
suspend fun deleteFollowSet() {
account.followLists.deleteFollowSet(selectedDTag.value, account)
}
fun loadNote(): AddressableNote? = account.followLists.getPeopleListNote(selectedDTag.value)
suspend fun removeUserFromSet(user: User) {
account.followLists.removeUserFromSet(user, selectedDTag.value, account)
}
suspend fun addUserToSet(user: User) {
account.followLists.addUserToSet(user, selectedDTag.value, account)
}
fun hasUserFlow(user: User): Flow<Boolean> =
selectedList.map {
if (it == null) {
false
} else {
user in it.publicMembers
}
}
}
@@ -0,0 +1,61 @@
/**
* 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.lists.list
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Stable
class FollowPackViewModel : ViewModel() {
lateinit var accountViewModel: AccountViewModel
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
}
fun listFlow() = accountViewModel.account.followLists.uiListFlow
fun cloneItem(
followSet: PeopleList,
customName: String?,
customDescription: String?,
) {
accountViewModel.launchSigner {
accountViewModel.account.followLists.cloneFollowSet(
currentFollowPack = followSet,
customCloneName = customName,
customCloneDescription = customDescription,
account = accountViewModel.account,
)
}
}
fun deleteItem(followSet: PeopleList) {
accountViewModel.launchSigner {
accountViewModel.account.followLists.deleteFollowSet(
identifierTag = followSet.identifierTag,
account = accountViewModel.account,
)
}
}
}
@@ -22,40 +22,45 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import kotlinx.coroutines.flow.StateFlow
import com.vitorpamplona.amethyst.ui.theme.grayText
@Composable
fun AllPeopleListFeedView(
listFlow: StateFlow<List<PeopleList>>,
onOpenItem: (String) -> Unit = {},
onRenameItem: (targetSet: PeopleList, newName: String) -> Unit,
onItemDescriptionChange: (peopleList: PeopleList, newDescription: String?) -> Unit,
onItemClone: (peopleList: PeopleList, customName: String?, customDesc: String?) -> Unit,
onDeleteItem: (peopleList: PeopleList) -> Unit,
peopleListModel: PeopleListViewModel,
followPackModel: FollowPackViewModel,
nav: INav,
) {
val followSetFeedState by listFlow.collectAsStateWithLifecycle()
val peopleListFeedState by peopleListModel.listFlow().collectAsStateWithLifecycle()
val followPackFeedState by followPackModel.listFlow().collectAsStateWithLifecycle()
if (followSetFeedState.isEmpty()) {
if (peopleListFeedState.isEmpty() && followPackFeedState.isEmpty()) {
AllPeopleListFeedEmpty(
message = stringRes(R.string.follow_set_empty_feed_msg),
)
@@ -64,15 +69,84 @@ fun AllPeopleListFeedView(
state = rememberLazyListState(),
contentPadding = FeedPadding,
) {
itemsIndexed(followSetFeedState, key = { _, item -> item.identifierTag }) { _, list ->
stickyHeader {
Row(
modifier = MaxWidthWithHorzPadding,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.follow_sets),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.follow_sets_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = {
nav.nav(Route.PeopleListMetadataEdit())
},
)
}
}
itemsIndexed(peopleListFeedState, key = { _, item -> item.identifierTag }) { _, followSet ->
PeopleListItem(
modifier = Modifier.fillMaxSize().animateItem(),
peopleList = list,
onClick = { onOpenItem(list.identifierTag) },
onRename = { onRenameItem(list, it) },
onDescriptionChange = { newDescription -> onItemDescriptionChange(list, newDescription) },
onClone = { cloneName, cloneDescription -> onItemClone(list, cloneName, cloneDescription) },
onDelete = { onDeleteItem(list) },
modifier =
Modifier
.fillMaxSize()
.animateItem(),
peopleList = followSet,
onClick = { nav.nav(Route.MyPeopleListView(followSet.identifierTag)) },
onEditMetadata = { nav.nav(Route.PeopleListMetadataEdit(followSet.identifierTag)) },
onClone = { cloneName, cloneDescription -> peopleListModel.cloneItem(followSet, cloneName, cloneDescription) },
onDelete = { peopleListModel.deleteItem(followSet) },
)
HorizontalDivider(thickness = DividerThickness)
}
stickyHeader {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.discover_follows),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.discover_follows_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = {
nav.nav(Route.FollowPackMetadataEdit())
},
)
}
}
itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, followSet ->
PeopleListItem(
modifier =
Modifier
.fillMaxSize()
.animateItem(),
peopleList = followSet,
onClick = { nav.nav(Route.MyFollowPackView(followSet.identifierTag)) },
onEditMetadata = { nav.nav(Route.FollowPackMetadataEdit(followSet.identifierTag)) },
onClone = { cloneName, cloneDescription -> followPackModel.cloneItem(followSet, cloneName, cloneDescription) },
onDelete = { followPackModel.deleteItem(followSet) },
)
HorizontalDivider(thickness = DividerThickness)
}
@@ -83,7 +157,9 @@ fun AllPeopleListFeedView(
@Composable
fun AllPeopleListFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) {
Column(
Modifier.fillMaxSize().padding(horizontal = Size40dp),
Modifier
.fillMaxSize()
.padding(horizontal = Size40dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@@ -21,108 +21,50 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.flow.StateFlow
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
@Composable
fun ListOfPeopleListsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
ListOfPeopleListsScreen(
listFlow = accountViewModel.account.peopleLists.uiListFlow,
addItem = { title: String, description: String? ->
accountViewModel.runIOCatching {
accountViewModel.account.peopleLists.addFollowList(
listName = title,
listDescription = description,
account = accountViewModel.account,
)
}
},
openItem = {
nav.nav(Route.PeopleListView(it))
},
renameItem = { followSet, newValue ->
accountViewModel.runIOCatching {
accountViewModel.account.peopleLists.renameFollowList(
newName = newValue,
peopleList = followSet,
account = accountViewModel.account,
)
}
},
changeItemDescription = { followSet, newDescription ->
accountViewModel.runIOCatching {
accountViewModel.account.peopleLists.modifyFollowSetDescription(
newDescription = newDescription,
peopleList = followSet,
account = accountViewModel.account,
)
}
},
cloneItem = { followSet, customName, customDescription ->
accountViewModel.runIOCatching {
accountViewModel.account.peopleLists.cloneFollowSet(
currentPeopleList = followSet,
customCloneName = customName,
customCloneDescription = customDescription,
account = accountViewModel.account,
)
}
},
deleteItem = { followSet ->
accountViewModel.runIOCatching {
accountViewModel.account.peopleLists.deleteFollowSet(
identifierTag = followSet.identifierTag,
account = accountViewModel.account,
)
}
},
nav,
)
val list: PeopleListViewModel = viewModel()
list.init(accountViewModel)
val pack: FollowPackViewModel = viewModel()
pack.init(accountViewModel)
ListOfPeopleListsScreen(list, pack, nav)
}
@Composable
fun ListOfPeopleListsScreen(
listFlow: StateFlow<List<PeopleList>>,
addItem: (title: String, description: String?) -> Unit,
openItem: (identifier: String) -> Unit,
renameItem: (peopleList: PeopleList, newName: String) -> Unit,
changeItemDescription: (peopleList: PeopleList, newDescription: String?) -> Unit,
cloneItem: (peopleList: PeopleList, customName: String?, customDesc: String?) -> Unit,
deleteItem: (peopleList: PeopleList) -> Unit,
list: PeopleListViewModel,
pack: FollowPackViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(R.string.my_lists), nav::popBack)
},
floatingActionButton = {
PeopleListFabsAndMenu(
onAddSet = addItem,
)
},
) { paddingValues ->
Column(
Modifier
@@ -131,47 +73,20 @@ fun ListOfPeopleListsScreen(
bottom = paddingValues.calculateBottomPadding(),
).fillMaxHeight(),
) {
AllPeopleListFeedView(
listFlow = listFlow,
onOpenItem = openItem,
onRenameItem = renameItem,
onItemDescriptionChange = changeItemDescription,
onItemClone = cloneItem,
onDeleteItem = deleteItem,
)
AllPeopleListFeedView(list, pack, nav)
}
}
}
@Composable
private fun PeopleListFabsAndMenu(onAddSet: (name: String, description: String?) -> Unit) {
val isSetAdditionDialogOpen = remember { mutableStateOf(false) }
ExtendedFloatingActionButton(
text = {
Text(text = stringRes(R.string.follow_set_create_btn_label))
},
icon = {
fun NewListButton(onClick: () -> Unit) {
OutlinedButton(onClick = onClick) {
Row(horizontalArrangement = SpacedBy5dp, verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = null,
)
},
onClick = {
isSetAdditionDialogOpen.value = true
},
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
)
if (isSetAdditionDialogOpen.value) {
NewPeopleListCreationDialog(
onDismiss = {
isSetAdditionDialogOpen.value = false
},
onCreateList = { name, description ->
onAddSet(name, description)
},
)
Text(stringRes(R.string.follow_set_create_btn_label))
}
}
}
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@Composable
fun NewPeopleListCreationDialog(
title: Int = R.string.follow_set_creation_dialog_title,
modifier: Modifier = Modifier,
onDismiss: () -> Unit,
onCreateList: (name: String, description: String?) -> Unit,
@@ -56,7 +57,7 @@ fun NewPeopleListCreationDialog(
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringRes(R.string.follow_set_creation_dialog_title),
text = stringRes(title),
)
}
},
@@ -81,8 +81,9 @@ private fun PeopleListItemPreview() {
val samplePeopleList1 =
PeopleList(
identifierTag = "00001-2222",
title = "Sample List Title",
title = "Sample List Title, Very long title, very very very long",
description = "Sample List Description",
image = "http://some.com/image.png",
emptySet(),
emptySet(),
)
@@ -92,6 +93,7 @@ private fun PeopleListItemPreview() {
identifierTag = "00001-2223",
title = "Sample List Title",
description = "Sample List Description",
image = "http://some.com/image.png",
setOf(user1, user3),
emptySet(),
)
@@ -101,6 +103,7 @@ private fun PeopleListItemPreview() {
identifierTag = "00001-2224",
title = "Sample List Title",
description = "Sample List Description",
image = "http://some.com/image.png",
emptySet(),
setOf(user1, user3),
)
@@ -110,6 +113,7 @@ private fun PeopleListItemPreview() {
identifierTag = "00001-2225",
title = "Sample List Title",
description = "Sample List Description",
image = "http://some.com/image.png",
setOf(user3),
setOf(user1, user2, user3),
)
@@ -120,8 +124,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier,
peopleList = samplePeopleList1,
onClick = {},
onRename = {},
onDescriptionChange = { },
onEditMetadata = {},
onClone = { newName, newDesc -> },
onDelete = {},
)
@@ -129,8 +132,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier,
peopleList = samplePeopleList2,
onClick = {},
onRename = {},
onDescriptionChange = { },
onEditMetadata = {},
onClone = { newName, newDesc -> },
onDelete = {},
)
@@ -138,8 +140,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier,
peopleList = samplePeopleList3,
onClick = {},
onRename = {},
onDescriptionChange = { },
onEditMetadata = {},
onClone = { newName, newDesc -> },
onDelete = {},
)
@@ -147,8 +148,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier,
peopleList = samplePeopleList4,
onClick = {},
onRename = {},
onDescriptionChange = { },
onEditMetadata = {},
onClone = { newName, newDesc -> },
onDelete = {},
)
@@ -161,8 +161,7 @@ fun PeopleListItem(
modifier: Modifier = Modifier,
peopleList: PeopleList,
onClick: () -> Unit,
onRename: (String) -> Unit,
onDescriptionChange: (String?) -> Unit,
onEditMetadata: () -> Unit,
onClone: (customName: String?, customDescription: String?) -> Unit,
onDelete: () -> Unit,
) {
@@ -173,7 +172,12 @@ fun PeopleListItem(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(peopleList.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
modifier = Modifier.weight(1f),
text = peopleList.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(
modifier = NoSoTinyBorders,
@@ -181,10 +185,7 @@ fun PeopleListItem(
horizontalAlignment = Alignment.End,
) {
PeopleListOptionsButton(
peopleListName = peopleList.title,
peopleListDescription = peopleList.description,
onListRename = onRename,
onListDescriptionChange = onDescriptionChange,
onListEditMetadata = onEditMetadata,
onListCloneCreate = onClone,
onListDelete = onDelete,
)
@@ -271,10 +272,7 @@ fun DisplayParticipantNumberAndStatus(
@Composable
private fun PeopleListOptionsButton(
modifier: Modifier = Modifier,
peopleListName: String,
peopleListDescription: String?,
onListRename: (String) -> Unit,
onListDescriptionChange: (String?) -> Unit,
onListEditMetadata: () -> Unit,
onListCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit,
onListDelete: () -> Unit,
) {
@@ -286,35 +284,23 @@ private fun PeopleListOptionsButton(
VerticalDotsIcon()
ListOptionsMenu(
setName = peopleListName,
setDescription = peopleListDescription,
isExpanded = isMenuOpen.value,
onDismiss = { isMenuOpen.value = false },
onListRename = onListRename,
onListDescriptionChange = onListDescriptionChange,
onListEditMetadata = onListEditMetadata,
onListClone = onListCloneCreate,
onDelete = onListDelete,
onDismiss = { isMenuOpen.value = false },
)
}
}
@Composable
private fun ListOptionsMenu(
modifier: Modifier = Modifier,
isExpanded: Boolean,
setName: String,
setDescription: String?,
onListRename: (String) -> Unit,
onListDescriptionChange: (String?) -> Unit,
onListEditMetadata: () -> Unit,
onListClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit,
onDelete: () -> Unit,
onDismiss: () -> Unit,
) {
val isRenameDialogOpen = remember { mutableStateOf(false) }
val renameString = remember { mutableStateOf("") }
val isDescriptionModDialogOpen = remember { mutableStateOf(false) }
val isCopyDialogOpen = remember { mutableStateOf(false) }
val optionalCloneName = remember { mutableStateOf<String?>(null) }
val optionalCloneDescription = remember { mutableStateOf<String?>(null) }
@@ -325,19 +311,10 @@ private fun ListOptionsMenu(
) {
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_rename_btn_label))
Text(text = stringRes(R.string.follow_set_edit_list_metadata))
},
onClick = {
isRenameDialogOpen.value = true
onDismiss()
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_desc_modify_label))
},
onClick = {
isDescriptionModDialogOpen.value = true
onListEditMetadata()
onDismiss()
},
)
@@ -360,28 +337,6 @@ private fun ListOptionsMenu(
)
}
if (isRenameDialogOpen.value) {
ListRenameDialog(
currentName = setName,
newName = renameString.value,
onStringRenameChange = {
renameString.value = it
},
onDismissDialog = { isRenameDialogOpen.value = false },
onListRename = {
onListRename(renameString.value)
},
)
}
if (isDescriptionModDialogOpen.value) {
ListModifyDescriptionDialog(
currentDescription = setDescription,
onDismissDialog = { isDescriptionModDialogOpen.value = false },
onModifyDescription = onListDescriptionChange,
)
}
if (isCopyDialogOpen.value) {
ListCloneDialog(
optionalNewName = optionalCloneName.value,
@@ -465,9 +420,9 @@ private fun ListModifyDescriptionDialog(
modifier: Modifier = Modifier,
currentDescription: String?,
onDismissDialog: () -> Unit,
onModifyDescription: (String?) -> Unit,
onModifyDescription: (String) -> Unit,
) {
val updatedDescription = remember { mutableStateOf<String?>(null) }
val updatedDescription = remember { mutableStateOf<String>("") }
val modifyIndicatorLabel =
if (currentDescription == null) {
@@ -504,7 +459,7 @@ private fun ListModifyDescriptionDialog(
fontStyle = FontStyle.Italic,
)
TextField(
value = updatedDescription.value ?: "",
value = updatedDescription.value,
onValueChange = { updatedDescription.value = it },
)
}
@@ -0,0 +1,61 @@
/**
* 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.lists.list
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Stable
class PeopleListViewModel : ViewModel() {
lateinit var accountViewModel: AccountViewModel
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
}
fun listFlow() = accountViewModel.account.peopleLists.uiListFlow
fun cloneItem(
followSet: PeopleList,
customName: String?,
customDescription: String?,
) {
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.cloneFollowSet(
currentPeopleList = followSet,
customCloneName = customName,
customCloneDescription = customDescription,
account = accountViewModel.account,
)
}
}
fun deleteItem(followSet: PeopleList) {
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.deleteFollowSet(
identifierTag = followSet.identifierTag,
account = accountViewModel.account,
)
}
}
}
@@ -0,0 +1,271 @@
/**
* 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.lists.list.metadata
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@Composable
fun FollowPackMetadataScreen(
selectedDTag: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val postViewModel: FollowPackMetadataViewModel = viewModel()
postViewModel.init(accountViewModel)
if (selectedDTag != null) {
LaunchedEffect(postViewModel) {
postViewModel.load(selectedDTag)
}
} else {
LaunchedEffect(postViewModel) {
postViewModel.new()
}
}
FollowPackMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
@Composable
private fun DialogContentPreview() {
val accountViewModel = mockAccountViewModel()
val postViewModel: FollowPackMetadataViewModel = viewModel()
postViewModel.init(accountViewModel)
ThemeComparisonRow {
FollowPackMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FollowPackMetadataScaffold(
postViewModel: FollowPackMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
FollowPackMetadataTopBar(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
},
) { pad ->
LazyColumn(
Modifier
.fillMaxSize()
.padding(
start = 10.dp,
end = 10.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding(),
) {
item {
SettingsCategory(
R.string.follow_pack_title,
R.string.follow_pack_explainer,
SettingsCategoryFirstModifier,
)
ListName(postViewModel)
Spacer(modifier = DoubleVertSpacer)
Picture(postViewModel, accountViewModel)
Spacer(modifier = DoubleVertSpacer)
Description(postViewModel)
}
}
}
}
@Composable
fun FollowPackMetadataTopBar(
postViewModel: FollowPackMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (postViewModel.isNewPack) {
CreatingTopBar(
titleRes = R.string.follow_pack_creation_dialog_title,
isActive = postViewModel::canPost,
onCancel = {
postViewModel.clear()
nav.popBack()
},
onPost = {
try {
postViewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
} else {
SavingTopBar(
titleRes = R.string.follow_pack_edit_list_metadata,
isActive = postViewModel::canPost,
onCancel = {
postViewModel.clear()
nav.popBack()
},
onPost = {
try {
postViewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
}
}
@Composable
private fun Description(postViewModel: FollowPackMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.follow_pack_creation_desc_label)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.description.value,
onValueChange = { postViewModel.description.value = it },
placeholder = {
Text(
text = stringRes(R.string.about_us),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
minLines = 3,
)
}
@Composable
private fun Picture(
postViewModel: FollowPackMetadataViewModel,
accountViewModel: AccountViewModel,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.picture_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
leadingIcon = {
val context = LocalContext.current
SelectSingleFromGallery(
isUploading = postViewModel.isUploadingImageForPicture,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 2.dp),
) {
postViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast)
}
},
)
}
@Composable
private fun ListName(postViewModel: FollowPackMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.follow_pack_creation_name_label)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.name.value,
onValueChange = { postViewModel.name.value = it },
placeholder = {
Text(
text = stringRes(R.string.follow_pack_copy_name_label),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@@ -0,0 +1,187 @@
/**
* 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.lists.list.metadata
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class FollowPackMetadataViewModel : ViewModel() {
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
var peopleList by mutableStateOf<PeopleList?>(null)
val isNewPack by derivedStateOf { peopleList == null }
val name = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
val description = mutableStateOf(TextFieldValue())
var isUploadingImageForPicture by mutableStateOf(false)
val canPost by derivedStateOf {
name.value.text.isNotBlank()
}
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun new() {
peopleList = null
clear()
}
fun load(dTag: String) {
peopleList = account.followLists.selectList(dTag)
name.value = TextFieldValue(peopleList?.title ?: "")
picture.value = TextFieldValue(peopleList?.image ?: "")
description.value = TextFieldValue(peopleList?.description ?: "")
}
fun createOrUpdate() {
accountViewModel.launchSigner {
val peopleList = peopleList
if (peopleList == null) {
val newListIdentifier =
accountViewModel.account.followLists.addFollowList(
name = name.value.text,
desc = description.value.text,
image = picture.value.text,
account = accountViewModel.account,
)
} else {
accountViewModel.account.followLists.updateMetadata(
name = name.value.text,
desc = description.value.text,
image = picture.value.text,
peopleList = peopleList,
account = accountViewModel.account,
)
}
clear()
}
}
fun clear() {
name.value = TextFieldValue()
picture.value = TextFieldValue()
description.value = TextFieldValue()
}
fun uploadForPicture(
uri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = { isUploadingImageForPicture = it },
onUploaded = { picture.value = TextFieldValue(it) },
onError = onError,
)
}
}
private suspend fun upload(
galleryUri: SelectedMedia,
context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
onUploading(true)
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
}
} catch (_: SignerExceptions.ReadOnlyException) {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload))
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
}
@@ -0,0 +1,271 @@
/**
* 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.lists.list.metadata
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@Composable
fun PeopleListMetadataScreen(
selectedDTag: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val postViewModel: PeopleListMetadataViewModel = viewModel()
postViewModel.init(accountViewModel)
if (selectedDTag != null) {
LaunchedEffect(postViewModel) {
postViewModel.load(selectedDTag)
}
} else {
LaunchedEffect(postViewModel) {
postViewModel.new()
}
}
PeopleListMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
@Composable
private fun DialogContentPreview() {
val accountViewModel = mockAccountViewModel()
val postViewModel: PeopleListMetadataViewModel = viewModel()
postViewModel.init(accountViewModel)
ThemeComparisonRow {
PeopleListMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PeopleListMetadataScaffold(
postViewModel: PeopleListMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
PeopleListMetadataTopBar(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
},
) { pad ->
LazyColumn(
Modifier
.fillMaxSize()
.padding(
start = 10.dp,
end = 10.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding(),
) {
item {
SettingsCategory(
R.string.people_list_title,
R.string.people_list_explainer,
SettingsCategoryFirstModifier,
)
ListName(postViewModel)
Spacer(modifier = DoubleVertSpacer)
Picture(postViewModel, accountViewModel)
Spacer(modifier = DoubleVertSpacer)
Description(postViewModel)
}
}
}
}
@Composable
fun PeopleListMetadataTopBar(
postViewModel: PeopleListMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (postViewModel.isNewList) {
CreatingTopBar(
titleRes = R.string.follow_set_creation_dialog_title,
isActive = postViewModel::canPost,
onCancel = {
postViewModel.clear()
nav.popBack()
},
onPost = {
try {
postViewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
} else {
SavingTopBar(
titleRes = R.string.follow_set_edit_list_metadata,
isActive = postViewModel::canPost,
onCancel = {
postViewModel.clear()
nav.popBack()
},
onPost = {
try {
postViewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
}
}
@Composable
private fun Description(postViewModel: PeopleListMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.follow_set_creation_desc_label)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.description.value,
onValueChange = { postViewModel.description.value = it },
placeholder = {
Text(
text = stringRes(R.string.about_us),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
minLines = 3,
)
}
@Composable
private fun Picture(
postViewModel: PeopleListMetadataViewModel,
accountViewModel: AccountViewModel,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.picture_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
leadingIcon = {
val context = LocalContext.current
SelectSingleFromGallery(
isUploading = postViewModel.isUploadingImageForPicture,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 2.dp),
) {
postViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast)
}
},
)
}
@Composable
private fun ListName(postViewModel: PeopleListMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.follow_set_creation_name_label)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.name.value,
onValueChange = { postViewModel.name.value = it },
placeholder = {
Text(
text = stringRes(R.string.follow_set_copy_name_label),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@@ -0,0 +1,188 @@
/**
* 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.lists.list.metadata
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class PeopleListMetadataViewModel : ViewModel() {
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
var peopleList by mutableStateOf<PeopleList?>(null)
val isNewList by derivedStateOf { peopleList == null }
val name = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
val description = mutableStateOf(TextFieldValue())
var isUploadingImageForPicture by mutableStateOf(false)
val canPost by derivedStateOf {
name.value.text.isNotBlank()
}
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun new() {
peopleList = null
clear()
}
fun load(dTag: String) {
peopleList = account.peopleLists.selectList(dTag)
name.value = TextFieldValue(peopleList?.title ?: "")
picture.value = TextFieldValue(peopleList?.image ?: "")
description.value = TextFieldValue(peopleList?.description ?: "")
}
fun isNewChannel() = peopleList == null
fun createOrUpdate() {
accountViewModel.launchSigner {
val peopleList = peopleList
if (peopleList == null) {
accountViewModel.account.peopleLists.addFollowList(
listName = name.value.text,
listDescription = description.value.text,
listImage = picture.value.text,
account = accountViewModel.account,
)
} else {
accountViewModel.account.peopleLists.updateMetadata(
listName = name.value.text,
listDescription = description.value.text,
listImage = picture.value.text,
peopleList = peopleList,
account = accountViewModel.account,
)
}
clear()
}
}
fun clear() {
name.value = TextFieldValue()
picture.value = TextFieldValue()
description.value = TextFieldValue()
}
fun uploadForPicture(
uri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = { isUploadingImageForPicture = it },
onUploaded = { picture.value = TextFieldValue(it) },
onError = onError,
)
}
}
private suspend fun upload(
galleryUri: SelectedMedia,
context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
onUploading(true)
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
}
} catch (_: SignerExceptions.ReadOnlyException) {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload))
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
}
@@ -26,15 +26,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -49,13 +42,12 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewPeopleListCreationDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditPeopleListScreen(
fun FollowListAndPackAndUserScreen(
userToAddOrRemove: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
@@ -72,22 +64,19 @@ fun EditPeopleListScreen(
}
userBase?.let {
EditPeopleListScreen(it, accountViewModel, nav)
FollowListAndPackAndUserScreen(it, accountViewModel, nav)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditPeopleListScreen(
fun FollowListAndPackAndUserScreen(
userToAddOrRemove: User,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
modifier = Modifier.fillMaxSize().recalculateWindowInsets(),
floatingActionButton = {
PeopleListAndUserFab(accountViewModel)
},
topBar = {
val userName by observeUserName(userToAddOrRemove, accountViewModel)
TopBarWithBackButton(
@@ -105,45 +94,7 @@ fun EditPeopleListScreen(
).consumeWindowInsets(contentPadding)
.imePadding(),
) {
PeopleListAndUserView(userToAddOrRemove, accountViewModel, nav)
FollowListAndPackAndUserView(userToAddOrRemove, accountViewModel, nav)
}
}
}
@Composable
private fun PeopleListAndUserFab(accountViewModel: AccountViewModel) {
var isOpen by remember { mutableStateOf(false) }
ExtendedFloatingActionButton(
text = {
Text(text = stringRes(R.string.follow_set_create_btn_label))
},
icon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = null,
)
},
onClick = { isOpen = !isOpen },
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
)
if (isOpen) {
NewPeopleListCreationDialog(
onDismiss = {
isOpen = false
},
onCreateList = { name, description ->
accountViewModel.runIOCatching {
accountViewModel.account.peopleLists.addFollowList(
listName = name,
listDescription = description,
account = accountViewModel.account,
)
}
isOpen = false
},
)
}
}
@@ -0,0 +1,198 @@
/**
* 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.lists.memberEdit
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
@Composable
fun FollowListAndPackAndUserView(
userToAddOrRemove: User,
accountViewModel: AccountViewModel,
nav: INav,
) {
val followSetsState by accountViewModel.account.peopleLists.uiListFlow
.collectAsStateWithLifecycle()
val followPackFeedState by accountViewModel.account.followLists.uiListFlow
.collectAsStateWithLifecycle()
if (followSetsState.isEmpty() && followPackFeedState.isEmpty()) {
Column(
Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(text = stringRes(R.string.follow_set_empty_dialog_msg))
Spacer(modifier = StdVertSpacer)
}
} else {
val userName by observeUserName(userToAddOrRemove, accountViewModel)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
stickyHeader {
Row(
modifier = MaxWidthWithHorzPadding,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.follow_sets),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.follow_sets_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = { nav.nav(Route.PeopleListMetadataEdit()) },
)
}
}
itemsIndexed(followSetsState, key = { _, item -> item.identifierTag }) { _, list ->
PeopleListAndUserItem(
modifier = Modifier.fillMaxWidth(),
listHeader = list.title,
userName = userName,
userIsPrivateMember = list.privateMembers.contains(userToAddOrRemove),
userIsPublicMember = list.publicMembers.contains(userToAddOrRemove),
onRemoveUser = {
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.removeUserFromSet(
userToAddOrRemove,
isPrivate = list.privateMembers.contains(userToAddOrRemove),
list.identifierTag,
accountViewModel.account,
)
}
},
privateMemberSize = list.privateMembers.size,
publicMemberSize = list.publicMembers.size,
onClick = {
nav.nav(Route.MyPeopleListView(list.identifierTag))
},
onAddUserToList = { userShouldBePrivate ->
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.addUserToSet(
userToAddOrRemove,
list.identifierTag,
userShouldBePrivate,
accountViewModel.account,
)
}
},
)
HorizontalDivider(thickness = DividerThickness)
}
stickyHeader {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.discover_follows),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.discover_follows_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
NewListButton(
onClick = { nav.nav(Route.FollowPackMetadataEdit()) },
)
}
}
itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, list ->
FollowPackAndUserItem(
modifier = Modifier.fillMaxWidth(),
listHeader = list.title,
userName = userName,
isMember = list.publicMembers.contains(userToAddOrRemove),
onClick = {
nav.nav(Route.MyFollowPackView(list.identifierTag))
},
onRemoveUser = {
accountViewModel.launchSigner {
accountViewModel.account.followLists.removeUserFromSet(
userToAddOrRemove,
list.identifierTag,
accountViewModel.account,
)
}
},
memberSize = list.publicMembers.size,
onAddUserToList = {
accountViewModel.launchSigner {
accountViewModel.account.followLists.addUserToSet(
userToAddOrRemove,
list.identifierTag,
accountViewModel.account,
)
}
},
)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
@@ -0,0 +1,216 @@
/**
* 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.lists.memberEdit
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.RemoveCircleOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.DisplayParticipantNumberAndStatus
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50ModifierOffset10
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun FollowPackAndUserMemberPreview() {
ThemeComparisonColumn {
FollowPackAndUserItem(
modifier = Modifier.fillMaxWidth(),
listHeader = "list title",
userName = "User",
isMember = true,
memberSize = 2,
onAddUserToList = {},
onClick = {},
onRemoveUser = {},
)
}
}
@Preview
@Composable
fun FollowPackAndUserNotMemberPreview() {
ThemeComparisonColumn {
FollowPackAndUserItem(
modifier = Modifier.fillMaxWidth(),
listHeader = "list title",
userName = "User",
isMember = false,
memberSize = 2,
onAddUserToList = {},
onClick = {},
onRemoveUser = {},
)
}
}
@Composable
fun FollowPackAndUserItem(
modifier: Modifier = Modifier,
listHeader: String,
userName: String,
isMember: Boolean,
memberSize: Int,
onClick: () -> Unit,
onAddUserToList: () -> Unit,
onRemoveUser: () -> Unit,
) {
ListItem(
modifier = modifier.clickable(onClick = onClick),
headlineContent = {
Text(
text = listHeader,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
UserStatusInList(userName, isMember)
},
leadingContent = {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Outlined.Groups,
contentDescription = stringRes(R.string.follow_set_icon_description),
modifier = Size50ModifierOffset10,
)
DisplayParticipantNumberAndStatus(
modifier = Modifier.align(Alignment.BottomCenter),
privateMembersSize = 0,
publicMembersSize = memberSize,
)
}
},
trailingContent = {
UserAdditionOptions(isMember, onAddUserToList, onRemoveUser)
},
)
}
@Composable
private fun UserStatusInList(
userName: String,
isMember: Boolean,
) {
Row(
modifier = HalfHalfVertPadding,
horizontalArrangement = SpacedBy5dp,
verticalAlignment = Alignment.CenterVertically,
) {
val text =
if (isMember) {
stringRes(R.string.follow_set_public_presence_indicator, userName)
} else {
stringRes(R.string.follow_set_absence_indicator2, userName)
}
val icon =
if (isMember) {
Icons.Outlined.Public
} else {
Icons.Outlined.RemoveCircleOutline
}
Icon(
imageVector = icon,
contentDescription = text,
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = text,
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
}
}
@Composable
private fun UserAdditionOptions(
isUserInList: Boolean,
onAddUserToList: () -> Unit,
onRemoveUser: () -> Unit,
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
IconButton(
onClick = {
if (isUserInList) {
onRemoveUser()
} else {
onAddUserToList()
}
},
modifier =
Modifier
.background(
color =
if (isUserInList) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.primary
},
shape = RoundedCornerShape(percent = 80),
),
) {
if (isUserInList) {
Icon(
imageVector = Icons.Filled.PersonRemove,
contentDescription = stringRes(R.string.remove_user_from_the_list),
tint = MaterialTheme.colorScheme.onErrorContainer,
)
} else {
Icon(
imageVector = Icons.Filled.PersonAdd,
contentDescription = stringRes(R.string.add_user_to_the_list),
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -70,6 +71,7 @@ fun PeopleListAndUserMemberPreview() {
privateMemberSize = 3,
publicMemberSize = 2,
onAddUserToList = {},
onClick = {},
onRemoveUser = {},
)
}
@@ -88,6 +90,7 @@ fun PeopleListAndUserNotMemberPreview() {
privateMemberSize = 3,
publicMemberSize = 2,
onAddUserToList = {},
onClick = {},
onRemoveUser = {},
)
}
@@ -102,11 +105,12 @@ fun PeopleListAndUserItem(
userIsPublicMember: Boolean,
publicMemberSize: Int,
privateMemberSize: Int,
onClick: () -> Unit,
onAddUserToList: (shouldBePrivateMember: Boolean) -> Unit,
onRemoveUser: () -> Unit,
) {
ListItem(
modifier = modifier,
modifier = modifier.clickable(onClick = onClick),
headlineContent = {
Text(
text = listHeader,
@@ -139,7 +143,7 @@ fun PeopleListAndUserItem(
}
@Composable
fun UserStatusInList(
private fun UserStatusInList(
userName: String,
userIsPrivateMember: Boolean,
userIsPublicMember: Boolean,

Some files were not shown because too many files have changed in this diff Show More