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

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

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