diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 2410ed699..c3ff33225 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -17,6 +17,7 @@ import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource import com.vitorpamplona.amethyst.service.NostrCommunityDataSource import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource +import com.vitorpamplona.amethyst.service.NostrGeohashDataSource import com.vitorpamplona.amethyst.service.NostrHashtagDataSource import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource @@ -97,6 +98,7 @@ object ServiceManager { NostrCommunityDataSource.stop() NostrHashtagDataSource.stop() + NostrGeohashDataSource.stop() NostrSearchEventOrUserDataSource.stop() NostrSingleChannelDataSource.stop() NostrSingleEventDataSource.stop() 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 e0a83abb7..5ec995425 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -174,6 +174,7 @@ class Account( val event = ContactListEvent.createFromScratch( followUsers = listOf(), followTags = listOf(), + followGeohashes = listOf(), followCommunities = listOf(), followEvents = DefaultChannels.toList(), relayUse = relays, @@ -453,6 +454,7 @@ class Account( ContactListEvent.createFromScratch( followUsers = listOf(Contact(user.pubkeyHex, null)), followTags = emptyList(), + followGeohashes = emptyList(), followCommunities = emptyList(), followEvents = DefaultChannels.toList(), relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, @@ -475,6 +477,7 @@ class Account( ContactListEvent.createFromScratch( followUsers = emptyList(), followTags = emptyList(), + followGeohashes = emptyList(), followCommunities = emptyList(), followEvents = DefaultChannels.toList().plus(channel.idHex), relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, @@ -498,6 +501,7 @@ class Account( ContactListEvent.createFromScratch( followUsers = emptyList(), followTags = emptyList(), + followGeohashes = emptyList(), followCommunities = listOf(community.address), followEvents = DefaultChannels.toList(), relayUse = relays, @@ -509,7 +513,7 @@ class Account( LocalCache.consume(event) } - fun follow(tag: String) { + fun followHashtag(tag: String) { if (!isWriteable()) return val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList) @@ -524,6 +528,34 @@ class Account( ContactListEvent.createFromScratch( followUsers = emptyList(), followTags = listOf(tag), + followGeohashes = emptyList(), + followCommunities = emptyList(), + followEvents = DefaultChannels.toList(), + relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, + privateKey = keyPair.privKey!! + ) + } + + Client.send(event) + LocalCache.consume(event) + } + + fun followGeohash(geohash: String) { + if (!isWriteable()) return + + val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList) + + val event = if (contactList != null) { + ContactListEvent.followGeohash( + contactList, + geohash, + keyPair.privKey!! + ) + } else { + ContactListEvent.createFromScratch( + followUsers = emptyList(), + followTags = emptyList(), + followGeohashes = listOf(geohash), followCommunities = emptyList(), followEvents = DefaultChannels.toList(), relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, @@ -552,7 +584,7 @@ class Account( } } - fun unfollow(tag: String) { + fun unfollowHashtag(tag: String) { if (!isWriteable()) return val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList) @@ -569,6 +601,23 @@ class Account( } } + fun unfollowGeohash(geohash: String) { + if (!isWriteable()) return + + val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList) + + if (contactList != null && contactList.tags.isNotEmpty()) { + val event = ContactListEvent.unfollowGeohash( + contactList, + geohash, + keyPair.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + } + fun unfollow(channel: Channel) { if (!isWriteable()) return @@ -1222,6 +1271,30 @@ class Account( } } + fun selectedGeohashesFollowList(listName: String?): Set? { + if (listName == GLOBAL_FOLLOWS) return null + if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingGeohashSet() + + val privKey = keyPair.privKey + + return if (listName != null) { + val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag() + val list = LocalCache.addressables[aTag] + if (list != null) { + val publicAddresses = list.event?.geohashes() ?: emptySet() + val privateAddresses = privKey?.let { + (list.event as? GeneralListEvent)?.privateGeohashes(it) + } ?: emptySet() + + (publicAddresses + privateAddresses).toSet() + } else { + emptySet() + } + } else { + emptySet() + } + } + fun selectedCommunitiesFollowList(listName: String?): Set? { if (listName == GLOBAL_FOLLOWS) return null if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingCommunitiesSet() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index ad630fdc6..e8e4dc59a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -249,6 +249,12 @@ class User(val pubkeyHex: String) { } ?: false } + fun isFollowingGeohashCached(geoTag: String): Boolean { + return latestContactList?.verifiedFollowGeohashSet?.let { + return geoTag.lowercase() in it + } ?: false + } + fun isFollowingCached(user: User): Boolean { return latestContactList?.verifiedFollowKeySet?.let { return user.pubkeyHex in it @@ -277,6 +283,10 @@ class User(val pubkeyHex: String) { return latestContactList?.verifiedFollowTagSet ?: emptySet() } + fun cachedFollowingGeohashSet(): Set { + return latestContactList?.verifiedFollowGeohashSet ?: emptySet() + } + fun cachedFollowingCommunitiesSet(): Set { return latestContactList?.verifiedFollowCommunitySet ?: emptySet() } 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 fbdf268bd..3bd03f69a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -92,6 +92,26 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { ) } + fun createLiveStreamGeohashesFilter(): TypedFilter? { + val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList) + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), + tags = mapOf( + "g" to hashToLoad.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList + ) + ) + } + fun createPublicChatsTagsFilter(): TypedFilter? { val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) @@ -112,6 +132,26 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { ) } + fun createPublicChatsGeohashesFilter(): TypedFilter? { + val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList) + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = JsonFilter( + kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind), + tags = mapOf( + "g" to hashToLoad.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList + ) + ) + } + fun createCommunitiesTagsFilter(): TypedFilter? { val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) @@ -132,6 +172,26 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { ) } + fun createCommunitiesGeohashesFilter(): TypedFilter? { + val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList) + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind), + tags = mapOf( + "g" to hashToLoad.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList + ) + ) + } + val discoveryFeedChannel = requestNewChannel() { time, relayUrl -> latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList, relayUrl, time) } @@ -143,7 +203,10 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { createCommunitiesFilter(), createLiveStreamTagsFilter(), createPublicChatsTagsFilter(), - createCommunitiesTagsFilter() + createCommunitiesTagsFilter(), + createCommunitiesGeohashesFilter(), + createPublicChatsGeohashesFilter(), + createLiveStreamGeohashesFilter() ).ifEmpty { null } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt new file mode 100644 index 000000000..4341e0462 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt @@ -0,0 +1,42 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PollNoteEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter + +object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") { + private var geohashToWatch: String? = null + + fun createLoadHashtagFilter(): TypedFilter? { + val hashToLoad = geohashToWatch ?: return null + + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = JsonFilter( + tags = mapOf( + "g" to listOf( + hashToLoad + ) + ), + kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind), + limit = 200 + ) + ) + } + + val loadGeohashChannel = requestNewChannel() + + override fun updateChannelFilters() { + loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } + } + + fun loadHashtag(tag: String?) { + geohashToWatch = tag + + invalidateFilters() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 53da14380..88eafe939 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -104,6 +104,26 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { ) } + fun createFollowGeohashesFilter(): TypedFilter? { + val hashToLoad = account.selectedGeohashesFollowList(account.defaultHomeFollowList) ?: emptySet() + + if (hashToLoad.isEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.FOLLOWS), + filter = JsonFilter( + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind), + tags = mapOf( + "g" to hashToLoad.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 100, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList + ) + ) + } + fun createFollowCommunitiesFilter(): TypedFilter? { val communitiesToLoad = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet() @@ -135,6 +155,11 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { } override fun updateChannelFilters() { - followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowCommunitiesFilter(), createFollowTagsFilter()).ifEmpty { null } + followAccountChannel.typedFilters = listOfNotNull( + createFollowAccountsFilter(), + createFollowCommunitiesFilter(), + createFollowTagsFilter(), + createFollowGeohashesFilter() + ).ifEmpty { null } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index 93edde4a7..b91f1b2c3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -51,11 +51,35 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") { ) } + fun createFollowGeohashesFilter(): TypedFilter? { + val hashToLoad = account.selectedGeohashesFollowList(account.defaultStoriesFollowList) + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), + tags = mapOf( + "g" to hashToLoad.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 100, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList + ) + ) + } + val videoFeedChannel = requestNewChannel() { time, relayUrl -> latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList, relayUrl, time) } override fun updateChannelFilters() { - videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null } + videoFeedChannel.typedFilters = listOfNotNull( + createContextualFilter(), + createFollowTagsFilter(), + createFollowGeohashesFilter() + ).ifEmpty { null } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt index b05724700..dc6b66c8a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt @@ -39,6 +39,10 @@ class ContactListEvent( unverifiedFollowTagSet().map { it.lowercase() }.toSet() } + val verifiedFollowGeohashSet: Set by lazy { + unverifiedFollowGeohashSet().map { it.lowercase() }.toSet() + } + val verifiedFollowCommunitySet: Set by lazy { unverifiedFollowAddressSet().toSet() } @@ -49,6 +53,7 @@ class ContactListEvent( fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) } fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) } + fun unverifiedFollowGeohashSet() = tags.filter { it[0] == "g" }.mapNotNull { it.getOrNull(1) } fun unverifiedFollowAddressSet() = tags.filter { it[0] == "a" }.mapNotNull { it.getOrNull(1) } @@ -82,6 +87,7 @@ class ContactListEvent( fun createFromScratch( followUsers: List, followTags: List, + followGeohashes: List, followCommunities: List, followEvents: List, relayUse: Map?, @@ -110,6 +116,8 @@ class ContactListEvent( } else { listOf("a", it.toTag()) } + } + followGeohashes.map { + listOf("g", it) } return create( @@ -164,6 +172,28 @@ class ContactListEvent( ) } + fun followGeohash(earlierVersion: ContactListEvent, hashtag: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent { + if (earlierVersion.isTaggedGeoHash(hashtag)) return earlierVersion + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = listOf("g", hashtag)), + privateKey = privateKey, + createdAt = createdAt + ) + } + + fun unfollowGeohash(earlierVersion: ContactListEvent, hashtag: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent { + if (!earlierVersion.isTaggedGeoHash(hashtag)) return earlierVersion + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag }, + privateKey = privateKey, + createdAt = createdAt + ) + } + fun followEvent(earlierVersion: ContactListEvent, idHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent { if (earlierVersion.isTaggedEvent(idHex)) return earlierVersion 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 59e015410..f81ab3e91 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 @@ -69,6 +69,7 @@ open class Event( } override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } + override fun geohashes() = tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] } override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) } @@ -81,7 +82,10 @@ open class Event( override fun isTaggedAddressableNotes(idHexes: Set) = tags.any { it.size > 1 && it[0] == "a" && it[1] in idHexes } override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) } + + override fun isTaggedGeoHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "g" && it[1].startsWith(hashtag, true) } override fun isTaggedHashes(hashtags: Set) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags } + override fun isTaggedGeoHashes(hashtags: Set) = tags.any { it.size > 1 && it[0] == "g" && 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) 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 39a6850ec..840a59773 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 @@ -34,7 +34,11 @@ interface EventInterface { fun isTaggedAddressableNotes(idHexes: Set): Boolean fun isTaggedHash(hashtag: String): Boolean + fun isTaggedGeoHash(hashtag: String): Boolean + fun isTaggedHashes(hashtags: Set): Boolean + fun isTaggedGeoHashes(hashtags: Set): Boolean + fun firstIsTaggedHashes(hashtags: Set): String? fun firstIsTaggedAddressableNote(addressableNotes: Set): String? @@ -42,6 +46,7 @@ interface EventInterface { fun getTagOfAddressableKind(kind: Int): ATag? fun hashtags(): List + fun geohashes(): List fun getReward(): BigDecimal? fun getPoWRank(): Int diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt index ee8ace2fb..84234bcb7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt @@ -61,6 +61,7 @@ abstract class GeneralListEvent( fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] } fun privateHashtags(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "t" }?.map { it[1] } + fun privateGeohashes(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "g" }?.map { it[1] } fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] } fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull { val aTagValue = it.getOrNull(1) 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 index 1990b7d47..dbb1ba1c8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt @@ -36,6 +36,7 @@ open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter() { + + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + tag + } + + 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 { + val myTag = tag ?: return emptySet() + + return collection + .asSequence() + .filter { + ( + it.event is TextNoteEvent || + it.event is LongTextNoteEvent || + it.event is ChannelMessageEvent || + it.event is PrivateDmEvent + ) && + it.event?.isTaggedGeoHash(myTag) == true + } + .filter { account.isAcceptable(it) } + .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/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index d977d0d46..f4f9f4120 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -35,6 +35,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter() val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet() val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet() + val followingGeoSet = account.selectedGeohashesFollowList(account.defaultHomeFollowList) ?: emptySet() val followingCommunities = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet() val oneMinuteInTheFuture = TimeUtils.now() + (1 * 60) // one minute in the future. @@ -51,7 +52,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter() .filter { it -> val noteEvent = it.event (noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) && - (isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) && + (isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedGeoHashes(followingGeoSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) && // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable (isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true) && ((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) && diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt index 7daf16abd..1ebc877db 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt @@ -33,11 +33,12 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter() { val followingKeySet = account.selectedUsersFollowList(account.defaultStoriesFollowList) ?: emptySet() val followingTagSet = account.selectedTagsFollowList(account.defaultStoriesFollowList) ?: emptySet() + val followingGeohashSet = account.selectedGeohashesFollowList(account.defaultStoriesFollowList) ?: emptySet() return collection .asSequence() .filter { it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent } - .filter { isGlobal || it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false) } + .filter { isGlobal || it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false) || (it.event?.isTaggedGeoHashes(followingGeohashSet) ?: false) } .filter { isHiddenList || account.isAcceptable(it) } .filter { it.createdAt()!! <= now } .toSet() 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 27a19c4a0..7ca5a5386 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 @@ -25,6 +25,7 @@ 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.GeoHashScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen @@ -178,6 +179,16 @@ fun AppNavigation( }) } + Route.Geohash.let { route -> + composable(route.route, route.arguments, content = { + GeoHashScreen( + tag = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav + ) + }) + } + Route.Community.let { route -> composable(route.route, route.arguments, content = { CommunityScreen( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 0da1f6aee..b8e0d82b0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource import com.vitorpamplona.amethyst.service.NostrCommunityDataSource import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource +import com.vitorpamplona.amethyst.service.NostrGeohashDataSource import com.vitorpamplona.amethyst.service.NostrHashtagDataSource import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource @@ -124,6 +125,7 @@ private fun RenderTopRouteBar( Route.Room.base -> NoTopBar() Route.Community.base -> NoTopBar() Route.Hashtag.base -> NoTopBar() + Route.Geohash.base -> NoTopBar() // Route.Profile.route -> TopBarWithBackButton(nav) Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel, nav) Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel, nav) @@ -492,6 +494,7 @@ fun debugState(context: Context) { NostrCommunityDataSource.printCounter() NostrDiscoveryDataSource.printCounter() NostrHashtagDataSource.printCounter() + NostrGeohashDataSource.printCounter() NostrHomeDataSource.printCounter() NostrSearchEventOrUserDataSource.printCounter() NostrSingleChannelDataSource.printCounter() 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 64ac30bff..8ba641d20 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 @@ -107,6 +107,12 @@ sealed class Route( arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() ) + object Geohash : Route( + route = "Geohash/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() + ) + object Community : Route( route = "Community/{id}", icon = R.drawable.ic_moments, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 672e7f9de..95efc9d3d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.nip05 +import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -115,6 +116,14 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie Crossfade(targetState = nip05, modifier = columnModifier) { if (it != null) { DisplayNIP05Line(it, baseUser, columnModifier) + } else { + Text( + text = baseUser.pubkeyDisplayHex(), + color = MaterialTheme.colors.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = columnModifier + ) } } } 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 0928f0f20..e172ab68d 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 @@ -2409,7 +2409,7 @@ fun SecondUserInfoRow( val geo = remember { noteEvent.getGeoHash() } if (geo != null) { - DisplayLocation(geo) + DisplayLocation(geo, nav) } val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } } @@ -2425,17 +2425,22 @@ fun SecondUserInfoRow( } @Composable -fun DisplayLocation(geohash: String) { +fun DisplayLocation(geohash: String, nav: (String) -> Unit) { val context = LocalContext.current val cityName = remember(geohash) { ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context) } - Text( - text = cityName ?: geohash, - color = MaterialTheme.colors.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, + ClickableText( + text = AnnotatedString(cityName ?: geohash), + onClick = { nav("Geohash/$geohash") }, + style = LocalTextStyle.current.copy( + color = MaterialTheme.colors.primary.copy( + alpha = 0.52f + ), + fontSize = Font14SP, + fontWeight = FontWeight.Bold + ), maxLines = 1 ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 64bff4d12..e5e3670a9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -85,7 +85,7 @@ private fun UserNameDisplay( } @Composable -private fun NPubDisplay(npubDisplay: String, modifier: Modifier) { +fun NPubDisplay(npubDisplay: String, modifier: Modifier) { Text( text = npubDisplay, fontWeight = FontWeight.Bold, 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 bfd230486..7ae02473c 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 @@ -27,6 +27,7 @@ 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.GeoHashFeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter @@ -125,6 +126,14 @@ class NostrHashtagFeedViewModel(val hashtag: String, val account: Account) : Fee } } +class NostrGeoHashFeedViewModel(val geohash: String, val account: Account) : FeedViewModel(GeoHashFeedFilter(geohash, account)) { + class Factory(val geohash: String, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrGeoHashFeedViewModel { + return NostrGeoHashFeedViewModel(geohash, account) as NostrGeoHashFeedViewModel + } + } +} + 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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt new file mode 100644 index 000000000..72ea8fca7 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt @@ -0,0 +1,203 @@ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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 +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import com.fonfon.kgeohash.toGeoHash +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.NostrGeohashDataSource +import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil +import com.vitorpamplona.amethyst.ui.screen.NostrGeoHashFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.theme.HalfPadding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun GeoHashScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + if (tag == null) return + + PrepareViewModelsGeoHashScreen(tag, accountViewModel, nav) +} + +@Composable +fun PrepareViewModelsGeoHashScreen(tag: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val followsFeedViewModel: NostrGeoHashFeedViewModel = viewModel( + key = tag + "GeoHashFeedViewModel", + factory = NostrGeoHashFeedViewModel.Factory( + tag, + accountViewModel.account + ) + ) + + GeoHashScreen(tag, followsFeedViewModel, accountViewModel, nav) +} + +@Composable +fun GeoHashScreen(tag: String, feedViewModel: NostrGeoHashFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val lifeCycleOwner = LocalLifecycleOwner.current + + NostrGeohashDataSource.loadHashtag(tag) + + LaunchedEffect(tag) { + NostrGeohashDataSource.start() + feedViewModel.invalidateData() + } + + DisposableEffect(accountViewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Hashtag Start") + NostrGeohashDataSource.loadHashtag(tag) + NostrGeohashDataSource.start() + feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Hashtag Stop") + NostrGeohashDataSource.loadHashtag(null) + NostrGeohashDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + NostrGeohashDataSource.loadHashtag(null) + NostrGeohashDataSource.stop() + } + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + GeoHashHeader(tag, accountViewModel) + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav + ) + } + } +} + +@Composable +fun GeoHashHeader(tag: String, account: AccountViewModel, onClick: () -> Unit = { }) { + Column( + Modifier.clickable { onClick() } + ) { + Column(modifier = HalfPadding) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column( + modifier = Modifier + .padding(start = 10.dp) + .weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + val context = LocalContext.current + val cityName = remember(tag) { + ReverseGeoLocationUtil().execute(tag.toGeoHash().toLocation(), context) + } + + Text( + "$cityName ($tag)", + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + + HashtagActionOptions(tag, account) + } + } + } + } + + Divider( + modifier = Modifier.padding(start = 12.dp, end = 12.dp), + thickness = 0.25.dp + ) + } +} + +@Composable +private fun HashtagActionOptions( + tag: String, + accountViewModel: AccountViewModel +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val userState by accountViewModel.userProfile().live().follows.observeAsState() + val isFollowingTag by remember(userState) { + derivedStateOf { + userState?.user?.isFollowingGeohashCached(tag) ?: false + } + } + + if (isFollowingTag) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow), + Toast.LENGTH_SHORT + ) + .show() + } + } else { + scope.launch(Dispatchers.IO) { + accountViewModel.account.unfollowGeohash(tag) + } + } + } + } else { + FollowButton { + if (!accountViewModel.isWriteable()) { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.login_with_a_private_key_to_be_able_to_follow), + Toast.LENGTH_SHORT + ) + .show() + } + } else { + scope.launch(Dispatchers.IO) { + accountViewModel.account.followGeohash(tag) + } + } + } + } +} 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 32b469c57..3a95da057 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,7 +18,6 @@ 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 @@ -170,7 +169,7 @@ private fun HashtagActionOptions( } } else { scope.launch(Dispatchers.IO) { - accountViewModel.account.unfollow(tag) + accountViewModel.account.unfollowHashtag(tag) } } } @@ -188,7 +187,7 @@ private fun HashtagActionOptions( } } else { scope.launch(Dispatchers.IO) { - accountViewModel.account.follow(tag) + accountViewModel.account.followHashtag(tag) } } }