Merge remote-tracking branch 'origin/labeled-bookmarks' into labeled-bookmarks
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+172
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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(),
|
||||
) {
|
||||
|
||||
+44
-39
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ class BlossomServerListState(
|
||||
return if (serverList != null && serverList.tags.isNotEmpty()) {
|
||||
BlossomServersEvent.updateRelayList(
|
||||
earlierVersion = serverList,
|
||||
relays = servers,
|
||||
servers = servers,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
+69
@@ -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())
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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 }
|
||||
|
||||
+38
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+27
-1
@@ -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())
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+6
-3
@@ -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) {
|
||||
|
||||
+12
-7
@@ -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
|
||||
}
|
||||
|
||||
+10
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -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) {
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ fun ErrorListPreview() {
|
||||
ErrorList(
|
||||
model = model,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) }
|
||||
|
||||
+8
-42
@@ -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)
|
||||
|
||||
|
||||
+71
-59
@@ -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)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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))
|
||||
|
||||
+2
-2
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ fun AddInboxRelayForDMCardPreview() {
|
||||
ThemeComparisonColumn {
|
||||
AddInboxRelayForDMCard(
|
||||
accountViewModel = mockAccountViewModel(),
|
||||
nav = EmptyNav,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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,
|
||||
) {
|
||||
|
||||
+67
-95
@@ -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())
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ fun RenderChannelDataPreview() {
|
||||
tags = EmptyTagList,
|
||||
bgColor = remember { mutableStateOf(Color.Transparent) },
|
||||
accountViewModel = mockAccountViewModel(),
|
||||
nav = EmptyNav,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ fun NewChatroomSubjectDialog(
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
accountViewModel.runIOCatching {
|
||||
accountViewModel.launchSigner {
|
||||
val template =
|
||||
ChatMessageEvent.build(
|
||||
message.value,
|
||||
|
||||
+5
-9
@@ -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)
|
||||
|
||||
+3
-3
@@ -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))
|
||||
|
||||
+3
-3
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ private fun DialogContentPreview() {
|
||||
ChannelMetadataScaffold(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-17
@@ -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()
|
||||
|
||||
+4
-8
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ fun EditFieldRow(
|
||||
nav: INav,
|
||||
) {
|
||||
BackHandler {
|
||||
accountViewModel.runIOCatching {
|
||||
accountViewModel.launchSigner {
|
||||
channelScreenModel.sendDraftSync()
|
||||
channelScreenModel.cancel()
|
||||
}
|
||||
|
||||
+1
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+49
-31
@@ -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)
|
||||
|
||||
+5
-15
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+3
-5
@@ -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
|
||||
|
||||
|
||||
+329
@@ -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,
|
||||
)
|
||||
}
|
||||
+118
@@ -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)
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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
|
||||
}
|
||||
}
|
||||
+152
@@ -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)
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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
|
||||
}
|
||||
}
|
||||
+54
@@ -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
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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
|
||||
}
|
||||
}
|
||||
+49
@@ -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() }
|
||||
}
|
||||
+42
@@ -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)
|
||||
}
|
||||
+68
@@ -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
|
||||
}
|
||||
+1
-1
@@ -57,7 +57,7 @@ fun AddOutboxRelayCardPreview() {
|
||||
ThemeComparisonColumn {
|
||||
AddInboxRelayCard(
|
||||
accountViewModel = mockAccountViewModel(),
|
||||
nav = EmptyNav,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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()
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+281
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
-279
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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,
|
||||
+404
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
-19
@@ -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,
|
||||
) {
|
||||
|
||||
+20
-105
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
+26
-71
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
+61
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+271
@@ -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),
|
||||
)
|
||||
}
|
||||
+187
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+271
@@ -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),
|
||||
)
|
||||
}
|
||||
+188
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-53
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+198
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+216
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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
Reference in New Issue
Block a user