From 5537208abb32896910ef32ccd7938b32387f01e1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 6 Jul 2023 10:11:25 -0400 Subject: [PATCH] - Adds Basic Support for Communities (172) - Adds Community discovery screens - Adds Public Chat discovery - Adds Community Follow/Unfollow - Renders Community Posts in feed - Creates a summary of verified participants in Communities/Chats/Streams - Restructures Hashtag Screen to the new Screen Building structure - Remembers scroll position in Live, Community and Chat discovery --- .../amethyst/LocalPreferences.kt | 4 + .../vitorpamplona/amethyst/ServiceManager.kt | 1 + .../vitorpamplona/amethyst/model/Account.kt | 23 ++ .../amethyst/model/LocalCache.kt | 16 +- .../com/vitorpamplona/amethyst/model/Note.kt | 39 ++- .../amethyst/model/ParticipantListBuilder.kt | 69 ++++ .../service/NostrCommunityDataSource.kt | 41 +++ .../service/NostrDiscoveryDataSource.kt | 95 ++++- .../service/model/BaseTextNoteEvent.kt | 2 +- .../service/model/ChannelHideMessageEvent.kt | 8 +- .../service/model/ChannelMessageEvent.kt | 8 +- .../service/model/ChannelMetadataEvent.kt | 5 +- .../service/model/ChannelMuteUserEvent.kt | 7 +- .../model/CommunityPostApprovalEvent.kt | 16 + .../amethyst/service/model/Event.kt | 19 + .../amethyst/service/model/EventInterface.kt | 6 + .../service/model/LiveActivitiesEvent.kt | 4 +- .../amethyst/ui/dal/CommunityFeedFilter.kt | 35 ++ .../amethyst/ui/dal/DiscoverChatFeedFilter.kt | 66 ++++ .../ui/dal/DiscoverCommunityFeedFilter.kt | 57 +++ ...eedFilter.kt => DiscoverLiveFeedFilter.kt} | 23 +- .../ui/dal/DiscoverLiveNowFeedFilter.kt | 2 +- .../amethyst/ui/dal/HashtagFeedFilter.kt | 9 +- .../ui/dal/HiddenAccountsFeedFilter.kt | 2 +- .../amethyst/ui/navigation/AppNavigation.kt | 23 +- .../amethyst/ui/navigation/DrawerContent.kt | 2 +- .../amethyst/ui/navigation/Routes.kt | 8 +- .../amethyst/ui/note/ChannelCardCompose.kt | 325 ++++++++++++++++-- .../amethyst/ui/note/NoteCompose.kt | 182 +++++++++- .../amethyst/ui/note/TimeAgoFormatter.kt | 1 + .../amethyst/ui/screen/FeedViewModel.kt | 44 ++- .../ui/screen/RememberForeverStates.kt | 7 +- .../amethyst/ui/screen/ThreadFeedView.kt | 14 +- .../ui/screen/loggedIn/ChannelScreen.kt | 52 ++- .../ui/screen/loggedIn/CommunityScreen.kt | 105 ++++++ .../ui/screen/loggedIn/DiscoverScreen.kt | 117 +++++-- .../ui/screen/loggedIn/HashtagScreen.kt | 99 +++--- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 28 +- app/src/main/res/values/strings.xml | 4 + 39 files changed, 1411 insertions(+), 157 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt rename app/src/main/java/com/vitorpamplona/amethyst/ui/dal/{DiscoverFeedFilter.kt => DiscoverLiveFeedFilter.kt} (71%) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index a0f66a65d..43124fee9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -43,6 +43,7 @@ private object PrefKeys { const val NOSTR_PRIVKEY = "nostr_privkey" const val NOSTR_PUBKEY = "nostr_pubkey" const val FOLLOWING_CHANNELS = "following_channels" + const val FOLLOWING_COMMUNITIES = "following_communities" const val HIDDEN_USERS = "hidden_users" const val RELAYS = "relays" const val DONT_TRANSLATE_FROM = "dontTranslateFrom" @@ -205,6 +206,7 @@ object LocalPreferences { account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) } account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) } putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels) + putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities) putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers) putString(PrefKeys.RELAYS, gson.toJson(account.localRelays)) putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom) @@ -247,6 +249,7 @@ object LocalPreferences { val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf() + val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf() val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf() val localRelays = gson.fromJson( getString(PrefKeys.RELAYS, "[]"), @@ -340,6 +343,7 @@ object LocalPreferences { val a = Account( loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()), followingChannels = followingChannels, + followingCommunities = followingCommunities, hiddenUsers = hiddenUsers, localRelays = localRelays, dontTranslateFrom = dontTranslateFrom, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 30a7b9de7..60091f2fb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -74,6 +74,7 @@ object ServiceManager { NostrHomeDataSource.start() NostrAccountDataSource.start() NostrChatroomListDataSource.start() + NostrDiscoveryDataSource.start() // More Info Data Sources NostrSingleEventDataSource.start() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e948889d8..48bbd487e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -50,6 +50,7 @@ val KIND3_FOLLOWS = " All Follows " class Account( val loggedIn: Persona, var followingChannels: Set = DefaultChannels, + var followingCommunities: Set = setOf(), var hiddenUsers: Set = setOf(), var localRelays: Set = Constants.defaultRelays.toSet(), var dontTranslateFrom: Set = getLanguagesSpokenByUser(), @@ -107,6 +108,10 @@ class Account( return followingChannels.mapNotNull { LocalCache.checkGetOrCreateChannel(it) } } + fun followingCommunities(): List { + return followingCommunities.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it) } + } + fun isWriteable(): Boolean { return loggedIn.privKey != null } @@ -857,6 +862,20 @@ class Account( saveable.invalidateData() } + fun joinCommunity(idHex: String) { + followingCommunities = followingCommunities + idHex + live.invalidateData() + + saveable.invalidateData() + } + + fun leaveCommunity(idHex: String) { + followingCommunities = followingCommunities - idHex + live.invalidateData() + + saveable.invalidateData() + } + fun hideUser(pubkeyHex: String) { hiddenUsers = hiddenUsers + pubkeyHex live.invalidateData() @@ -1153,6 +1172,10 @@ class Account( return user.pubkeyHex in followingKeySet() } + fun isFollowing(user: HexKey): Boolean { + return user in followingKeySet() + } + fun isAcceptable(note: Note): Boolean { return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author isAcceptableDirect(note) && diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 690566c73..d21aa85b2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -55,6 +55,10 @@ object LocalCache { return users.get(key) } + fun getAddressableNoteIfExists(key: String): AddressableNote? { + return addressables[key] + } + fun getNoteIfExists(key: String): Note? { return addressables[key] ?: notes[key] } @@ -681,16 +685,20 @@ object LocalCache { // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") val author = getOrCreateUser(event.pubKey) - val repliesTo = event.taggedAddresses().map { getOrCreateAddressableNote(it) } - note.loadEvent(event, author, repliesTo) + val communities = event.communities() + val eventsApproved = event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } + + val repliesTo = communities.map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, eventsApproved) // Prepares user's profile view. author.addNote(note) // Counts the replies repliesTo.forEach { - it.addReply(note) + it.addBoost(note) } refreshObservers(note) @@ -1181,7 +1189,7 @@ object LocalCache { } } - println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}") + println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept") } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 860a699af..57e050070 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -279,20 +279,55 @@ open class Note(val idHex: String) { } } - fun publicZapAuthors(): Set { + fun publicZapAuthors(): Set { + // Zaps who the requester was the user + return zaps.mapNotNull { + it.key.author + }.toSet() + } + + fun publicZapAuthorHexes(): Set { // Zaps who the requester was the user return zaps.mapNotNull { it.key.author?.pubkeyHex }.toSet() } - fun reactionAuthors(): Set { + fun reactionAuthors(): Set { + // Zaps who the requester was the user + return reactions.values.map { + it.mapNotNull { it.author } + }.flatten().toSet() + } + + fun reactionAuthorHexes(): Set { // Zaps who the requester was the user return reactions.values.map { it.mapNotNull { it.author?.pubkeyHex } }.flatten().toSet() } + fun replyAuthorHexes(): Set { + // Zaps who the requester was the user + return replies.mapNotNull { + it.author?.pubkeyHex + }.toSet() + } + + fun replyAuthors(): Set { + // Zaps who the requester was the user + return replies.mapNotNull { + it.author + }.toSet() + } + + fun boostAuthors(): Set { + // Zaps who the requester was the user + return boosts.mapNotNull { + it.author + }.toSet() + } + fun isReactedBy(user: User): String? { return reactions.filter { it.value.any { it.author?.pubkeyHex == user.pubkeyHex } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt new file mode 100644 index 000000000..9f7459b6c --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt @@ -0,0 +1,69 @@ +package com.vitorpamplona.amethyst.model + +class ParticipantListBuilder { + private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set?, set: MutableSet) { + baseNote.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author) + } + + // Breaks these searchers down to avoid the memory use of creating multiple lists + baseNote.replies.forEach { reply -> + reply.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author) + } + } + + baseNote.boosts.forEach { boost -> + boost.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author) + } + } + + baseNote.zaps.forEach { zapPair -> + zapPair.key.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author) + } + } + + baseNote.reactions.forEach { reactionSet -> + reactionSet.value.forEach { reaction -> + reaction.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author) + } + } + } + } + + fun followsThatParticipateOnDirect(baseNote: Note?, followingSet: Set?): Set { + if (baseNote == null) return mutableSetOf() + + val set = mutableSetOf() + addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set) + return set + } + + fun followsThatParticipateOn(baseNote: Note?, followingSet: Set?): Set { + if (baseNote == null) return mutableSetOf() + + val mySet = mutableSetOf() + addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet) + + baseNote.replies.forEach { + addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) + } + + LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach { + addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) + } + + return mySet + } + + fun countFollowsThatParticipateOn(baseNote: Note?, followingSet: Set?): Int { + if (baseNote == null) return 0 + + val list = followsThatParticipateOn(baseNote, followingSet) + + return list.size + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt new file mode 100644 index 000000000..77fbfcb90 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt @@ -0,0 +1,41 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent +import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter + +object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") { + private var communityToWatch: AddressableNote? = null + + private fun createLoadCommunityFilter(): TypedFilter? { + val myCommunityToWatch = communityToWatch ?: return null + + val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null + + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = JsonFilter( + authors = community.moderators().map { it.key }.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)), + tags = mapOf( + "a" to listOf(myCommunityToWatch.address.toTag()) + ), + kinds = listOf(CommunityPostApprovalEvent.kind), + limit = 500 + ) + ) + } + + val loadCommunityChannel = requestNewChannel() + + override fun updateChannelFilters() { + loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null } + } + + fun loadCommunity(note: AddressableNote?) { + communityToWatch = note + invalidateFilters() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt index 03187ef92..d502bc832 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -4,6 +4,8 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent +import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent import com.vitorpamplona.amethyst.service.relays.FeedType @@ -13,7 +15,7 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { lateinit var account: Account - fun createContextualFilter(): TypedFilter { + fun createLiveStreamFilter(): TypedFilter { val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) val followKeys = follows?.map { @@ -24,13 +26,47 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { types = setOf(FeedType.GLOBAL), filter = JsonFilter( authors = followKeys, - kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind, LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), - limit = 1000 + kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), + limit = 500 ) ) } - fun createFollowTagsFilter(): TypedFilter? { + fun createPublicChatFilter(): TypedFilter { + val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) + + val followKeys = follows?.map { + it.substring(0, 6) + } + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + authors = followKeys, + kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind), + limit = 500 + ) + ) + } + + fun createCommunitiesFilter(): TypedFilter { + val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) + + val followKeys = follows?.map { + it.substring(0, 6) + } + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + authors = followKeys, + kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind), + limit = 500 + ) + ) + } + + fun createLiveStreamTagsFilter(): TypedFilter? { val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) if (hashToLoad.isNullOrEmpty()) return null @@ -38,13 +74,51 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { return TypedFilter( types = setOf(FeedType.GLOBAL), filter = JsonFilter( - kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind, LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), + kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), tags = mapOf( "t" to hashToLoad.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }.flatten() ), - limit = 1000 + limit = 500 + ) + ) + } + + fun createPublicChatsTagsFilter(): TypedFilter? { + val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind), + tags = mapOf( + "t" to hashToLoad.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 500 + ) + ) + } + + fun createCommunitiesTagsFilter(): TypedFilter? { + val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind), + tags = mapOf( + "t" to hashToLoad.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 500 ) ) } @@ -52,6 +126,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { val discoveryFeedChannel = requestNewChannel() override fun updateChannelFilters() { - discoveryFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null } + discoveryFeedChannel.typedFilters = listOfNotNull( + createLiveStreamFilter(), + createPublicChatFilter(), + createCommunitiesFilter(), + createLiveStreamTagsFilter(), + createPublicChatsTagsFilter(), + createCommunitiesTagsFilter() + ).ifEmpty { null } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt index 009ed591a..6913cc980 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt @@ -113,7 +113,7 @@ open class BaseTextNoteEvent( fun tagsWithoutCitations(): List { val repliesTo = replyTos() - val tagAddresses = taggedAddresses().map { it.toTag() } + val tagAddresses = taggedAddresses().filter { it.kind != CommunityDefinitionEvent.kind }.map { it.toTag() } if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() val citations = findCitations() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt index b2ed4c4b4..825f9d1a2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt @@ -14,7 +14,13 @@ class ChannelHideMessageEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel { + override fun channel() = tags.firstOrNull { + it.size > 3 && it[0] == "e" && it[3] == "root" + }?.get(1) ?: tags.firstOrNull { + it.size > 1 && it[0] == "e" + }?.get(1) + fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } companion object { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt index cefaad710..6b71d8692 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt @@ -14,9 +14,9 @@ class ChannelMessageEvent( tags: List>, content: String, sig: HexKey -) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { +) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel { - fun channel() = tags.firstOrNull { + override fun channel() = tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) ?: tags.firstOrNull { it.size > 1 && it[0] == "e" @@ -65,3 +65,7 @@ class ChannelMessageEvent( } } } + +interface IsInPublicChatChannel { + open fun channel(): String? +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt index 6f188f823..94bb3585d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt @@ -15,8 +15,9 @@ class ChannelMetadataEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) +) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel { + + override fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) fun channelInfo() = try { MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt index 626dbf529..d1dd832c7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt @@ -14,7 +14,12 @@ class ChannelMuteUserEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel { + override fun channel() = tags.firstOrNull { + it.size > 3 && it[0] == "e" && it[3] == "root" + }?.get(1) ?: tags.firstOrNull { + it.size > 1 && it[0] == "e" + }?.get(1) fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/CommunityPostApprovalEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/CommunityPostApprovalEvent.kt index efdc82ba1..1b1804b3c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/CommunityPostApprovalEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/CommunityPostApprovalEvent.kt @@ -26,6 +26,22 @@ class CommunityPostApprovalEvent( null } + fun communities() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { + val aTag = ATag.parse(it[1], it.getOrNull(2)) + + if (aTag?.kind == CommunityDefinitionEvent.kind) { + aTag + } else { + null + } + } + + fun approvedEvents() = tags.filter { + it.size > 1 && (it[0] == "e" || (it[0] == "a" && ATag.parse(it[1], null)?.kind != CommunityDefinitionEvent.kind)) + }.map { + it[1] + } + companion object { const val kind = 4550 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 53eb5ac03..c7b60b80c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -71,10 +71,29 @@ open class Event( override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex } + override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex } + override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) } override fun isTaggedHashes(hashtags: Set) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags } override fun firstIsTaggedHashes(hashtags: Set) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1) + override fun firstIsTaggedAddressableNote(addressableNotes: Set) = tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1) + + override fun isTaggedAddressableKind(kind: Int): Boolean { + val kindStr = kind.toString() + return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) } + } + + override fun getTagOfAddressableKind(kind: Int): ATag? { + val kindStr = kind.toString() + val aTag = tags + .firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) } + ?.getOrNull(1) + ?: return null + + return ATag.parse(aTag, null) + } + override fun getPoWRank(): Int { var rank = 0 for (i in 0..id.length) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt index cd339a0e0..a874324b7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt @@ -27,10 +27,16 @@ interface EventInterface { fun hasValidSignature(): Boolean fun isTaggedUser(idHex: String): Boolean + fun isTaggedAddressableNote(idHex: String): Boolean fun isTaggedHash(hashtag: String): Boolean fun isTaggedHashes(hashtags: Set): Boolean fun firstIsTaggedHashes(hashtags: Set): String? + fun firstIsTaggedAddressableNote(addressableNotes: Set): String? + + fun isTaggedAddressableKind(kind: Int): Boolean + fun getTagOfAddressableKind(kind: Int): ATag? + fun hashtags(): List fun getReward(): BigDecimal? diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesEvent.kt index e7b67dc68..084e6a277 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesEvent.kt @@ -23,13 +23,13 @@ class LiveActivitiesEvent( fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1) - fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1) + fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)?.toLongOrNull() fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1) fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)) fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1) fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1) - fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) } + fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) } fun checkStatus(eventStatus: String?): String? { return if (eventStatus == STATUS_LIVE && createdAt < Date().time / 1000 - (60 * 60 * 8)) { // 2 hours { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt new file mode 100644 index 000000000..8a21bf43b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt @@ -0,0 +1,35 @@ +package com.vitorpamplona.amethyst.ui.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.service.model.CommunityPostApprovalEvent + +class CommunityFeedFilter(val note: AddressableNote, val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + note.idHex + } + + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { + return collection + .asSequence() + .filter { it.event is CommunityPostApprovalEvent } // Only Approvals + .filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // Of the given community + .mapNotNull { it.replyTo }.flatten() // get approved posts + .filter { it.isNewThread() } // check if it is a new thread + .toSet() + } + + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt new file mode 100644 index 000000000..0403cfa99 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt @@ -0,0 +1,66 @@ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.ParticipantListBuilder +import com.vitorpamplona.amethyst.service.model.* + +open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList + } + + override fun feed(): List { + val allChannelNotes = LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } + + val notes = innerApplyFilter(allChannelNotes) + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + protected open fun innerApplyFilter(collection: Collection): Set { + val now = System.currentTimeMillis() / 1000 + val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS + + val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet() + val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet() + + val createEvents = collection.filter { it.event is ChannelCreateEvent } + val anyOtherChannelEvent = collection + .asSequence() + .filter { it.event is IsInPublicChatChannel } + .mapNotNull { (it.event as? ChannelMessageEvent)?.channel() } + .mapNotNull { LocalCache.checkGetOrCreateNote(it) } + .toSet() + + val activities = (createEvents + anyOtherChannelEvent) + .asSequence() + .filter { it.event is ChannelCreateEvent } + .filter { isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true } + .filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() + + return activities + } + + override fun sort(collection: Set): List { + val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) + + val counter = ParticipantListBuilder() + + return collection.sortedWith( + compareBy( + { counter.countFollowsThatParticipateOn(it, followingKeySet) }, + { it.createdAt() }, + { it.idHex } + ) + ).reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt new file mode 100644 index 000000000..2c11d6f79 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt @@ -0,0 +1,57 @@ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.ParticipantListBuilder +import com.vitorpamplona.amethyst.service.model.* + +open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList + } + + override fun feed(): List { + val allNotes = LocalCache.addressables.values + + val notes = innerApplyFilter(allNotes) + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + protected open fun innerApplyFilter(collection: Collection): Set { + val now = System.currentTimeMillis() / 1000 + val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS + + val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet() + val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet() + + val activities = collection + .asSequence() + .filter { it.event is CommunityDefinitionEvent } + .filter { isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true } + .filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() + + return activities + } + + override fun sort(collection: Set): List { + val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) + val counter = ParticipantListBuilder() + + return collection.sortedWith( + compareBy( + { counter.countFollowsThatParticipateOn(it, followingKeySet) }, + { it.createdAt() }, + { it.idHex } + ) + ).reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt similarity index 71% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverFeedFilter.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt index e2556b550..88a05d32a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt @@ -4,18 +4,20 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.ParticipantListBuilder import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_ENDED import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED -open class DiscoverFeedFilter(val account: Account) : AdditiveFeedFilter() { +open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter() { override fun feedKey(): String { return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList } override fun feed(): List { - val allChannelNotes = LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } + val allChannelNotes = + LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten() val notes = innerApplyFilter(allChannelNotes + allMessageNotes) @@ -31,13 +33,19 @@ open class DiscoverFeedFilter(val account: Account) : AdditiveFeedFilter() val now = System.currentTimeMillis() / 1000 val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS - val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet() - val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet() + val followingKeySet = + account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet() + val followingTagSet = + account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet() val activities = collection .asSequence() .filter { it.event is LiveActivitiesEvent } - .filter { isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true } + .filter { + isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes( + followingTagSet + ) == true + } .filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } .filter { (it.createdAt() ?: 0) <= now } .toSet() @@ -46,9 +54,14 @@ open class DiscoverFeedFilter(val account: Account) : AdditiveFeedFilter() } override fun sort(collection: Set): List { + val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) + + val counter = ParticipantListBuilder() + return collection.sortedWith( compareBy( { convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) }, + { counter.countFollowsThatParticipateOn(it, followingKeySet) }, { it.createdAt() }, { it.idHex } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt index f670ef947..e506cf983 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt @@ -6,7 +6,7 @@ import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE -class DiscoverLiveNowFeedFilter(account: Account) : DiscoverFeedFilter(account) { +class DiscoverLiveNowFeedFilter(account: Account) : DiscoverLiveFeedFilter(account) { override fun innerApplyFilter(collection: Collection): Set { val allItems = super.innerApplyFilter(collection) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt index dbf22f15e..06d787aff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt @@ -8,19 +8,12 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object HashtagFeedFilter : AdditiveFeedFilter() { - lateinit var account: Account - var tag: String? = null +class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFilter() { override fun feedKey(): String { return account.userProfile().pubkeyHex + "-" + tag } - fun loadHashtag(account: Account, tag: String?) { - this.account = account - this.tag = tag - } - override fun feed(): List { return sort(innerApplyFilter(LocalCache.notes.values)) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt index 59c58f1cf..9532df08a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt @@ -6,7 +6,7 @@ import com.vitorpamplona.amethyst.model.User class HiddenAccountsFeedFilter(val account: Account) : FeedFilter() { override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + HashtagFeedFilter.tag + return account.userProfile().pubkeyHex } override fun feed(): List { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 73150d737..cabe963d9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -12,7 +12,9 @@ import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel @@ -22,6 +24,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen @@ -42,7 +45,9 @@ fun AppNavigation( knownFeedViewModel: NostrChatroomListKnownFeedViewModel, newFeedViewModel: NostrChatroomListNewFeedViewModel, videoFeedViewModel: NostrVideoFeedViewModel, - discoveryFeedViewModel: NostrDiscoverFeedViewModel, + discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, + discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, + discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, notifFeedViewModel: NotificationViewModel, userReactionsStatsModel: UserReactionsViewModel, @@ -109,7 +114,9 @@ fun AppNavigation( Route.Discover.let { route -> composable(route.route, route.arguments, content = { DiscoverScreen( - discoveryFeedViewModel = discoveryFeedViewModel, + discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, + discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, + discoveryChatFeedViewModel = discoveryChatFeedViewModel, accountViewModel = accountViewModel, nav = nav ) @@ -169,6 +176,16 @@ fun AppNavigation( }) } + Route.Community.let { route -> + composable(route.route, route.arguments, content = { + CommunityScreen( + aTagHex = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav + ) + }) + } + Route.Room.let { route -> composable(route.route, route.arguments, content = { ChatroomScreen( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index e44231d86..b115e5b56 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -582,7 +582,7 @@ fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) { Text( modifier = Modifier.padding(start = 16.dp), - text = stringResource(id = R.string.relays), + text = stringResource(id = R.string.relay_setup), fontSize = 18.sp ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 750eeaea6..a1a8399ba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -104,6 +104,12 @@ sealed class Route( arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() ) + object Community : Route( + route = "Community/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() + ) + object Room : Route( route = "Room/{id}", icon = R.drawable.ic_moments, @@ -195,7 +201,7 @@ object DiscoverLatestItem : LatestItem() { ): Boolean { checkNotInMainThread() - val lastTime = account.loadLastRead(Route.Discover.base) + val lastTime = account.loadLastRead(Route.Discover.base + "Live") val newestItem = updateNewestItem(newNotes, account, DiscoverLiveNowFeedFilter(account)) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 2aaaf0be1..ff39cbf50 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -3,15 +3,20 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.animation.Crossfade import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -23,20 +28,29 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.BottomStart +import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.TopEnd import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.ParticipantListBuilder import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.OnlineChecker +import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent +import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_ENDED import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE @@ -48,9 +62,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor +import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf @@ -300,7 +318,7 @@ fun InnerChannelCardWithReactions( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - Column() { + Column(StdPadding) { RenderNoteRow( baseNote, accountViewModel, @@ -319,6 +337,12 @@ private fun RenderNoteRow( is LiveActivitiesEvent -> { RenderLiveActivityThumb(baseNote, accountViewModel, nav) } + is CommunityDefinitionEvent -> { + RenderCommunitiesThumb(baseNote, accountViewModel, nav) + } + is ChannelCreateEvent -> { + RenderChannelThumb(baseNote, accountViewModel, nav) + } } } @@ -339,6 +363,7 @@ fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, val content = remember(eventUpdates) { noteEvent.summary() } val participants = remember(eventUpdates) { noteEvent.participants() } val status = remember(eventUpdates) { noteEvent.status() } + val starts = remember(eventUpdates) { noteEvent.starts() } var isOnline by remember { mutableStateOf(false) } @@ -359,29 +384,31 @@ fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, LaunchedEffect(key1 = eventUpdates) { launch(Dispatchers.IO) { - val channel = LocalCache.getChannelIfExists(baseNote.idHex) - val repliesByFollows = channel?.notes?.mapNotNull { it.value.author?.pubkeyHex }?.toSet() ?: emptySet() - - val zappers = baseNote.publicZapAuthors() - val likeAuthors = baseNote.reactionAuthors() - - val allParticipants = (repliesByFollows + zappers + likeAuthors).filter { accountViewModel.isFollowing(it) } - - val newParticipantUsers = ( - participants.mapNotNull { part -> - if (part.key != baseNote.author?.pubkeyHex) { - LocalCache.checkGetOrCreateUser(part.key) - } else { - null - } - } + allParticipants.mapNotNull { - if (it != baseNote.author?.pubkeyHex) { - LocalCache.checkGetOrCreateUser(it) - } else { - null - } + val hosts = participants.mapNotNull { part -> + if (part.key != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(part.key) + } else { + null } - ).toImmutableList() + } + + val hostsAuthor = hosts + ( + baseNote.author?.let { + listOf(it) + } ?: emptyList() + ) + + val followingKeySet = accountViewModel.account.selectedUsersFollowList(accountViewModel.account.defaultDiscoveryFollowList) + val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hostsAuthor) + + val newParticipantUsers = if (followingKeySet == null) { + val allFollows = accountViewModel.account.selectedUsersFollowList(KIND3_FOLLOWS) + val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hostsAuthor) + + (hosts + followingParticipants + (allParticipants - followingParticipants)).toImmutableList() + } else { + (hosts + allParticipants).toImmutableList() + } if (!equalImmutableLists(newParticipantUsers, participantUsers)) { participantUsers = newParticipantUsers @@ -429,7 +456,7 @@ fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, EndedFlag() } STATUS_PLANNED -> { - ScheduledFlag() + ScheduledFlag(starts) } else -> { EndedFlag() @@ -438,13 +465,13 @@ fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, } } - Box(Modifier.padding(10.dp).align(BottomStart)) { + Box( + Modifier + .padding(10.dp) + .align(BottomStart) + ) { if (participantUsers.isNotEmpty()) { - FlowRow() { - participantUsers.forEach { - ClickableUserPicture(it, Size35dp, accountViewModel) - } - } + Gallery(participantUsers, accountViewModel) } } } @@ -463,6 +490,244 @@ fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val noteEvent = baseNote.event as? CommunityDefinitionEvent ?: return + + val eventUpdates by baseNote.live().metadata.observeAsState() + + val name = remember(eventUpdates) { noteEvent.dTag() } + val description = remember(eventUpdates) { noteEvent.description() } + val cover by remember(eventUpdates) { + derivedStateOf { + noteEvent.image()?.ifBlank { null } + } + } + val moderators = remember(eventUpdates) { noteEvent.moderators() } + + var participantUsers by remember { + mutableStateOf>( + persistentListOf() + ) + } + + LaunchedEffect(key1 = eventUpdates) { + launch(Dispatchers.IO) { + val hosts = moderators.mapNotNull { part -> + if (part.key != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(part.key) + } else { + null + } + } + + val followingKeySet = accountViewModel.account.selectedUsersFollowList(accountViewModel.account.defaultDiscoveryFollowList) + val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) + + val newParticipantUsers = if (followingKeySet == null) { + val allFollows = accountViewModel.account.selectedUsersFollowList(KIND3_FOLLOWS) + val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) + + (hosts + followingParticipants + (allParticipants - followingParticipants)).toImmutableList() + } else { + (hosts + allParticipants).toImmutableList() + } + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + Row(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth(0.3f) + .aspectRatio(ratio = 1f) + ) { + cover?.let { + Box(contentAlignment = BottomStart) { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(QuoteBorder) + ) + } + } ?: run { + baseNote.author?.let { + DisplayAuthorBanner(it) + } + } + } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Row() { + Text( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + description?.let { + Spacer(modifier = StdVertSpacer) + Row() { + Text( + text = it, + color = MaterialTheme.colors.placeholderText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp + ) + } + } + + if (participantUsers.isNotEmpty()) { + Spacer(modifier = StdVertSpacer) + Row(modifier = Modifier.fillMaxWidth()) { + Gallery(participantUsers, accountViewModel) + } + } + } + } +} + +@Composable +fun RenderChannelThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val noteEvent = baseNote.event as? ChannelCreateEvent ?: return + + LoadChannel(baseChannelHex = baseNote.idHex) { + RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav) + } +} + +@Composable +fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val channelUpdates by channel.live.observeAsState() + + val name = remember(channelUpdates) { channel.toBestDisplayName() } + val description = remember(channelUpdates) { channel.summary() } + val cover by remember(channelUpdates) { + derivedStateOf { + channel.profilePicture()?.ifBlank { null } + } + } + + var participantUsers by remember(baseNote) { + mutableStateOf>( + persistentListOf() + ) + } + + LaunchedEffect(key1 = channelUpdates) { + launch(Dispatchers.IO) { + val followingKeySet = accountViewModel.account.selectedUsersFollowList(accountViewModel.account.defaultDiscoveryFollowList) + val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).toImmutableList() + + val newParticipantUsers = if (followingKeySet == null) { + val allFollows = accountViewModel.account.selectedUsersFollowList(KIND3_FOLLOWS) + val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() + + (followingParticipants + (allParticipants - followingParticipants)).toImmutableList() + } else { + allParticipants.toImmutableList() + } + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + Row(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth(0.3f) + .aspectRatio(ratio = 1f) + ) { + cover?.let { + Box(contentAlignment = BottomStart) { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(QuoteBorder) + ) + } + } ?: run { + baseNote.author?.let { + DisplayAuthorBanner(it) + } + } + } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.fillMaxWidth().fillMaxHeight() + ) { + Row() { + Text( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + description?.let { + Spacer(modifier = StdVertSpacer) + Row() { + Text( + text = it, + color = MaterialTheme.colors.placeholderText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp + ) + } + } + + if (participantUsers.isNotEmpty()) { + Spacer(modifier = StdVertSpacer) + Row() { + Gallery(participantUsers, accountViewModel) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun Gallery(users: List, accountViewModel: AccountViewModel) { + FlowRow(verticalAlignment = CenterVertically) { + users.take(6).forEach { + ClickableUserPicture(it, Size35dp, accountViewModel) + } + + if (users.size > 6) { + Text( + text = remember(users) { " + " + (showCount(users.size - 6)).toString() }, + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface + ) + } + } +} + @Composable fun DisplayAuthorBanner(author: User) { val picture by author.live().metadata.map { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 9b08d82e0..a676d0ea4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -94,6 +94,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserMetadata import com.vitorpamplona.amethyst.service.OnlineChecker +import com.vitorpamplona.amethyst.service.model.ATag import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent @@ -102,6 +103,7 @@ import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.model.GenericRepostEvent @@ -147,6 +149,8 @@ import com.vitorpamplona.amethyst.ui.components.imageExtensions import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag @@ -156,6 +160,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.HalfPadding +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer @@ -170,6 +175,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdStartPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight @@ -432,6 +438,12 @@ fun NormalNote( accountViewModel = accountViewModel, nav = nav ) + is CommunityDefinitionEvent -> CommunityHeader( + baseNote = baseNote, + showBottomDiviser = true, + accountViewModel = accountViewModel, + nav = nav + ) is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) is FileHeaderEvent -> FileHeaderDisplay(baseNote) is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote) @@ -456,6 +468,113 @@ fun NormalNote( } } +@Composable +fun CommunityHeader( + baseNote: Note, + showBottomDiviser: Boolean, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val scope = rememberCoroutineScope() + Column( + Modifier + .fillMaxWidth() + .clickable { + scope.launch { + nav("Community/${baseNote.idHex}") + } + } + ) { + val channelState by baseNote.live().metadata.observeAsState() + val noteEvent = remember(channelState) { channelState?.note?.event as? CommunityDefinitionEvent } ?: return + + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + noteEvent.image()?.let { + RobohashAsyncImageProxy( + robot = baseNote.idHex, + model = it, + contentDescription = stringResource(R.string.profile_image), + contentScale = ContentScale.Crop, + modifier = Modifier.padding(start = 10.dp) + .width(Size35dp) + .height(Size35dp) + .clip(shape = CircleShape) + ) + } + + Column( + modifier = Modifier + .padding(start = 10.dp) + .weight(1f), + verticalArrangement = Arrangement.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = remember(channelState) { noteEvent.dTag() }, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + val summary = remember(channelState) { + noteEvent.description()?.ifBlank { null } + } + + if (summary != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = summary, + color = MaterialTheme.colors.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 12.sp + ) + } + } + } + + Row( + modifier = Modifier + .height(Size35dp) + .padding(start = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CommunityActionOptions(baseNote, accountViewModel, nav) + } + } + } + + if (showBottomDiviser) { + Divider( + thickness = 0.25.dp + ) + } + } +} + +@Composable +private fun CommunityActionOptions( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val isFollowing by remember(accountState) { + derivedStateOf { + accountState?.account?.followingCommunities?.contains(note.idHex) ?: false + } + } + + if (isFollowing) { + LeaveCommunityButton(accountViewModel, note, nav) + } else { + JoinCommunityButton(accountViewModel, note, nav) + } +} + @Composable private fun CheckNewAndRenderNote( baseNote: Note, @@ -821,6 +940,8 @@ fun routeFor(note: Note, loggedIn: User): String? { } } else if (noteEvent is PrivateDmEvent) { return "Room/${noteEvent.talkingWith(loggedIn.pubkeyHex)}" + } else if (noteEvent is CommunityDefinitionEvent) { + return "Community/${note.idHex}" } else { return "Note/${note.idHex}" } @@ -849,7 +970,7 @@ fun RenderTextEvent( val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } val body = accountViewModel.decrypt(note) - if (subject != null) { + if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { "## $subject\n$body" } else { body @@ -1716,7 +1837,7 @@ private fun ReplyRow( } if (showReply) { - val replyingDirectlyTo = remember { note.replyTo?.lastOrNull() } + val replyingDirectlyTo = remember { note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.kind } } if (replyingDirectlyTo != null && unPackReply) { ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) Spacer(modifier = StdVertSpacer) @@ -1818,12 +1939,18 @@ fun FirstUserInfoRow( nav: (String) -> Unit ) { Row(verticalAlignment = CenterVertically, modifier = remember { UserNameRowHeight }) { - val isRepost by remember { + val isRepost by remember(baseNote) { derivedStateOf { baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent } } + val isCommunityPost by remember(baseNote) { + derivedStateOf { + baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.kind) == true + } + } + if (showAuthorPicture) { NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) Spacer(HalfPadding) @@ -1834,6 +1961,8 @@ fun FirstUserInfoRow( if (isRepost) { BoostedMark() + } else if (isCommunityPost) { + DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) } else { DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) } @@ -2142,6 +2271,19 @@ private fun LoadAndDisplayUser( } } +@Composable +fun DisplayFollowingCommunityInPost( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + Column(HalfStartPadding) { + Row(verticalAlignment = CenterVertically) { + DisplayCommunity(baseNote, nav) + } + } +} + @Composable fun DisplayFollowingHashtagsInPost( baseNote: Note, @@ -2192,6 +2334,37 @@ private fun DisplayTagList(firstTag: String, nav: (String) -> Unit) { ) } +@Composable +private fun DisplayCommunity(note: Note, nav: (String) -> Unit) { + val communityTag = remember(note) { + note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.kind) + } ?: return + + val displayTag = remember(note) { AnnotatedString(getCommunityShortName(communityTag)) } + val route = remember(note) { "Community/${communityTag.toTag()}" } + + ClickableText( + text = displayTag, + onClick = { nav(route) }, + style = LocalTextStyle.current.copy( + color = MaterialTheme.colors.primary.copy( + alpha = 0.52f + ) + ), + maxLines = 1 + ) +} + +private fun getCommunityShortName(communityTag: ATag): String { + val name = if (communityTag.dTag.length > 10) { + communityTag.dTag.take(10) + "..." + } else { + communityTag.dTag.take(10) + } + + return "/c/$name" +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayUncitedHashtags( @@ -2663,6 +2836,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo val content = remember(eventUpdates) { noteEvent.summary() } val participants = remember(eventUpdates) { noteEvent.participants() } val status = remember(eventUpdates) { noteEvent.status() } + val starts = remember(eventUpdates) { noteEvent.starts() } var isOnline by remember { mutableStateOf(false) } @@ -2698,7 +2872,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo } } STATUS_PLANNED -> { - ScheduledFlag() + ScheduledFlag(starts) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 7ba20cf64..e6387f3e6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -22,6 +22,7 @@ fun timeAgo(mills: Long?, context: Context): String { .replace(" hr. ago", context.getString(R.string.h)) .replace(" min. ago", context.getString(R.string.m)) .replace(" days ago", context.getString(R.string.d)) + .replace("Yesterday", "1" + context.getString(R.string.d)) } fun timeAgoShort(mills: Long?, context: Context): String { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 31d4014a5..c632d9e5c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -21,7 +22,10 @@ import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DiscoverFeedFilter +import com.vitorpamplona.amethyst.ui.dal.CommunityFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DiscoverChatFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DiscoverCommunityFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter @@ -66,10 +70,26 @@ class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFil } } -class NostrDiscoverFeedViewModel(val account: Account) : FeedViewModel(DiscoverFeedFilter(account)) { +class NostrDiscoverLiveFeedViewModel(val account: Account) : FeedViewModel(DiscoverLiveFeedFilter(account)) { class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrDiscoverFeedViewModel { - return NostrDiscoverFeedViewModel(account) as NostrDiscoverFeedViewModel + override fun create(modelClass: Class): NostrDiscoverLiveFeedViewModel { + return NostrDiscoverLiveFeedViewModel(account) as NostrDiscoverLiveFeedViewModel + } + } +} + +class NostrDiscoverCommunityFeedViewModel(val account: Account) : FeedViewModel(DiscoverCommunityFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDiscoverCommunityFeedViewModel { + return NostrDiscoverCommunityFeedViewModel(account) as NostrDiscoverCommunityFeedViewModel + } + } +} + +class NostrDiscoverChatFeedViewModel(val account: Account) : FeedViewModel(DiscoverChatFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDiscoverChatFeedViewModel { + return NostrDiscoverChatFeedViewModel(account) as NostrDiscoverChatFeedViewModel } } } @@ -97,7 +117,21 @@ class NostrUserProfileConversationsFeedViewModel(val user: User, val account: Ac } } -class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter) +class NostrHashtagFeedViewModel(val hashtag: String, val account: Account) : FeedViewModel(HashtagFeedFilter(hashtag, account)) { + class Factory(val hashtag: String, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrHashtagFeedViewModel { + return NostrHashtagFeedViewModel(hashtag, account) as NostrHashtagFeedViewModel + } + } +} + +class NostrCommunityFeedViewModel(val note: AddressableNote, val account: Account) : FeedViewModel(CommunityFeedFilter(note, account)) { + class Factory(val note: AddressableNote, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrCommunityFeedViewModel { + return NostrCommunityFeedViewModel(note, account) as NostrCommunityFeedViewModel + } + } +} class NostrUserProfileReportFeedViewModel(val user: User) : FeedViewModel(UserProfileReportsFeedFilter(user)) { class Factory(val user: User) : ViewModelProvider.Factory { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt index 5d67de16d..575646ca0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt @@ -20,10 +20,15 @@ object ScrollStateKeys { const val DISCOVER_SCREEN = "Discover" val HOME_FOLLOWS = Route.Home.base + "Follows" val HOME_REPLIES = Route.Home.base + "FollowsReplies" + + val DISCOVER_LIVE = Route.Home.base + "Live" + val DISCOVER_COMMUNITY = Route.Home.base + "Communities" + val DISCOVER_CHATS = Route.Home.base + "Chats" } object PagerStateKeys { - const val HOME_SCREEN = "Home" + const val HOME_SCREEN = "PagerHome" + const val DISCOVER_SCREEN = "PagerDiscover" } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 796b7822c..6ba967721 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -33,6 +33,7 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -59,6 +60,7 @@ import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.model.GenericRepostEvent @@ -295,7 +297,17 @@ fun NoteMaster( Row(verticalAlignment = Alignment.CenterVertically) { NoteUsernameDisplay(baseNote, Modifier.weight(1f)) - DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) + val isCommunityPost by remember(baseNote) { + derivedStateOf { + baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.kind) == true + } + } + + if (isCommunityPost) { + DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) + } else { + DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) + } Text( timeAgo(note.createdAt(), context = context), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index d0bd0ae9c..a59be55b6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -98,6 +98,7 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -703,9 +704,9 @@ private fun ChannelActionOptions( } if (isFollowing) { - LeaveButton(accountViewModel, channel, nav) + LeaveChatButton(accountViewModel, channel, nav) } else { - JoinButton(accountViewModel, channel, nav) + JoinChatButton(accountViewModel, channel, nav) } } @@ -784,9 +785,12 @@ fun OfflineFlag() { } @Composable -fun ScheduledFlag() { +fun ScheduledFlag(starts: Long?) { + val context = LocalContext.current + val startsIn = starts?.let { timeAgo(it, context) } + Text( - text = stringResource(id = R.string.live_stream_planned_tag), + text = startsIn ?: stringResource(id = R.string.live_stream_planned_tag), color = Color.White, fontWeight = FontWeight.Bold, modifier = remember { @@ -864,7 +868,7 @@ private fun EditButton(accountViewModel: AccountViewModel, channel: PublicChatCh } @Composable -private fun JoinButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) { +fun JoinChatButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) { Button( modifier = Modifier.padding(horizontal = 3.dp), onClick = { @@ -882,7 +886,7 @@ private fun JoinButton(accountViewModel: AccountViewModel, channel: Channel, nav } @Composable -private fun LeaveButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) { +fun LeaveChatButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) { Button( modifier = Modifier.padding(horizontal = 3.dp), onClick = { @@ -899,3 +903,39 @@ private fun LeaveButton(accountViewModel: AccountViewModel, channel: Channel, na Text(text = stringResource(R.string.leave), color = Color.White) } } + +@Composable +fun JoinCommunityButton(accountViewModel: AccountViewModel, note: Note, nav: (String) -> Unit) { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + accountViewModel.account.joinCommunity(note.idHex) + }, + shape = ButtonBorder, + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ), + contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) + ) { + Text(text = stringResource(R.string.join), color = Color.White) + } +} + +@Composable +fun LeaveCommunityButton(accountViewModel: AccountViewModel, note: Note, nav: (String) -> Unit) { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + accountViewModel.account.leaveCommunity((note.idHex)) + }, + shape = ButtonBorder, + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ), + contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) + ) { + Text(text = stringResource(R.string.leave), color = Color.White) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt new file mode 100644 index 000000000..59131c16e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt @@ -0,0 +1,105 @@ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.NostrCommunityDataSource +import com.vitorpamplona.amethyst.ui.note.CommunityHeader +import com.vitorpamplona.amethyst.ui.screen.NostrCommunityFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun CommunityScreen(aTagHex: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + if (aTagHex == null) return + + var noteDefBase by remember { mutableStateOf(LocalCache.getAddressableNoteIfExists(aTagHex)) } + + if (noteDefBase == null) { + LaunchedEffect(aTagHex) { + // waits to resolve. + launch(Dispatchers.IO) { + val newNote = LocalCache.checkGetOrCreateAddressableNote(aTagHex) + if (newNote != noteDefBase) { + noteDefBase = newNote + } + } + } + } + + noteDefBase?.let { + PrepareViewModelsCommunityScreen( + note = it, + accountViewModel = accountViewModel, + nav = nav + ) + } +} + +@Composable +fun PrepareViewModelsCommunityScreen(note: AddressableNote, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val followsFeedViewModel: NostrCommunityFeedViewModel = viewModel( + key = note.idHex + "CommunityFeedViewModel", + factory = NostrCommunityFeedViewModel.Factory( + note, + accountViewModel.account + ) + ) + + CommunityScreen(note, followsFeedViewModel, accountViewModel, nav) +} + +@Composable +fun CommunityScreen(note: AddressableNote, feedViewModel: NostrCommunityFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val lifeCycleOwner = LocalLifecycleOwner.current + + NostrCommunityDataSource.loadCommunity(note) + + LaunchedEffect(note) { + feedViewModel.invalidateData() + } + + DisposableEffect(accountViewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Community Start") + NostrCommunityDataSource.start() + feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Community Stop") + NostrCommunityDataSource.loadCommunity(null) + NostrCommunityDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + + Column(Modifier.fillMaxHeight()) { + CommunityHeader(baseNote = note, showBottomDiviser = true, accountViewModel = accountViewModel, nav = nav) + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt index bad60bcb8..60e393739 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt @@ -5,25 +5,36 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row 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.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.ChannelCardCompose @@ -32,20 +43,37 @@ import com.vitorpamplona.amethyst.ui.screen.FeedError import com.vitorpamplona.amethyst.ui.screen.FeedState import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.LoadingFeed -import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys import com.vitorpamplona.amethyst.ui.screen.RefresheableView import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +@OptIn(ExperimentalFoundationApi::class) @Composable fun DiscoverScreen( - discoveryFeedViewModel: NostrDiscoverFeedViewModel, + discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, + discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, + discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { val lifeCycleOwner = LocalLifecycleOwner.current - WatchAccountForDiscoveryScreen(discoveryViewModel = discoveryFeedViewModel, accountViewModel = accountViewModel) + val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) + + WatchAccountForDiscoveryScreen( + discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, + discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, + discoveryChatFeedViewModel = discoveryChatFeedViewModel, + accountViewModel = accountViewModel + ) DisposableEffect(accountViewModel) { val observer = LifecycleEventObserver { _, event -> @@ -53,10 +81,6 @@ fun DiscoverScreen( println("Discovery Start") NostrDiscoveryDataSource.start() } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Discovery Stop") - NostrDiscoveryDataSource.stop() - } } lifeCycleOwner.lifecycle.addObserver(observer) @@ -65,14 +89,56 @@ fun DiscoverScreen( } } + val tabs by remember(discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel) { + mutableStateOf( + listOf( + TabItem(R.string.discover_live, discoveryLiveFeedViewModel, Route.Discover.base + "Live", ScrollStateKeys.DISCOVER_LIVE), + TabItem(R.string.discover_community, discoveryCommunityFeedViewModel, Route.Discover.base + "Community", ScrollStateKeys.DISCOVER_COMMUNITY), + TabItem(R.string.discover_chat, discoveryChatFeedViewModel, Route.Discover.base + "Chats", ScrollStateKeys.DISCOVER_CHATS) + ).toImmutableList() + ) + } + Column(Modifier.fillMaxHeight()) { Column( modifier = Modifier.padding(vertical = 0.dp) ) { - RefresheableView(discoveryFeedViewModel, true) { - SaveableFeedState(discoveryFeedViewModel, scrollStateKey = ScrollStateKeys.DISCOVER_SCREEN) { listState -> - RenderDiscoverFeed(discoveryFeedViewModel, Route.Discover.base, accountViewModel, listState, nav) + DiscoverPages(pagerState, tabs, accountViewModel, nav) + } + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun DiscoverPages( + pagerState: PagerState, + tabs: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + TabRow( + backgroundColor = MaterialTheme.colors.background, + selectedTabIndex = pagerState.currentPage + ) { + val coroutineScope = rememberCoroutineScope() + + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + text = { + Text(text = stringResource(tab.resource)) + }, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(index) } } + ) + } + } + + HorizontalPager(pageCount = 3, state = pagerState) { page -> + RefresheableView(tabs[page].viewModel, true) { + SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState -> + RenderDiscoverFeed(tabs[page].viewModel, tabs[page].routeForLastRead, accountViewModel, listState, nav) } } } @@ -123,12 +189,19 @@ private fun RenderDiscoverFeed( } @Composable -fun WatchAccountForDiscoveryScreen(discoveryViewModel: NostrDiscoverFeedViewModel, accountViewModel: AccountViewModel) { +fun WatchAccountForDiscoveryScreen( + discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, + discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, + discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, + accountViewModel: AccountViewModel +) { val accountState by accountViewModel.accountLiveData.observeAsState() LaunchedEffect(accountViewModel, accountState?.account?.defaultDiscoveryFollowList) { NostrDiscoveryDataSource.resetFilters() - discoveryViewModel.checkKeysInvalidateDataAndSendToTop() + discoveryLiveFeedViewModel.checkKeysInvalidateDataAndSendToTop() + discoveryCommunityFeedViewModel.checkKeysInvalidateDataAndSendToTop() + discoveryChatFeedViewModel.checkKeysInvalidateDataAndSendToTop() } } @@ -150,16 +223,18 @@ private fun DiscoverFeedLoaded( ) { itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> val defaultModifier = remember { - Modifier.padding(10.dp).animateItemPlacement() + Modifier.fillMaxWidth().animateItemPlacement() } - ChannelCardCompose( - baseNote = item, - routeForLastRead = routeForLastRead, - modifier = defaultModifier, - accountViewModel = accountViewModel, - nav = nav - ) + Row(defaultModifier) { + ChannelCardCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + modifier = Modifier, + accountViewModel = accountViewModel, + nav = nav + ) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt index d905bb82b..32b469c57 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -29,61 +30,77 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrHashtagDataSource -import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.theme.HalfPadding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + if (tag == null) return + + PrepareViewModelsHashtagScreen(tag, accountViewModel, nav) +} + +@Composable +fun PrepareViewModelsHashtagScreen(tag: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val followsFeedViewModel: NostrHashtagFeedViewModel = viewModel( + key = tag + "HashtagFeedViewModel", + factory = NostrHashtagFeedViewModel.Factory( + tag, + accountViewModel.account + ) + ) + + HashtagScreen(tag, followsFeedViewModel, accountViewModel, nav) +} + +@Composable +fun HashtagScreen(tag: String, feedViewModel: NostrHashtagFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { val lifeCycleOwner = LocalLifecycleOwner.current - if (tag != null) { - HashtagFeedFilter.loadHashtag(accountViewModel.account, tag) - val feedViewModel: NostrHashtagFeedViewModel = viewModel() + NostrHashtagDataSource.loadHashtag(tag) - LaunchedEffect(tag) { - HashtagFeedFilter.loadHashtag(accountViewModel.account, tag) - NostrHashtagDataSource.loadHashtag(tag) - feedViewModel.invalidateData() - } + LaunchedEffect(tag) { + NostrHashtagDataSource.start() + feedViewModel.invalidateData() + } - DisposableEffect(accountViewModel) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hashtag Start") - HashtagFeedFilter.loadHashtag(accountViewModel.account, tag) - NostrHashtagDataSource.loadHashtag(tag) - NostrHashtagDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Hashtag Stop") - HashtagFeedFilter.loadHashtag(accountViewModel.account, null) - NostrHashtagDataSource.loadHashtag(null) - NostrHashtagDataSource.stop() - } + DisposableEffect(accountViewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Hashtag Start") + NostrHashtagDataSource.loadHashtag(tag) + NostrHashtagDataSource.start() + feedViewModel.invalidateData() } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) + if (event == Lifecycle.Event.ON_PAUSE) { + println("Hashtag Stop") + NostrHashtagDataSource.loadHashtag(null) + NostrHashtagDataSource.stop() } } - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - HashtagHeader(tag, accountViewModel) - RefresheableFeedView( - feedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav - ) - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + NostrHashtagDataSource.loadHashtag(null) + NostrHashtagDataSource.stop() + } + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + HashtagHeader(tag, accountViewModel) + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav + ) } } } @@ -93,7 +110,7 @@ fun HashtagHeader(tag: String, account: AccountViewModel, onClick: () -> Unit = Column( Modifier.clickable { onClick() } ) { - Column(modifier = Modifier.padding(12.dp)) { + Column(modifier = HalfPadding) { Row(verticalAlignment = Alignment.CenterVertically) { Column( modifier = Modifier diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 0af413902..6baec1ba5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -43,7 +43,9 @@ import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel @@ -93,9 +95,19 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun factory = NostrVideoFeedViewModel.Factory(accountViewModel.account) ) - val discoveryFeedViewModel: NostrDiscoverFeedViewModel = viewModel( - key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryFeedViewModel", - factory = NostrDiscoverFeedViewModel.Factory(accountViewModel.account) + val discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel = viewModel( + key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryLiveFeedViewModel", + factory = NostrDiscoverLiveFeedViewModel.Factory(accountViewModel.account) + ) + + val discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel = viewModel( + key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryCommunityFeedViewModel", + factory = NostrDiscoverCommunityFeedViewModel.Factory(accountViewModel.account) + ) + + val discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel = viewModel( + key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryChatFeedViewModel", + factory = NostrDiscoverChatFeedViewModel.Factory(accountViewModel.account) ) val notifFeedViewModel: NotificationViewModel = viewModel( @@ -137,7 +149,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun videoFeedViewModel.sendToTop() } Route.Discover.base -> { - discoveryFeedViewModel.sendToTop() + discoveryLiveFeedViewModel.sendToTop() + discoveryCommunityFeedViewModel.sendToTop() + discoveryChatFeedViewModel.sendToTop() } Route.Notification.base -> { notifFeedViewModel.invalidateDataAndSendToTop() @@ -186,7 +200,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun knownFeedViewModel = knownFeedViewModel, newFeedViewModel = newFeedViewModel, videoFeedViewModel = videoFeedViewModel, - discoveryFeedViewModel = discoveryFeedViewModel, + discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, + discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, + discoveryChatFeedViewModel = discoveryChatFeedViewModel, notifFeedViewModel = notifFeedViewModel, userReactionsStatsModel = userReactionsStatsModel, navController = navController, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49fb0670c..8fa571bf0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -472,4 +472,8 @@ Followed Tags Relays + + Live + Communities + Chats Groups