From 9a6f88b81baf82718ea1c008d25656681c7a752b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 29 Mar 2023 15:14:52 -0400 Subject: [PATCH 01/61] Turning LocalCache Listeners into an Additive Feed type. --- .../amethyst/model/LocalCache.kt | 61 +++++++++---------- .../amethyst/ui/components/BundledUpdate.kt | 36 +++++++++++ .../amethyst/ui/dal/ChannelFeedFilter.kt | 12 +++- .../amethyst/ui/dal/ChatroomFeedFilter.kt | 21 ++++++- .../amethyst/ui/dal/FeedFilter.kt | 23 ++++++- .../amethyst/ui/dal/GlobalFeedFilter.kt | 42 ++++++------- .../amethyst/ui/dal/HashtagFeedFilter.kt | 24 +++++--- .../ui/dal/HomeConversationsFeedFilter.kt | 20 ++++-- .../ui/dal/HomeNewThreadFeedFilter.kt | 34 ++++++----- .../amethyst/ui/dal/NotificationFeedFilter.kt | 19 ++++-- .../amethyst/ui/screen/CardFeedViewModel.kt | 7 +-- .../amethyst/ui/screen/FeedViewModel.kt | 34 +++++++++-- .../amethyst/ui/screen/LnZapFeedViewModel.kt | 7 +-- .../amethyst/ui/screen/UserFeedViewModel.kt | 8 +-- 14 files changed, 242 insertions(+), 106 deletions(-) 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 da8686412..e0831547a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.Relay -import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.components.BundledInsert import fr.acinq.secp256k1.Hex import kotlinx.coroutines.* import nostr.postr.toNpub @@ -208,7 +208,7 @@ object LocalCache { it.addReply(note) } - refreshObservers() + refreshObservers(note) } fun consume(event: LongTextNoteEvent, relay: Relay?) { @@ -237,7 +237,7 @@ object LocalCache { author.addNote(note) - refreshObservers() + refreshObservers(note) } } @@ -251,7 +251,7 @@ object LocalCache { if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, emptyList()) - refreshObservers() + refreshObservers(note) } } @@ -269,8 +269,6 @@ object LocalCache { note.loadEvent(event, author, replyTo) author.updateAcceptedBadges(note) - - refreshObservers() } } @@ -292,7 +290,7 @@ object LocalCache { it.addReply(note) } - refreshObservers() + refreshObservers(note) } @Suppress("UNUSED_PARAMETER") @@ -338,7 +336,7 @@ object LocalCache { recipient.addMessage(author, note) } - refreshObservers() + refreshObservers(note) } fun consume(event: DeletionEvent) { @@ -386,7 +384,7 @@ object LocalCache { } if (deletedAtLeastOne) { - live.invalidateData() + // refreshObservers() } } @@ -412,7 +410,7 @@ object LocalCache { it.addBoost(note) } - refreshObservers() + refreshObservers(note) } fun consume(event: ReactionEvent) { @@ -450,6 +448,8 @@ object LocalCache { it.addReport(note) } } + + refreshObservers(note) } fun consume(event: ReportEvent, relay: Relay?) { @@ -480,6 +480,8 @@ object LocalCache { repliesTo.forEach { it.addReport(note) } + + refreshObservers(note) } fun consume(event: ChannelCreateEvent) { @@ -496,7 +498,7 @@ object LocalCache { oldChannel.addNote(note) note.loadEvent(event, author, emptyList()) - refreshObservers() + refreshObservers(note) } } @@ -516,7 +518,7 @@ object LocalCache { oldChannel.addNote(note) note.loadEvent(event, author, emptyList()) - refreshObservers() + refreshObservers(note) } } else { // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") @@ -563,7 +565,7 @@ object LocalCache { it.addReply(note) } - refreshObservers() + refreshObservers(note) } @Suppress("UNUSED_PARAMETER") @@ -606,6 +608,8 @@ object LocalCache { mentions.forEach { it.addZap(zapRequest, note) } + + refreshObservers(note) } fun consume(event: LnZapRequestEvent) { @@ -629,6 +633,8 @@ object LocalCache { mentions.forEach { it.addZap(note, null) } + + refreshObservers(note) } fun findUsersStartingWith(username: String): List { @@ -734,30 +740,23 @@ object LocalCache { } // Observers line up here. - val live: LocalCacheLiveData = LocalCacheLiveData(this) + val live: LocalCacheLiveData = LocalCacheLiveData() - private fun refreshObservers() { - live.invalidateData() + private fun refreshObservers(newNote: Note) { + live.invalidateData(newNote) } } -class LocalCacheLiveData(val cache: LocalCache) : - LiveData(LocalCacheState(cache)) { +class LocalCacheLiveData : LiveData>() { // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Main) { - if (hasActiveObservers()) { - refresh() + private val bundler = BundledInsert(300, Dispatchers.Main) + + fun invalidateData(newNote: Note) { + bundler.invalidateList(newNote) { bundledNewNotes -> + if (hasActiveObservers()) { + postValue(bundledNewNotes) + } } } - - fun invalidateData() { - bundler.invalidate() - } - - private fun refresh() { - postValue(LocalCacheState(cache)) - } } - -class LocalCacheState(val cache: LocalCache) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt index d2172e560..3d118e08c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference /** * This class is designed to have a waiting time between two calls of invalidate @@ -44,3 +45,38 @@ class BundledUpdate( } } } + +/** + * This class is designed to have a waiting time between two calls of invalidate + */ +class BundledInsert( + val delay: Long, + val dispatcher: CoroutineDispatcher = Dispatchers.Default +) { + private var onlyOneInBlock = AtomicBoolean() + private var atomicSet = AtomicReference>(setOf()) + + fun invalidateList(newObject: T, onUpdate: (Set) -> Unit) { + // atomicSet.updateAndGet() { + // it + newObject + // } + + if (onlyOneInBlock.getAndSet(true)) { + return + } + + val scope = CoroutineScope(Job() + dispatcher) + scope.launch { + try { + // onUpdate(atomicSet.getAndSet(emptySet())) + onUpdate(emptySet()) + delay(delay) + // onUpdate(atomicSet.getAndSet(emptySet())) + } finally { + withContext(NonCancellable) { + onlyOneInBlock.set(false) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt index 6e19217dd..14e4b0955 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt @@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -object ChannelFeedFilter : FeedFilter() { +object ChannelFeedFilter : AdditiveFeedFilter() { lateinit var account: Account lateinit var channel: Channel @@ -22,4 +22,14 @@ object ChannelFeedFilter : FeedFilter() { .sortedBy { it.createdAt() } .reversed() } + + override fun applyFilter(collection: Set): List { + return collection + .filter { it.idHex in channel.notes.keys } + .filter { account.isAcceptable(it) } + } + + override fun sort(collection: List): List { + return collection.sortedBy { it.createdAt() }.reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index 9d77e1ac4..d885ce7e4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -object ChatroomFeedFilter : FeedFilter() { +object ChatroomFeedFilter : AdditiveFeedFilter() { var account: Account? = null var withUser: User? = null @@ -30,4 +30,23 @@ object ChatroomFeedFilter : FeedFilter() { .sortedBy { it.createdAt() } .reversed() } + + override fun applyFilter(collection: Set): List { + val myAccount = account + val myUser = withUser + + if (myAccount == null || myUser == null) return emptyList() + + val messages = myAccount + .userProfile() + .privateChatrooms[myUser] ?: return emptyList() + + return collection + .filter { it in messages.roomMessages } + .filter { account?.isAcceptable(it) == true } + } + + override fun sort(collection: List): List { + return collection.sortedBy { it.createdAt() }.reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index e5f78f4e2..d4395f8da 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -4,7 +4,7 @@ import android.util.Log import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue -abstract class FeedFilter() { +abstract class FeedFilter { @OptIn(ExperimentalTime::class) fun loadTop(): List { val (feed, elapsed) = measureTimedValue { @@ -17,3 +17,24 @@ abstract class FeedFilter() { abstract fun feed(): List } + +abstract class AdditiveFeedFilter : FeedFilter() { + abstract fun applyFilter(collection: Set): List + abstract fun sort(collection: List): List + + @OptIn(ExperimentalTime::class) + fun updateListWith(oldList: List, newItems: Set): List { + val (feed, elapsed) = measureTimedValue { + val newItemsToBeAdded = applyFilter(newItems) + if (newItemsToBeAdded.isNotEmpty()) { + val newList = oldList + newItemsToBeAdded + sort(newList).take(1000) + } else { + oldList + } + } + + Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects") + return feed + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt index ffed429cc..9122dd8f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt @@ -7,18 +7,28 @@ import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object GlobalFeedFilter : FeedFilter() { +object GlobalFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { + val notes = applyFilter(LocalCache.notes.values) + val longFormNotes = applyFilter(LocalCache.addressables.values) + + return sort(notes + longFormNotes) + } + + override fun applyFilter(collection: Set): List { + return applyFilter(collection) + } + + private fun applyFilter(collection: Collection): List { val followChannels = account.followingChannels() val followUsers = account.followingKeySet() - val notes = LocalCache.notes.values + return collection .asSequence() .filter { - (it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) && - it.replyTo.isNullOrEmpty() + (it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) && it.replyTo.isNullOrEmpty() } .filter { // does not show events already in the public chat list @@ -32,27 +42,9 @@ object GlobalFeedFilter : FeedFilter() { it.createdAt()!! <= System.currentTimeMillis() / 1000 } .toList() + } - val longFormNotes = LocalCache.addressables.values - .asSequence() - .filter { - (it.event is LongTextNoteEvent) && it.replyTo.isNullOrEmpty() - } - .filter { - // does not show events already in the public chat list - (it.channel() == null || it.channel() !in followChannels) && - // does not show people the user already follows - (it.author?.pubkeyHex !in followUsers) - } - .filter { account.isAcceptable(it) } - .filter { - // Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating. - it.createdAt()!! <= System.currentTimeMillis() / 1000 - } - .toList() - - return (notes + longFormNotes) - .sortedBy { it.createdAt() } - .reversed() + override fun sort(collection: List): List { + return collection.sortedBy { it.createdAt() }.reversed() } } 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 844505ff3..c33593ff4 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,14 +8,27 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object HashtagFeedFilter : FeedFilter() { +object HashtagFeedFilter : AdditiveFeedFilter() { lateinit var account: Account var tag: String? = null + fun loadHashtag(account: Account, tag: String?) { + this.account = account + this.tag = tag + } + override fun feed(): List { + return sort(applyFilter(LocalCache.notes.values)) + } + + override fun applyFilter(collection: Set): List { + return applyFilter(collection) + } + + private fun applyFilter(collection: Collection): List { val myTag = tag ?: return emptyList() - return LocalCache.notes.values + return collection .asSequence() .filter { ( @@ -27,13 +40,10 @@ object HashtagFeedFilter : FeedFilter() { it.event?.isTaggedHash(myTag) == true } .filter { account.isAcceptable(it) } - .sortedBy { it.createdAt() } .toList() - .reversed() } - fun loadHashtag(account: Account, tag: String?) { - this.account = account - this.tag = tag + override fun sort(collection: List): List { + return collection.sortedBy { it.createdAt() }.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 50385acfd..3a2fda470 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 @@ -5,15 +5,24 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object HomeConversationsFeedFilter : FeedFilter() { +object HomeConversationsFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { + return sort(applyFilter(LocalCache.notes.values)) + } + + override fun applyFilter(collection: Set): List { + return applyFilter(collection) + } + + private fun applyFilter(collection: Collection): List { val user = account.userProfile() val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = user.cachedFollowingTagSet() - return LocalCache.notes.values + return collection + .asSequence() .filter { (it.event is TextNoteEvent) && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) && @@ -21,7 +30,10 @@ object HomeConversationsFeedFilter : FeedFilter() { it.author?.let { !account.isHidden(it) } ?: true && !it.isNewThread() } - .sortedBy { it.createdAt() } - .reversed() + .toList() + } + + override fun sort(collection: List): List { + return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index ca88aaa96..5ae61e017 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -7,34 +7,38 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object HomeNewThreadFeedFilter : FeedFilter() { +object HomeNewThreadFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { + val notes = applyFilter(LocalCache.notes.values) + val longFormNotes = applyFilter(LocalCache.addressables.values) + + return sort(notes + longFormNotes) + } + + override fun applyFilter(collection: Set): List { + return applyFilter(collection) + } + + private fun applyFilter(collection: Collection): List { val user = account.userProfile() val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = user.cachedFollowingTagSet() - val notes = LocalCache.notes.values + return collection + .asSequence() .filter { it -> - (it.event is TextNoteEvent || it.event is RepostEvent) && + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) && // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable it.author?.let { !account.isHidden(it) } ?: true && it.isNewThread() } + .toList() + } - val longFormNotes = LocalCache.addressables.values - .filter { it -> - (it.event is LongTextNoteEvent) && - (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) && - // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable - it.author?.let { !account.isHidden(it) } ?: true && - it.isNewThread() - } - - return (notes + longFormNotes) - .sortedBy { it.createdAt() } - .reversed() + override fun sort(collection: List): List { + return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 39dcc920e..aa9327bf0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -5,12 +5,21 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.* -object NotificationFeedFilter : FeedFilter() { +object NotificationFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { + return sort(applyFilter(LocalCache.notes.values)) + } + + override fun applyFilter(collection: Set): List { + return applyFilter(collection) + } + + private fun applyFilter(collection: Collection): List { val loggedInUser = account.userProfile() - return LocalCache.notes.values + + return collection .asSequence() .filter { it.event !is ChannelCreateEvent && @@ -36,8 +45,10 @@ object NotificationFeedFilter : FeedFilter() { it.replyTo?.lastOrNull()?.author == loggedInUser || loggedInUser in it.directlyCiteUsers() } - .sortedBy { it.createdAt() } .toList() - .reversed() + } + + override fun sort(collection: List): List { + return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 34337677e..5debefce8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -4,7 +4,6 @@ import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent @@ -47,7 +46,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { val lastNotesCopy = lastNotes - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) { val newCards = convertToCard(notes.minus(lastNotesCopy)) if (newCards.isNotEmpty()) { @@ -125,7 +124,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private fun updateFeed(notes: List) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { CardFeedState.Empty } @@ -152,7 +151,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { + private val cacheListener: (Set) -> Unit = { newNotes -> invalidateData() } 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 a6b2fcdb1..60daf1072 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 @@ -3,9 +3,10 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter @@ -65,7 +66,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { fun refreshSuspended() { val notes = newListFromDataSource() - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (oldNotesState is FeedState.Loaded) { // Using size as a proxy for has changed. if (notes != oldNotesState.feed.value) { @@ -79,7 +80,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { private fun updateFeed(notes: List) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { FeedState.Empty } } else if (currentState is FeedState.Loaded) { @@ -91,18 +92,41 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } + fun refreshFromOldState(newItems: Set) { + val oldNotesState = _feedContent.value + if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) { + val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet()) + updateFeed(newList) + } else { + // Refresh Everything + refreshSuspended() + } + } + private val bundler = BundledUpdate(250, Dispatchers.IO) { // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. refreshSuspended() } + private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) fun invalidateData() { bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { - invalidateData() + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { + refreshFromOldState(it.flatten().toSet()) + } + } + + private val cacheListener: (Set) -> Unit = { newNotes -> + if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } } init { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt index 9973afab8..8c6806de8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.dal.FeedFilter @@ -32,7 +31,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi private fun refreshSuspended() { val notes = dataSource.loadTop() - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (oldNotesState is LnZapFeedState.Loaded) { // Using size as a proxy for has changed. if (notes != oldNotesState.feed.value) { @@ -46,7 +45,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi private fun updateFeed(notes: List>) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { LnZapFeedState.Empty } } else if (currentState is LnZapFeedState.Loaded) { @@ -68,7 +67,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { + private val cacheListener: (Set) -> Unit = { newNotes -> invalidateData() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index 500320035..aae7fd358 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCacheState +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.dal.FeedFilter @@ -36,7 +36,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private fun refreshSuspended() { val notes = dataSource.loadTop() - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (oldNotesState is UserFeedState.Loaded) { // Using size as a proxy for has changed. if (notes != oldNotesState.feed.value) { @@ -50,7 +50,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private fun updateFeed(notes: List) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { UserFeedState.Empty } } else if (currentState is UserFeedState.Loaded) { @@ -72,7 +72,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { + private val cacheListener: (Set) -> Unit = { newNotes -> invalidateData() } From 401d807d29712bf7a6ac120044f481397c597648 Mon Sep 17 00:00:00 2001 From: Zoltan <34719275+ZsZolee@users.noreply.github.com> Date: Tue, 11 Apr 2023 10:26:19 +0200 Subject: [PATCH 02/61] Added new Hungarian translations --- app/src/main/res/values-hu/strings.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 81296d243..dc5073f2a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -263,9 +263,30 @@ nsec / hex privát kulcs Hozzájárulás összege sats-ban + Szavazás Létrehozása + Szükséges mezők: + Zap-et kapják + Szavazás elsődleges leírása… + Szavazás %s megoszlása + Szavazás opcióinak leírása + Kiegészítő mezők: + Zap minimum + Zap maximum + Konszenzus + (0–100)% + Szavazás lezárása + napok + A szavazásra nem lehet már új szavazatot leadni + Zap összege + Az ilyen típusú szavazásokon felhasználónként csak egy szavazat engedélyezett + "%1$s esemény keresése" Nyilvános üzenet hozzáadása Köszönöm a kemény munkát! + + Létrehoz és Hozzáad + A szavazás létrehozója sajátjára nem szavazhat. + #zappoll From 9d27b8d47e705936b868c065373daff8661f4adc Mon Sep 17 00:00:00 2001 From: nalbaltaji Date: Sat, 15 Apr 2023 18:59:44 +0200 Subject: [PATCH 03/61] Intercept back press when drawer is opened to close drawer --- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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 e0293b5d2..49486609d 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 @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.activity.compose.BackHandler import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -19,6 +20,7 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController @@ -34,10 +36,12 @@ import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.currentRoute import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterialApi::class) @Composable fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) { + val coroutineScope = rememberCoroutineScope() val navController = rememberNavController() val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) val sheetState = rememberModalBottomSheetState( @@ -64,6 +68,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun }, drawerContent = { DrawerContent(navController, scaffoldState, sheetState, accountViewModel) + BackHandler(enabled = scaffoldState.drawerState.isOpen) { + coroutineScope.launch { scaffoldState.drawerState.close() } + } }, floatingActionButton = { FloatingButtons(navController, accountStateViewModel) From 50b0b5af37f2f2e9a7d72d437b1808f1466c43de Mon Sep 17 00:00:00 2001 From: Ryo Kanbayashi Date: Sun, 16 Apr 2023 22:12:54 +0900 Subject: [PATCH 04/61] fix bug: quote repost is tied up to a post which is replyed a little while ago. --- .../com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt | 1 + .../com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 516ac6e52..9f2869508 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -53,6 +53,7 @@ class NewMessageTagger(var channel: Channel?, var mentions: List?, var rep message = message.split('\n').map { paragraph: String -> paragraph.split(' ').map { word: String -> val results = parseDirtyWordForKey(word) + if (results?.key?.type == Nip19.Type.USER) { val user = LocalCache.getOrCreateUser(results.key.hex) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 1a98c4e97..e924b0627 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -79,6 +79,7 @@ open class NewPostViewModel : ViewModel() { quote?.let { message = TextFieldValue(message.text + "\n\n@${it.idNote()}") + this.replyTos = emptyList() } canAddInvoice = account.userProfile().info?.lnAddress() != null From 180083147dcd059209523cda114380a3dbef9465 Mon Sep 17 00:00:00 2001 From: Ryo Kanbayashi Date: Sun, 16 Apr 2023 22:26:55 +0900 Subject: [PATCH 05/61] removed needless added newline. --- .../com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 9f2869508..516ac6e52 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -53,7 +53,6 @@ class NewMessageTagger(var channel: Channel?, var mentions: List?, var rep message = message.split('\n').map { paragraph: String -> paragraph.split(' ').map { word: String -> val results = parseDirtyWordForKey(word) - if (results?.key?.type == Nip19.Type.USER) { val user = LocalCache.getOrCreateUser(results.key.hex) From 78c997403659ca25a384126ed8b8c3bff8ae12fb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Apr 2023 08:41:19 -0400 Subject: [PATCH 06/61] Removes invalidateFilters Logging --- .../java/com/vitorpamplona/amethyst/service/NostrDataSource.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 755fbf607..eb564b247 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -154,7 +154,7 @@ abstract class NostrDataSource(val debugName: String) { // Refreshes observers in batches. private val bundler = BundledUpdate(250, Dispatchers.IO) { - println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") + // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. From 38e18cf0a753bfa39000781db428bf4441f07fb2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Apr 2023 08:41:59 -0400 Subject: [PATCH 07/61] Simplifies comparison for Channels via Hex code instead of objects --- .../java/com/vitorpamplona/amethyst/model/Note.kt | 13 +++++++------ .../amethyst/ui/dal/GlobalFeedFilter.kt | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) 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 f12698a33..7bf4537f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -53,13 +53,14 @@ open class Note(val idHex: String) { open fun idNote() = id().toNote() open fun idDisplayNote() = idNote().toShortenHex() - fun channel(): Channel? { - val channelHex = - (event as? ChannelMessageEvent)?.channel() - ?: (event as? ChannelMetadataEvent)?.channel() - ?: (event as? ChannelCreateEvent)?.id + fun channelHex(): HexKey? { + return (event as? ChannelMessageEvent)?.channel() + ?: (event as? ChannelMetadataEvent)?.channel() + ?: (event as? ChannelCreateEvent)?.id + } - return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) } + fun channel(): Channel? { + return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) } } open fun address(): ATag? = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt index e902c2f98..bd441642a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt @@ -9,7 +9,7 @@ object GlobalFeedFilter : FeedFilter() { lateinit var account: Account override fun feed(): List { - val followChannels = account.followingChannels() + val followChannels = account.followingChannels val followUsers = account.followingKeySet() val now = System.currentTimeMillis() / 1000 @@ -19,7 +19,7 @@ object GlobalFeedFilter : FeedFilter() { it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty() } .filter { - val channel = it.channel() + val channel = it.channelHex() // does not show events already in the public chat list (channel == null || channel !in followChannels) && // does not show people the user already follows @@ -38,7 +38,7 @@ object GlobalFeedFilter : FeedFilter() { it.event is LongTextNoteEvent && it.replyTo.isNullOrEmpty() } .filter { - val channel = it.channel() + val channel = it.channelHex() // does not show events already in the public chat list (channel == null || channel !in followChannels) && // does not show people the user already follows From a2c86589724372d79444c406e525c0d1cebfd98a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Apr 2023 08:42:32 -0400 Subject: [PATCH 08/61] Only calculates nip05 after identifying if it is correct. --- .../vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 53c570214..cebfe1fdd 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 @@ -96,7 +96,6 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie user.nip05()?.let { nip05 -> if (nip05.split("@").size == 2) { - val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex) Column(modifier = columnModifier) { Row(verticalAlignment = Alignment.CenterVertically) { if (nip05.split("@")[0] != "_") { @@ -108,6 +107,7 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie ) } + val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex) if (nip05Verified == null) { Icon( tint = Color.Yellow, From 35647a836185c9241fd1376aaff657bc8b65a4f0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Apr 2023 08:42:58 -0400 Subject: [PATCH 09/61] Fixes DM link in Message Notifications --- .../amethyst/ui/note/MessageSetCompose.kt | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index bf0fa2cdb..7629b5ca0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -26,7 +26,9 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.ui.screen.MessageSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers @@ -64,14 +66,29 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal Column( modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { - if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}") { - launchSingleTop = true - } - } else { + if (noteEvent is ChannelMessageEvent) { note.channel()?.let { navController.navigate("Channel/${it.idHex}") } + } else if (noteEvent is PrivateDmEvent) { + val replyAuthorBase = + (note.event as? PrivateDmEvent) + ?.recipientPubKey() + ?.let { LocalCache.getOrCreateUser(it) } + + var userToComposeOn = note.author!! + + if (replyAuthorBase != null) { + if (note.author == accountViewModel.userProfile()) { + userToComposeOn = replyAuthorBase + } + } + + navController.navigate("Room/${userToComposeOn.pubkeyHex}") + } else { + navController.navigate("Note/${note.idHex}") { + launchSingleTop = true + } } }, onLongClick = { popupExpanded = true } From f64db1271be744473d9001cc671c447e83d6d7e0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Apr 2023 08:44:11 -0400 Subject: [PATCH 10/61] Uses createdAt instead of the card object, which can change randomly, to determine when to call LaunchedEffects. --- .../com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt | 2 +- .../java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt | 2 +- .../com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index 7629b5ca0..8deb5bcb1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -48,7 +48,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal } else { var isNew by remember { mutableStateOf(false) } - LaunchedEffect(key1 = messageSetCard) { + LaunchedEffect(key1 = messageSetCard.createdAt()) { withContext(Dispatchers.IO) { isNew = messageSetCard.createdAt() > NotificationCache.load(routeForLastRead) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index b001cce84..a8c766356 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -61,7 +61,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun } else { var isNew by remember { mutableStateOf(false) } - LaunchedEffect(key1 = multiSetCard) { + LaunchedEffect(key1 = multiSetCard.createdAt()) { withContext(Dispatchers.IO) { isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index f9512cde8..7d0094295 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -43,7 +43,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false, var isNew by remember { mutableStateOf(false) } - LaunchedEffect(key1 = zapSetCard) { + LaunchedEffect(key1 = zapSetCard.createdAt()) { withContext(Dispatchers.IO) { isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead) From e4fc00669e26e824ec0f2b47128dd84eed772716 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Apr 2023 08:44:32 -0400 Subject: [PATCH 11/61] Compares users by reference. --- .../java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index a8c766356..537649905 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -244,7 +244,7 @@ fun FastNoteAuthorPicture( val userState by author.live().metadata.observeAsState() val user = userState?.user ?: return - val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount + val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount UserPicture( userHex = user.pubkeyHex, From 47e2683aa359de9f1572d25dcaaf9801dcb31b19 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Apr 2023 08:45:02 -0400 Subject: [PATCH 12/61] Moves firstIsTaggedHash search to the IO thread. --- .../java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 be5075c25..807a40898 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 @@ -582,7 +582,9 @@ fun DisplayFollowingHashtagsInPost( var firstTag by remember { mutableStateOf(null) } LaunchedEffect(key1 = noteEvent) { - firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet()) + withContext(Dispatchers.IO) { + firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet()) + } } Column() { From 11bf702e022624431635d10214fe0d771564788c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Apr 2023 08:45:34 -0400 Subject: [PATCH 13/61] Moves DropDown state to the IO thread. --- .../vitorpamplona/amethyst/ui/note/NoteCompose.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 807a40898..3d03c513c 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 @@ -1110,12 +1110,14 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, } LaunchedEffect(key1 = note) { - state = DropDownParams( - accountViewModel.isFollowing(note.author), - accountViewModel.isInPrivateBookmarks(note), - accountViewModel.isInPublicBookmarks(note), - accountViewModel.isLoggedUser(note.author) - ) + withContext(Dispatchers.IO) { + state = DropDownParams( + accountViewModel.isFollowing(note.author), + accountViewModel.isInPrivateBookmarks(note), + accountViewModel.isInPublicBookmarks(note), + accountViewModel.isLoggedUser(note.author) + ) + } } DropdownMenu( From df45d68b74d13b057e18fc45e76852c18781e05c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Apr 2023 08:45:50 -0400 Subject: [PATCH 14/61] Caches user profile in Account. --- .../main/java/com/vitorpamplona/amethyst/model/Account.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 178e5f264..5a36d7297 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -54,8 +54,14 @@ class Account( val liveLanguages: AccountLiveData = AccountLiveData(this) val saveable: AccountLiveData = AccountLiveData(this) + var userProfileCache: User? = null + fun userProfile(): User { - return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) + return userProfileCache ?: run { + val myUser: User = LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) + userProfileCache = myUser + myUser + } } fun followingChannels(): List { From 12b0afd9c62202178cad8812ac6c8c33e17e986d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Apr 2023 08:46:12 -0400 Subject: [PATCH 15/61] Display note IDHex when measuring time. --- .../main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3d03c513c..8400d174b 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 @@ -96,7 +96,7 @@ fun NoteCompose( ) } - Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}") + Log.d("Time", "Note Compose in $elapsed for ${baseNote.idHex} ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}") } @OptIn(ExperimentalFoundationApi::class) From c0946f5c0ee8147244419aa5908aedc3bccaee2d Mon Sep 17 00:00:00 2001 From: Ryo Kanbayashi Date: Wed, 19 Apr 2023 22:50:32 +0900 Subject: [PATCH 16/61] fix bug: quote repost is tied up to a post which is replyed a little while ago. (2) --- .../com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index e924b0627..8be28a491 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -72,14 +72,13 @@ open class NewPostViewModel : ViewModel() { this.mentions = currentMentions.plus(replyUser) } } - } ?: { + } ?: run { replyTos = null mentions = null } quote?.let { message = TextFieldValue(message.text + "\n\n@${it.idNote()}") - this.replyTos = emptyList() } canAddInvoice = account.userProfile().info?.lnAddress() != null From 5afa333fab37face44b70beae500152919b8bf65 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 09:58:11 -0400 Subject: [PATCH 17/61] Migration to Android Studio 2022.2.1 and Gradle 8 --- build.gradle | 4 ++-- gradle.properties | 4 +++- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index ee67b5b0c..b44afca7a 100644 --- a/build.gradle +++ b/build.gradle @@ -10,8 +10,8 @@ buildscript { } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '7.4.2' apply false - id 'com.android.library' version '7.4.2' apply false + id 'com.android.application' version '8.0.0' apply false + id 'com.android.library' version '8.0.0' apply false id 'org.jetbrains.kotlin.android' version '1.8.10' apply false id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false } diff --git a/gradle.properties b/gradle.properties index c9ecf2f95..e4e68fdb1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,4 +21,6 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.enableR8.fullMode=true \ No newline at end of file +android.enableR8.fullMode=true +android.defaults.buildfeatures.buildconfig=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c1070955d..289cef284 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Jan 04 09:23:50 EST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME From 4e09d9e54ac5030d08e3cd9df0b5f8aa36729715 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 10:33:02 -0400 Subject: [PATCH 18/61] Performance Improvements for Zaps in Polls. --- .../amethyst/model/LocalCache.kt | 2 +- .../amethyst/service/NostrDataSource.kt | 2 +- .../amethyst/service/model/LnZapEvent.kt | 68 +++++++++---------- .../service/model/LnZapEventInterface.kt | 2 - .../service/model/LnZapRequestEvent.kt | 6 +- .../amethyst/service/model/PollNoteEvent.kt | 19 ++---- .../amethyst/ui/note/PollNote.kt | 11 ++- .../amethyst/ui/note/PollNoteViewModel.kt | 65 +++++++++--------- 8 files changed, 79 insertions(+), 96 deletions(-) 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 cef63d463..1705175d5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -595,7 +595,7 @@ object LocalCache { // Already processed this event. if (note.event != null) return - val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) } + val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) } val author = getOrCreateUser(event.pubKey) val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index eb564b247..6d4fbadf9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -76,7 +76,7 @@ abstract class NostrDataSource(val debugName: String) { is DeletionEvent -> LocalCache.consume(event) is LnZapEvent -> { - event.containedPost()?.let { onEvent(it, subscriptionId, relay) } + event.zapRequest?.let { onEvent(it, subscriptionId, relay) } LocalCache.consume(event) } is LnZapRequestEvent -> LocalCache.consume(event) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt index 1812fb803..5f08c6a6e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt @@ -4,7 +4,6 @@ import android.util.Log import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.relays.Client -import java.math.BigDecimal class LnZapEvent( id: HexKey, @@ -14,25 +13,37 @@ class LnZapEvent( content: String, sig: HexKey ) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) { + // This event is also kept in LocalCache (same object) + @Transient val zapRequest: LnZapRequestEvent? - override fun zappedPost() = tags - .filter { it.firstOrNull() == "e" } - .mapNotNull { it.getOrNull(1) } - - override fun zappedPollOption(): Int? = containedPost()?.tags - ?.filter { it.firstOrNull() == POLL_OPTION } - ?.getOrNull(0)?.getOrNull(1)?.toInt() - - override fun zappedAuthor() = tags - .filter { it.firstOrNull() == "p" } - .mapNotNull { it.getOrNull(1) } - - override fun zappedRequestAuthor(): String? = containedPost()?.pubKey() - - override fun amount(): BigDecimal? { - return amount + private fun containedPost(): LnZapRequestEvent? = try { + description()?.ifBlank { null }?.let { + fromJson(it, Client.lenient) + } as? LnZapRequestEvent + } catch (e: Exception) { + Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e) + null } + init { + zapRequest = containedPost() + } + + override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + + override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + + override fun zappedPollOption(): Int? = try { + zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt() + } catch (e: Exception) { + Log.e("LnZapEvent", "ZappedPollOption failed to parse", e) + null + } + + override fun zappedRequestAuthor(): String? = zapRequest?.pubKey() + + override fun amount() = amount + // Keeps this as a field because it's a heavier function used everywhere. val amount by lazy { try { @@ -42,29 +53,14 @@ class LnZapEvent( null } } + override fun message(): String { - return message - } - val message = content - - override fun containedPost(): Event? = try { - description()?.ifBlank { null }?.let { - fromJson(it, Client.lenient) - } - } catch (e: Exception) { - Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e) - null + return content } - private fun lnInvoice(): String? = tags - .filter { it.firstOrNull() == "bolt11" } - .mapNotNull { it.getOrNull(1) } - .firstOrNull() + private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1) - private fun description(): String? = tags - .filter { it.firstOrNull() == "description" } - .mapNotNull { it.getOrNull(1) } - .firstOrNull() + private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) companion object { const val kind = 9735 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt index b69e02781..ada0d609d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt @@ -16,7 +16,5 @@ interface LnZapEventInterface : EventInterface { fun amount(): BigDecimal? - fun containedPost(): Event? - fun message(): String } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index 34fddc02a..eba7a18a9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -13,8 +13,10 @@ class LnZapRequestEvent( content: String, sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + + fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + + fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } companion object { const val kind = 9734 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt index a6bcc89b8..c91cd4c7a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt @@ -20,24 +20,17 @@ class PollNoteEvent( content: String, sig: HexKey ) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun pollOptions(): Map { - val map = mutableMapOf() - tags.filter { it.first() == POLL_OPTION } - .forEach { map[it[1].toInt()] = it[2] } - return map - } + fun pollOptions() = + tags.filter { it.size > 2 && it[0] == POLL_OPTION } + .associate { it[1].toInt() to it[2] } fun getTagInt(property: String): Int? { - val tagList = tags.filter { - it.firstOrNull() == property - } - val tag = tagList.getOrNull(0) - val s = tag?.getOrNull(1) + val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1) - return if (s.isNullOrBlank() || s == "null") { + return if (number.isNullOrBlank() || number == "null") { null } else { - s.toInt() + number.toInt() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index da0f35936..26e83cf91 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -48,9 +48,6 @@ fun PollNote( accountViewModel: AccountViewModel, navController: NavController ) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return - val zapsState by baseNote.live().zaps.observeAsState() val zappedNote = zapsState?.note ?: return @@ -72,7 +69,7 @@ fun PollNote( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 3.dp) ) { - if (zappedNote.author == account.userProfile() || zappedNote.isZappedBy(account.userProfile())) { + if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) { ZapVote( baseNote, accountViewModel, @@ -90,7 +87,7 @@ fun PollNote( LinearProgressIndicator( modifier = Modifier.matchParentSize(), color = color, - progress = optionTally + progress = optionTally.toFloat() ) Row( @@ -101,7 +98,7 @@ fun PollNote( modifier = Modifier.padding(horizontal = 10.dp).width(40.dp) ) { Text( - text = "${(optionTally * 100).roundToInt()}%", + text = "${(optionTally.toFloat() * 100).roundToInt()}%", fontWeight = FontWeight.Bold ) } @@ -210,7 +207,7 @@ fun ZapVote( ) .show() } - } else if (zappedNote?.author == account.userProfile()) { + } else if (accountViewModel.isLoggedUser(zappedNote?.author)) { scope.launch { Toast .makeText( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index e5d3d4cd8..4ce21c372 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.* import java.math.BigDecimal +import java.math.RoundingMode import java.util.* class PollNoteViewModel { @@ -16,7 +17,9 @@ class PollNoteViewModel { var valueMaximum: Int? = null var valueMinimum: Int? = null private var closedAt: Int? = null - var consensusThreshold: Float? = null + var consensusThreshold: BigDecimal? = null + + var totalZapped: BigDecimal = BigDecimal.ZERO fun load(note: Note?) { pollNote = note @@ -24,8 +27,10 @@ class PollNoteViewModel { pollOptions = pollEvent?.pollOptions() valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM) valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM) - consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100) + consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal() closedAt = pollEvent?.getTagInt(CLOSED_AT) + + totalZapped = totalZapped() } fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum @@ -73,47 +78,39 @@ class PollNoteViewModel { return false } - fun optionVoteTally(op: Int): Float { - val tally = zappedPollOptionAmount(op).toFloat().div(zappedVoteTotal()) - return if (tally.isNaN()) { // catch div by 0 - 0f - } else { tally } - } - - private fun zappedVoteTotal(): Float { - var total = 0f - pollOptions?.keys?.forEach { - total += zappedPollOptionAmount(it).toFloat() + fun optionVoteTally(op: Int): BigDecimal { + return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { + zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP) + } else { + BigDecimal.ZERO } - return total } fun isPollOptionZappedBy(option: Int, user: User): Boolean { - if (pollNote?.zaps?.any { it.key.author == user } == true) { - pollNote!!.zaps.mapNotNull { it.value?.event } - .filterIsInstance() - .map { - val zappedOption = it.zappedPollOption() - if (zappedOption == option && it.zappedRequestAuthor() == user.pubkeyHex) { - return true - } + if (pollNote?.zaps?.any { it.key.author === user } == true) { + pollNote!!.zaps + .any { + val event = it.value?.event as? LnZapEvent + event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex } } return false } fun zappedPollOptionAmount(option: Int): BigDecimal { - return if (pollNote != null) { - pollNote!!.zaps.mapNotNull { it.value?.event } - .filterIsInstance() - .mapNotNull { - val zappedOption = it.zappedPollOption() - if (zappedOption == option) { - it.amount - } else { null } - }.sumOf { it } - } else { - BigDecimal(0) - } + return pollNote?.zaps?.values?.sumOf { + val event = it?.event as? LnZapEvent + if (event?.zappedPollOption() == option) { + event.amount ?: BigDecimal(0) + } else { + BigDecimal(0) + } + } ?: BigDecimal(0) + } + + fun totalZapped(): BigDecimal { + return pollNote?.zaps?.values?.sumOf { + (it?.event as? LnZapEvent)?.amount ?: BigDecimal(0) + } ?: BigDecimal(0) } } From b55f999efb5ef67353be4a1e589be9e0b146bbcc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 10:33:52 -0400 Subject: [PATCH 19/61] Slight performance improvements on tag search. --- .../vitorpamplona/amethyst/service/model/PrivateDmEvent.kt | 4 ++-- .../com/vitorpamplona/amethyst/service/model/ReactionEvent.kt | 4 ++-- .../com/vitorpamplona/amethyst/service/model/ReportEvent.kt | 4 ++-- .../com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt index 89e1d98e6..c820d0ca2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt @@ -21,7 +21,7 @@ class PrivateDmEvent( * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used * for initial messages. */ - fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one + fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one /** * To be fully compatible with nip-04, we read e-tags that are in violation to nip-18. @@ -29,7 +29,7 @@ class PrivateDmEvent( * Nip-18 messages should refer to other events by inline references in the content like * `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506). */ - fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) + fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? { return try { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt index f89bb2977..13188520c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt @@ -14,8 +14,8 @@ class ReactionEvent( sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } companion object { const val kind = 7 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt index 42f33c3c5..d3bd9dd97 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt @@ -30,7 +30,7 @@ class ReportEvent( } fun reportedPost() = tags - .filter { it.firstOrNull() == "e" && it.getOrNull(1) != null } + .filter { it.size > 1 && it[0] == "e" } .map { ReportedKey( it[1], @@ -39,7 +39,7 @@ class ReportEvent( } fun reportedAuthor() = tags - .filter { it.firstOrNull() == "p" && it.getOrNull(1) != null } + .filter { it.size > 1 && it[0] == "p" } .map { ReportedKey( it[1], diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 1a98c4e97..8be28a491 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -72,7 +72,7 @@ open class NewPostViewModel : ViewModel() { this.mentions = currentMentions.plus(replyUser) } } - } ?: { + } ?: run { replyTos = null mentions = null } From 744a55f4972000a2a48d5cd04eda8bdb99f76d55 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 10:34:10 -0400 Subject: [PATCH 20/61] Caching channel info JSON parse in NoteCompose. --- .../amethyst/ui/note/ChatroomMessageCompose.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index fee45ca0e..bee998015 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -246,30 +246,32 @@ fun ChatroomMessageCompose( Row(verticalAlignment = Alignment.CenterVertically) { val event = note.event if (event is ChannelCreateEvent) { + val channelInfo = event.channelInfo() Text( text = note.author?.toBestDisplayName() .toString() + " ${stringResource(R.string.created)} " + ( - event.channelInfo().name + channelInfo.name ?: "" ) + " ${stringResource(R.string.with_description_of)} '" + ( - event.channelInfo().about + channelInfo.about ?: "" ) + "', ${stringResource(R.string.and_picture)} '" + ( - event.channelInfo().picture + channelInfo.picture ?: "" ) + "'" ) } else if (event is ChannelMetadataEvent) { + val channelInfo = event.channelInfo() Text( text = note.author?.toBestDisplayName() .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( - event.channelInfo().name + channelInfo.name ?: "" ) + "$', {stringResource(R.string.description_to)} '" + ( - event.channelInfo().about + channelInfo.about ?: "" ) + "', ${stringResource(R.string.and_picture_to)} '" + ( - event.channelInfo().picture + channelInfo.picture ?: "" ) + "'" ) From b28f0bff742e180aef572a01dfea0aea034aebb4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 11:24:49 -0400 Subject: [PATCH 21/61] Updating build scripts to java 17 --- .github/workflows/build.yml | 4 ++-- .github/workflows/create-release.yml | 4 ++-- README.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e6e2910b..e7ef182e0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,10 +14,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - name: Cache gradle uses: actions/cache@v1 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 853a6ad2b..e788e06bf 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -12,10 +12,10 @@ jobs: - name: Checkout code uses: actions/checkout@v2 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - name: Cache gradle uses: actions/cache@v1 diff --git a/README.md b/README.md index 27b39d5a2..7d2eb1ce5 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro ## Setup Make sure to have the following pre-requisites installed: -1. Java 11 +1. Java 17 2. Android Studio 3. Android 8.0+ Phone or Emulation setup From dc8804933d28eb3fec7a777d716a0ccb8e8a1e08 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 11:27:20 -0400 Subject: [PATCH 22/61] moving source code compatibility to java 17 --- app/build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index fd5da4e0d..2e96328bc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -57,12 +57,12 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '11' + jvmTarget = '17' } buildFeatures { From 0b30c83143bbaa0ea4912c76392c6e32c4b96f82 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 11:32:52 -0400 Subject: [PATCH 23/61] Moves coffee.xml to the right directory. --- app/src/main/res/{drawable-xxxhdpi => drawable}/coffee.xml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/res/{drawable-xxxhdpi => drawable}/coffee.xml (100%) diff --git a/app/src/main/res/drawable-xxxhdpi/coffee.xml b/app/src/main/res/drawable/coffee.xml similarity index 100% rename from app/src/main/res/drawable-xxxhdpi/coffee.xml rename to app/src/main/res/drawable/coffee.xml From dc745c3cdbab662df70b098023b3508260865f1e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 11:33:03 -0400 Subject: [PATCH 24/61] disables errors due to missing translations --- app/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/build.gradle b/app/build.gradle index 2e96328bc..c31919d7d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -78,6 +78,10 @@ android { excludes += '/META-INF/{AL2.0,LGPL2.1}' } } + + lintOptions { + disable 'MissingTranslation' + } } dependencies { From a816fa561a7634f0003b282eea39399a25dc9d43 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 11:34:38 -0400 Subject: [PATCH 25/61] Declares the use of the camera as a feature. --- app/src/main/AndroidManifest.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 518e6a539..3735c465f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + From 6a9a321e2bfdcb7445f4acf96050a7d5e2a39b27 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 13:00:52 -0400 Subject: [PATCH 26/61] Fixing format --- .../amethyst/ui/dal/NotificationFeedFilter.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 46e483c41..39334ca15 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -23,14 +23,14 @@ object NotificationFeedFilter : AdditiveFeedFilter() { return collection.filter { it.event !is ChannelCreateEvent && - it.event !is ChannelMetadataEvent && - it.event !is LnZapRequestEvent && - it.event !is BadgeDefinitionEvent && - it.event !is BadgeProfilesEvent && - it.author !== loggedInUser && - it.event?.isTaggedUser(loggedInUserHex) ?: false && - (it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && - tagsAnEventByUser(it, loggedInUser) + it.event !is ChannelMetadataEvent && + it.event !is LnZapRequestEvent && + it.event !is BadgeDefinitionEvent && + it.event !is BadgeProfilesEvent && + it.author !== loggedInUser && + it.event?.isTaggedUser(loggedInUserHex) ?: false && + (it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && + tagsAnEventByUser(it, loggedInUser) } } From e775ae9ada6f191b8e4daf365f3ad4c2a21d8697 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 13:54:00 -0400 Subject: [PATCH 27/61] Adding the new parameter. --- .../main/java/com/vitorpamplona/amethyst/model/LocalCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3dc33582b..1beb05b36 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -255,7 +255,7 @@ object LocalCache { it.addReply(note) } - refreshObservers() + refreshObservers(note) } fun consume(event: BadgeDefinitionEvent) { From 47398e6e867c74e2e86611f060148e8361fdf220 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 15:56:58 -0400 Subject: [PATCH 28/61] Starts with an empty set as database --- .../main/java/com/vitorpamplona/amethyst/model/LocalCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1beb05b36..ee4636288 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -762,7 +762,7 @@ object LocalCache { } } -class LocalCacheLiveData : LiveData>() { +class LocalCacheLiveData : LiveData>(setOf()) { // Refreshes observers in batches. private val bundler = BundledInsert(300, Dispatchers.Main) From 57b35398bebe2e8a6c960d441e7db50d3b5ca9bd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 15:57:17 -0400 Subject: [PATCH 29/61] Activates updates --- .../amethyst/ui/components/BundledUpdate.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt index 3d118e08c..ea6dff88f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt @@ -57,9 +57,9 @@ class BundledInsert( private var atomicSet = AtomicReference>(setOf()) fun invalidateList(newObject: T, onUpdate: (Set) -> Unit) { - // atomicSet.updateAndGet() { - // it + newObject - // } + atomicSet.updateAndGet() { + it + newObject + } if (onlyOneInBlock.getAndSet(true)) { return @@ -68,10 +68,9 @@ class BundledInsert( val scope = CoroutineScope(Job() + dispatcher) scope.launch { try { - // onUpdate(atomicSet.getAndSet(emptySet())) - onUpdate(emptySet()) + onUpdate(atomicSet.getAndSet(emptySet())) delay(delay) - // onUpdate(atomicSet.getAndSet(emptySet())) + onUpdate(atomicSet.getAndSet(emptySet())) } finally { withContext(NonCancellable) { onlyOneInBlock.set(false) From abdad7fbea85b88c8ca6a543f988d86bf1f3b7d3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 15:57:27 -0400 Subject: [PATCH 30/61] solve recursive method call --- .../com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt | 8 ++++---- .../vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt | 4 ++-- .../amethyst/ui/dal/HomeConversationsFeedFilter.kt | 6 +++--- .../amethyst/ui/dal/HomeNewThreadFeedFilter.kt | 8 ++++---- .../amethyst/ui/dal/NotificationFeedFilter.kt | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt index b55a9adf2..ebd4d2cdf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt @@ -9,17 +9,17 @@ object GlobalFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { - val notes = applyFilter(LocalCache.notes.values) - val longFormNotes = applyFilter(LocalCache.addressables.values) + val notes = innerApplyFilter(LocalCache.notes.values) + val longFormNotes = innerApplyFilter(LocalCache.addressables.values) return sort(notes + longFormNotes) } override fun applyFilter(collection: Set): List { - return applyFilter(collection) + return innerApplyFilter(collection) } - private fun applyFilter(collection: Collection): List { + private fun innerApplyFilter(collection: Collection): List { val followChannels = account.followingChannels val followUsers = account.followingKeySet() val now = System.currentTimeMillis() / 1000 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 c33593ff4..32354c408 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 @@ -18,14 +18,14 @@ object HashtagFeedFilter : AdditiveFeedFilter() { } override fun feed(): List { - return sort(applyFilter(LocalCache.notes.values)) + return sort(innerApplyFilter(LocalCache.notes.values)) } override fun applyFilter(collection: Set): List { return applyFilter(collection) } - private fun applyFilter(collection: Collection): List { + private fun innerApplyFilter(collection: Collection): List { val myTag = tag ?: return emptyList() return collection 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 5f2f3e6e5..f18885612 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 @@ -10,14 +10,14 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { - return sort(applyFilter(LocalCache.notes.values)) + return sort(innerApplyFilter(LocalCache.notes.values)) } override fun applyFilter(collection: Set): List { - return applyFilter(collection) + return innerApplyFilter(collection) } - private fun applyFilter(collection: Collection): List { + private fun innerApplyFilter(collection: Collection): List { val user = account.userProfile() val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = user.cachedFollowingTagSet() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index 443c11526..3ddf1471a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -12,17 +12,17 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { - val notes = applyFilter(LocalCache.notes.values) - val longFormNotes = applyFilter(LocalCache.addressables.values) + val notes = innerApplyFilter(LocalCache.notes.values) + val longFormNotes = innerApplyFilter(LocalCache.addressables.values) return sort(notes + longFormNotes) } override fun applyFilter(collection: Set): List { - return applyFilter(collection) + return innerApplyFilter(collection) } - private fun applyFilter(collection: Collection): List { + private fun innerApplyFilter(collection: Collection): List { val user = account.userProfile() val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = user.cachedFollowingTagSet() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 39334ca15..8cb1f6280 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -10,14 +10,14 @@ object NotificationFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { - return sort(applyFilter(LocalCache.notes.values)) + return sort(innerApplyFilter(LocalCache.notes.values)) } override fun applyFilter(collection: Set): List { - return applyFilter(collection) + return innerApplyFilter(collection) } - private fun applyFilter(collection: Collection): List { + private fun innerApplyFilter(collection: Collection): List { val loggedInUser = account.userProfile() val loggedInUserHex = loggedInUser.pubkeyHex From 6981fe8f8ac8ed8ec48fef059c34db0e80dada30 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Apr 2023 16:35:26 -0400 Subject: [PATCH 31/61] Adapting interfaces for the additive filter. --- .../amethyst/ui/dal/ChannelFeedFilter.kt | 8 +-- .../amethyst/ui/dal/ChatroomFeedFilter.kt | 12 ++--- .../amethyst/ui/dal/FeedFilter.kt | 6 +-- .../amethyst/ui/dal/GlobalFeedFilter.kt | 8 +-- .../amethyst/ui/dal/HashtagFeedFilter.kt | 10 ++-- .../ui/dal/HomeConversationsFeedFilter.kt | 8 +-- .../ui/dal/HomeNewThreadFeedFilter.kt | 8 +-- .../amethyst/ui/dal/NotificationFeedFilter.kt | 8 +-- .../amethyst/ui/screen/CardFeedViewModel.kt | 54 ++++++++++++++++--- 9 files changed, 81 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt index 14e4b0955..aa2921dce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt @@ -23,13 +23,13 @@ object ChannelFeedFilter : AdditiveFeedFilter() { .reversed() } - override fun applyFilter(collection: Set): List { + override fun applyFilter(collection: Set): Set { return collection - .filter { it.idHex in channel.notes.keys } - .filter { account.isAcceptable(it) } + .filter { it.idHex in channel.notes.keys && account.isAcceptable(it) } + .toSet() } - override fun sort(collection: List): List { + override fun sort(collection: Set): List { return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index d885ce7e4..f96c68681 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -31,22 +31,22 @@ object ChatroomFeedFilter : AdditiveFeedFilter() { .reversed() } - override fun applyFilter(collection: Set): List { + override fun applyFilter(collection: Set): Set { val myAccount = account val myUser = withUser - if (myAccount == null || myUser == null) return emptyList() + if (myAccount == null || myUser == null) return emptySet() val messages = myAccount .userProfile() - .privateChatrooms[myUser] ?: return emptyList() + .privateChatrooms[myUser] ?: return emptySet() return collection - .filter { it in messages.roomMessages } - .filter { account?.isAcceptable(it) == true } + .filter { it in messages.roomMessages && account?.isAcceptable(it) == true } + .toSet() } - override fun sort(collection: List): List { + override fun sort(collection: Set): List { return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index 9254d766b..505a959ef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -19,15 +19,15 @@ abstract class FeedFilter { } abstract class AdditiveFeedFilter : FeedFilter() { - abstract fun applyFilter(collection: Set): List - abstract fun sort(collection: List): List + abstract fun applyFilter(collection: Set): Set + abstract fun sort(collection: Set): List @OptIn(ExperimentalTime::class) fun updateListWith(oldList: List, newItems: Set): List { val (feed, elapsed) = measureTimedValue { val newItemsToBeAdded = applyFilter(newItems) if (newItemsToBeAdded.isNotEmpty()) { - val newList = oldList + newItemsToBeAdded + val newList = oldList.toSet() + newItemsToBeAdded sort(newList).take(1000) } else { oldList diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt index ebd4d2cdf..f17d5ade5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt @@ -15,11 +15,11 @@ object GlobalFeedFilter : AdditiveFeedFilter() { return sort(notes + longFormNotes) } - override fun applyFilter(collection: Set): List { + override fun applyFilter(collection: Set): Set { return innerApplyFilter(collection) } - private fun innerApplyFilter(collection: Collection): List { + private fun innerApplyFilter(collection: Collection): Set { val followChannels = account.followingChannels val followUsers = account.followingKeySet() val now = System.currentTimeMillis() / 1000 @@ -41,10 +41,10 @@ object GlobalFeedFilter : AdditiveFeedFilter() { // Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating. it.createdAt()!! <= now } - .toList() + .toSet() } - override fun sort(collection: List): List { + override fun sort(collection: Set): List { return collection.sortedBy { it.createdAt() }.reversed() } } 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 32354c408..d4fd4765e 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 @@ -21,12 +21,12 @@ object HashtagFeedFilter : AdditiveFeedFilter() { return sort(innerApplyFilter(LocalCache.notes.values)) } - override fun applyFilter(collection: Set): List { + override fun applyFilter(collection: Set): Set { return applyFilter(collection) } - private fun innerApplyFilter(collection: Collection): List { - val myTag = tag ?: return emptyList() + private fun innerApplyFilter(collection: Collection): Set { + val myTag = tag ?: return emptySet() return collection .asSequence() @@ -40,10 +40,10 @@ object HashtagFeedFilter : AdditiveFeedFilter() { it.event?.isTaggedHash(myTag) == true } .filter { account.isAcceptable(it) } - .toList() + .toSet() } - override fun sort(collection: List): List { + override fun sort(collection: Set): List { return collection.sortedBy { it.createdAt() }.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 f18885612..6026e2669 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 @@ -13,11 +13,11 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter() { return sort(innerApplyFilter(LocalCache.notes.values)) } - override fun applyFilter(collection: Set): List { + override fun applyFilter(collection: Set): Set { return innerApplyFilter(collection) } - private fun innerApplyFilter(collection: Collection): List { + private fun innerApplyFilter(collection: Collection): Set { val user = account.userProfile() val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = user.cachedFollowingTagSet() @@ -31,10 +31,10 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter() { it.author?.let { !account.isHidden(it) } ?: true && !it.isNewThread() } - .toList() + .toSet() } - override fun sort(collection: List): List { + override fun sort(collection: Set): List { return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index 3ddf1471a..d31a45c68 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -18,11 +18,11 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() { return sort(notes + longFormNotes) } - override fun applyFilter(collection: Set): List { + override fun applyFilter(collection: Set): Set { return innerApplyFilter(collection) } - private fun innerApplyFilter(collection: Collection): List { + private fun innerApplyFilter(collection: Collection): Set { val user = account.userProfile() val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = user.cachedFollowingTagSet() @@ -36,10 +36,10 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true && it.isNewThread() } - .toList() + .toSet() } - override fun sort(collection: List): List { + override fun sort(collection: Set): List { return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 8cb1f6280..ba746ebba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -13,11 +13,11 @@ object NotificationFeedFilter : AdditiveFeedFilter() { return sort(innerApplyFilter(LocalCache.notes.values)) } - override fun applyFilter(collection: Set): List { + override fun applyFilter(collection: Set): Set { return innerApplyFilter(collection) } - private fun innerApplyFilter(collection: Collection): List { + private fun innerApplyFilter(collection: Collection): Set { val loggedInUser = account.userProfile() val loggedInUserHex = loggedInUser.pubkeyHex @@ -31,10 +31,10 @@ object NotificationFeedFilter : AdditiveFeedFilter() { it.event?.isTaggedUser(loggedInUserHex) ?: false && (it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && tagsAnEventByUser(it, loggedInUser) - } + }.toSet() } - override fun sort(collection: List): List { + override fun sort(collection: Set): List { return collection.sortedBy { it.createdAt() }.reversed() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index f2a2eb9cc..f7e1a24c0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -14,7 +14,9 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import kotlinx.coroutines.CoroutineScope @@ -29,7 +31,7 @@ import kotlin.time.measureTimedValue class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter) -open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { +open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { private val _feedContent = MutableStateFlow(CardFeedState.Loading) val feedContent = _feedContent.asStateFlow() @@ -45,9 +47,9 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { @Synchronized private fun refreshSuspended() { - val notes = dataSource.loadTop() + val notes = localFilter.loadTop() - val thisAccount = (dataSource as? NotificationFeedFilter)?.account + val thisAccount = (localFilter as? NotificationFeedFilter)?.account val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null val oldNotesState = _feedContent.value @@ -55,18 +57,18 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { val newCards = convertToCard(notes.minus(lastNotesCopy)) if (newCards.isNotEmpty()) { lastNotes = notes - lastAccount = (dataSource as? NotificationFeedFilter)?.account + lastAccount = (localFilter as? NotificationFeedFilter)?.account updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed()) } } else { val cards = convertToCard(notes) lastNotes = notes - lastAccount = (dataSource as? NotificationFeedFilter)?.account + lastAccount = (localFilter as? NotificationFeedFilter)?.account updateFeed(cards) } } - private fun convertToCard(notes: List): List { + private fun convertToCard(notes: Collection): List { val reactionsPerEvent = mutableMapOf>() notes .filter { it.event is ReactionEvent } @@ -171,6 +173,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { } } + fun refreshFromOldState(newItems: Set) { + val oldNotesState = _feedContent.value + + val thisAccount = (localFilter as? NotificationFeedFilter)?.account + val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null + + if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) { + val filteredNewList = localFilter.applyFilter(newItems) + val actuallyNew = filteredNewList.minus(lastNotesCopy) + + val newCards = convertToCard(actuallyNew) + if (newCards.isNotEmpty()) { + lastNotes = lastNotesCopy + newItems + lastAccount = (localFilter as? NotificationFeedFilter)?.account + updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed()) + } + } else { + // Refresh Everything + refreshSuspended() + } + } + @OptIn(ExperimentalTime::class) private val bundler = BundledUpdate(250, Dispatchers.IO) { // adds the time to perform the refresh into this delay @@ -180,13 +204,29 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { } Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") } + private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) fun invalidateData() { bundler.invalidate() } + @OptIn(ExperimentalTime::class) + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { + val (value, elapsed) = measureTimedValue { + refreshFromOldState(it.flatten().toSet()) + } + Log.d("Time", "${this.javaClass.simpleName} Card additive update $elapsed") + } + } + private val cacheListener: (Set) -> Unit = { newNotes -> - invalidateData() + if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } } init { From 7ee3b8a19638dbde7da613a03daed7e1aee2c5dd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:06:12 -0400 Subject: [PATCH 32/61] Improves performance of video loading and playback. --- .../com/vitorpamplona/amethyst/VideoCache.kt | 4 +- .../vitorpamplona/amethyst/ui/MainActivity.kt | 4 ++ .../amethyst/ui/components/VideoView.kt | 58 ++++++++++++------- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt index 4d01c8301..bd785161b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt @@ -18,7 +18,7 @@ object VideoCache { lateinit var cacheDataSourceFactory: CacheDataSource.Factory - fun get(context: Context): CacheDataSource.Factory { + fun init(context: Context) { if (!this::simpleCache.isInitialized) { exoDatabaseProvider = StandaloneDatabaseProvider(context) @@ -35,7 +35,9 @@ object VideoCache { ) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) } + } + fun get(): CacheDataSource.Factory { return cacheDataSourceFactory } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 49823b3e4..e60c431fc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -19,6 +19,7 @@ import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager +import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.navigation.Route @@ -54,6 +55,9 @@ class MainActivity : FragmentActivity() { null } + // Initializes video cache. + VideoCache.init(this.applicationContext) + Coil.setImageLoader { ImageLoader.Builder(this).components { if (SDK_INT >= 28) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index ea18cd3b5..ab67d171d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -6,9 +6,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem @@ -21,44 +25,54 @@ import com.vitorpamplona.amethyst.VideoCache @Composable fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) { val context = LocalContext.current + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) - val exoPlayer = remember { + val exoPlayer = remember(videoUri) { ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_ALL videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING setMediaSource( - ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri)) + ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(MediaItem.fromUri(videoUri)) ) prepare() } } - val playerView = remember { - StyledPlayerView(context).apply { - player = exoPlayer - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH - onDialog?.let { innerOnDialog -> - setFullscreenButtonClickListener { - innerOnDialog(it) + DisposableEffect( + AndroidView( + modifier = Modifier.fillMaxWidth(), + factory = { + StyledPlayerView(context).apply { + player = exoPlayer + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH + onDialog?.let { innerOnDialog -> + setFullscreenButtonClickListener { + exoPlayer.pause() + innerOnDialog(it) + } + } } } + ) + ) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + exoPlayer.pause() + } + else -> {} + } } - } + val lifecycle = lifecycleOwner.value.lifecycle + lifecycle.addObserver(observer) - DisposableEffect(exoPlayer) { onDispose { exoPlayer.release() + lifecycle.removeObserver(observer) } } - - AndroidView( - modifier = Modifier.fillMaxWidth(), - factory = { - playerView - } - ) } From 00e54706424897f75aaccc8c43fc20cbb836aa94 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:07:03 -0400 Subject: [PATCH 33/61] Passing the size of the robohash rendering to the request in the hopes to improve performance. --- .../amethyst/ui/components/Robohash.kt | 7 ++- .../ui/components/RobohashAsyncImage.kt | 47 ++++++++++++++----- .../ui/note/ChatroomMessageCompose.kt | 1 + .../amethyst/ui/note/NoteCompose.kt | 2 + .../ui/screen/loggedIn/ProfileScreen.kt | 2 + 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt index 556c3a134..dedb0cdd4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt @@ -11,6 +11,7 @@ import coil.fetch.Fetcher import coil.fetch.SourceResult import coil.request.ImageRequest import coil.request.Options +import coil.size.Size import okio.Buffer import java.security.MessageDigest @@ -61,6 +62,7 @@ private fun svgString(msg: String): String { class HashImageFetcher( private val context: Context, + private val size: Size, private val data: Uri ) : Fetcher { @@ -79,16 +81,17 @@ class HashImageFetcher( object Factory : Fetcher.Factory { override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher { - return HashImageFetcher(options.context, data) + return HashImageFetcher(options.context, options.size, data) } } } object Robohash { - fun imageRequest(context: Context, message: String): ImageRequest { + fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest { return ImageRequest .Builder(context) .data("robohash:$message") .fetcherFactory(HashImageFetcher.Factory) + .size(robotSize) .crossfade(100) .build() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 73b500ccf..845037266 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -9,13 +9,17 @@ import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter +import coil.size.Size @Composable fun RobohashAsyncImage( robot: String, + robotSize: Dp, modifier: Modifier = Modifier, contentDescription: String? = null, transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform, @@ -26,23 +30,30 @@ fun RobohashAsyncImage( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - AsyncImage( - model = Robohash.imageRequest(LocalContext.current, robot), - contentDescription = contentDescription, - modifier = modifier, - transform = transform, - onState = onState, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality - ) + with(LocalDensity.current) { + AsyncImage( + model = Robohash.imageRequest( + LocalContext.current, + robot, + Size(robotSize.roundToPx(), robotSize.roundToPx()) + ), + contentDescription = contentDescription, + modifier = modifier, + transform = transform, + onState = onState, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) + } } @Composable fun RobohashFallbackAsyncImage( robot: String, + robotSize: Dp, model: String?, contentDescription: String?, modifier: Modifier = Modifier, @@ -53,7 +64,15 @@ fun RobohashFallbackAsyncImage( filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { val context = LocalContext.current - val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot)) + val painter = with(LocalDensity.current) { + rememberAsyncImagePainter( + model = Robohash.imageRequest( + context, + robot, + Size(robotSize.roundToPx(), robotSize.roundToPx()) + ) + ) + } AsyncImage( model = model, @@ -85,6 +104,7 @@ fun RobohashAsyncImageProxy( if (model.url == null) { RobohashAsyncImage( robot = robot, + robotSize = model.size, contentDescription = contentDescription, modifier = modifier, alignment = alignment, @@ -96,6 +116,7 @@ fun RobohashAsyncImageProxy( } else { RobohashFallbackAsyncImage( robot = robot, + robotSize = model.size, model = model.proxyUrl(), contentDescription = contentDescription, modifier = modifier, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index bee998015..1294cf883 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -373,6 +373,7 @@ private fun RelayBadges(baseNote: Note) { ) { RobohashFallbackAsyncImage( robot = "https://$url/favicon.ico", + robotSize = 15.dp, model = "https://$url/favicon.ico", contentDescription = stringResource(id = R.string.relay_icon), colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), 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 8400d174b..34c97bd9b 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 @@ -898,6 +898,7 @@ private fun RelayBadges(baseNote: Note) { ) { RobohashFallbackAsyncImage( robot = "https://$url/favicon.ico", + robotSize = 15.dp, model = "https://$url/favicon.ico", contentDescription = stringResource(R.string.relay_icon), colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), @@ -970,6 +971,7 @@ fun NoteAuthorPicture( if (author == null) { RobohashAsyncImage( robot = "authornotfound", + robotSize = size, contentDescription = stringResource(R.string.unknown_author), modifier = modifier .width(size) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 6c832ceac..2b69981de 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -655,6 +655,7 @@ fun BadgeThumb( if (image == null) { RobohashAsyncImage( robot = "authornotfound", + robotSize = size, contentDescription = stringResource(R.string.unknown_author), modifier = pictureModifier .width(size) @@ -664,6 +665,7 @@ fun BadgeThumb( } else { RobohashFallbackAsyncImage( robot = note.idHex, + robotSize = size, model = image, contentDescription = stringResource(id = R.string.profile_image), modifier = pictureModifier From b11b52b60c1ab0218688971aa77d03a5a92069a1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:07:10 -0400 Subject: [PATCH 34/61] typo --- .../java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 34c97bd9b..a09fe58ca 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 @@ -496,13 +496,13 @@ fun NoteComposeInner( noteEvent.recipientPubKey() != loggedIn.pubkeyHex && note.author !== loggedIn ) { - val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) } + val recipient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) } TranslatableRichTextViewer( stringResource( id = R.string.private_conversation_notification, "@${note.author?.pubkeyNpub()}", - "@${recepient?.pubkeyNpub()}" + "@${recipient?.pubkeyNpub()}" ), canPreview = !makeItShort, Modifier.fillMaxWidth(), From 64321dbe7c648286fcbd501cc1fe35d6c901f403 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:07:34 -0400 Subject: [PATCH 35/61] No need to sort. It will be resorted later. --- .../com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index e755a8df1..8c2686973 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -20,11 +20,9 @@ import com.vitorpamplona.amethyst.model.* @Composable fun ReplyInformation(replyTo: List?, mentions: List, account: Account, navController: NavController) { - val sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } - .toSet() - .sortedBy { account.userProfile().isFollowingCached(it) } + val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } - ReplyInformation(replyTo, sortedMentions, account) { + ReplyInformation(replyTo, dupMentions, account) { navController.navigate("User/${it.pubkeyHex}") } } From 42801e3ecab095b8cd78b831f21fe0f4361c4ca5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:08:32 -0400 Subject: [PATCH 36/61] Using the URL Detector (which is faster) to catch urls in the text --- .../amethyst/ui/components/RichTextViewer.kt | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 474a1fea7..6a4e24272 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -32,6 +32,8 @@ import com.halilibo.richtext.markdown.MarkdownParseOptions import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.material.MaterialRichText import com.halilibo.richtext.ui.resolveDefaults +import com.linkedin.urls.detection.UrlDetector +import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -132,22 +134,13 @@ fun RichTextViewer( ) } } else { - val imagesForPager = mutableListOf() - - content.split('\n').forEach { paragraph -> - paragraph.split(' ').forEach { word: String -> - // sequence of images will render in a slideview - if (isValidURL(word)) { - val removedParamsFromUrl = word.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - imagesForPager.add(word) - } - if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - imagesForPager.add(word) - } - } - } + val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() + val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } + val imagesForPager = urlSet.filter { fullUrl -> + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + imageExtensions.any { removedParamsFromUrl.endsWith(it) } || videoExtensions.any { removedParamsFromUrl.endsWith(it) } } + val imagesForPagerSet = imagesForPager.toSet() // FlowRow doesn't work well with paragraphs. So we need to split them content.split('\n').forEach { paragraph -> @@ -156,15 +149,10 @@ fun RichTextViewer( s.forEach { word: String -> if (canPreview) { // Explicit URL - if (isValidURL(word)) { - val removedParamsFromUrl = word.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableImageView(word, imagesForPager) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableImageView(word, imagesForPager) - } else { - UrlPreview(word, "$word ") - } + if (imagesForPagerSet.contains(word)) { + ZoomableImageView(word, imagesForPager) + } else if (urlSet.contains(word)) { + UrlPreview(word, "$word ") } else if (word.startsWith("lnbc", true)) { MayBeInvoicePreview(word) } else if (word.startsWith("lnurl", true)) { @@ -214,7 +202,7 @@ fun RichTextViewer( ) } } else { - if (isValidURL(word)) { + if (urlSet.contains(word)) { ClickableUrl("$word ", word) } else if (word.startsWith("lnurl", true)) { val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word) From 75922f93bc919ad434cf8ee4f0f5e4dfecc4ae48 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:09:28 -0400 Subject: [PATCH 37/61] Moving ReactionsRow into a Lazy Grid --- .../amethyst/ui/note/ReactionsRow.kt | 91 +++++++++++-------- 1 file changed, 51 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 5a0d7a3b4..a60fa6184 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -9,11 +9,12 @@ 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -87,26 +88,38 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { NewPostView({ wantsToQuote = null }, null, wantsToQuote, account) } - Row( - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + Spacer(modifier = Modifier.height(8.dp)) + + LazyVerticalGrid( + columns = GridCells.Fixed(5), + modifier = Modifier.height(20.dp), + userScrollEnabled = false, + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.Center ) { - ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) { - wantsToReplyTo = baseNote + items(5) { + when (it) { + 0 -> Row(verticalAlignment = CenterVertically) { + ReplyReaction(baseNote, accountViewModel) { + wantsToReplyTo = baseNote + } + } + 1 -> Row(verticalAlignment = CenterVertically) { + BoostReaction(baseNote, accountViewModel) { + wantsToQuote = baseNote + } + } + 2 -> Row(verticalAlignment = CenterVertically) { + LikeReaction(baseNote, accountViewModel) + } + 3 -> Row(verticalAlignment = CenterVertically) { + ZapReaction(baseNote, accountViewModel) + } + 4 -> Row(verticalAlignment = CenterVertically) { + ViewCountReaction(baseNote.idHex) + } + } } - - BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) { - wantsToQuote = baseNote - } - - LikeReaction(baseNote, accountViewModel, Modifier.weight(1f)) - - ZapReaction(baseNote, accountViewModel, Modifier.weight(1f)) - - ViewCountReaction(baseNote, Modifier.weight(1f)) } } @@ -114,14 +127,12 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { fun ReplyReaction( baseNote: Note, accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier, showCounter: Boolean = true, onPress: () -> Unit ) { val repliesState by baseNote.live().replies.observeAsState() val replies = repliesState?.note?.replies ?: emptySet() - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -141,29 +152,32 @@ fun ReplyReaction( } } ) { - Icon( - painter = painterResource(R.drawable.ic_comment), - null, - modifier = Modifier.size(15.dp), - tint = grayTint - ) + ReplyIcon() } if (showCounter) { Text( " ${showCount(replies.size)}", fontSize = 14.sp, - color = grayTint, - modifier = textModifier + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } } +@Composable +private fun ReplyIcon() { + Icon( + painter = painterResource(R.drawable.ic_comment), + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) +} + @Composable private fun BoostReaction( baseNote: Note, accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier, onQuotePress: () -> Unit ) { val boostsState by baseNote.live().boosts.observeAsState() @@ -229,16 +243,14 @@ private fun BoostReaction( Text( " ${showCount(boostedNote?.boosts?.size)}", fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = textModifier + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @Composable fun LikeReaction( baseNote: Note, - accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier + accountViewModel: AccountViewModel ) { val reactionsState by baseNote.live().reactions.observeAsState() val reactedNote = reactionsState?.note ?: return @@ -287,8 +299,7 @@ fun LikeReaction( Text( " ${showCount(reactedNote.reactions.size)}", fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = textModifier + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @@ -457,13 +468,13 @@ fun ZapReaction( } @Composable -private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) { +private fun ViewCountReaction(idHex: String) { val uri = LocalUriHandler.current val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) IconButton( modifier = Modifier.size(20.dp), - onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") } + onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") } ) { Icon( imageVector = Icons.Outlined.BarChart, @@ -473,10 +484,10 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) ) } - Row(modifier = textModifier) { + Row() { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000") + .data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000") .diskCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.ENABLED) .build(), From cfdb20892ed178e5bbb3e7e1a0bccc53e5e029f0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:53:09 -0400 Subject: [PATCH 38/61] Updating dependencies --- app/build.gradle | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c31919d7d..7af3bc96b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,7 +85,7 @@ android { } dependencies { - implementation 'androidx.core:core-ktx:1.9.0' + implementation 'androidx.core:core-ktx:1.10.0' implementation 'androidx.activity:activity-compose:1.7.0-beta01' implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" diff --git a/build.gradle b/build.gradle index b44afca7a..347dc9b4d 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { fragment_version = "1.5.6" lifecycle_version = '2.6.1' - compose_ui_version = '1.5.0-alpha01' + compose_ui_version = '1.5.0-alpha02' nav_version = "2.5.3" room_version = "2.4.3" accompanist_version = '0.30.0' From 817c5172b6fc479b436e5100c8a6f003f7b86215 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:54:35 -0400 Subject: [PATCH 39/61] Clearing some warnings up --- .../vitorpamplona/amethyst/model/Account.kt | 4 +-- .../amethyst/model/LocalCache.kt | 5 ---- .../service/NostrHashtagDataSource.kt | 9 +++++- .../service/NostrSingleEventDataSource.kt | 5 ---- .../amethyst/service/NostrThreadDataSource.kt | 2 +- .../amethyst/ui/actions/NewMessageTagger.kt | 8 +++--- .../amethyst/ui/actions/NewPollOption.kt | 10 ------- .../amethyst/ui/actions/NewPollView.kt | 4 +-- .../amethyst/ui/actions/NewPostView.kt | 4 +-- .../amethyst/ui/components/RichTextViewer.kt | 28 ++++++++++++++----- .../amethyst/ui/components/UrlPreviewCard.kt | 8 +++--- 11 files changed, 44 insertions(+), 43 deletions(-) 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 5a36d7297..8a0a85a82 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -728,11 +728,11 @@ class Account( fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers fun followingKeySet(): Set { - return userProfile().cachedFollowingKeySet() ?: emptySet() + return userProfile().cachedFollowingKeySet() } fun followingTagSet(): Set { - return userProfile().cachedFollowingTagSet() ?: emptySet() + return userProfile().cachedFollowingTagSet() } fun isAcceptable(user: User): Boolean { 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 ee4636288..41539afa1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -700,11 +700,6 @@ object LocalCache { notes.remove(it.idHex) // Doesn't need to clean up the replies and mentions.. Too small to matter. - // reverts the add - val mentions = - it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) } - ?.mapNotNull { checkGetOrCreateUser(it) } - // Counts the replies it.replyTo?.forEach { _ -> it.removeReply(it) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index d3fe1e4ce..81df891f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -17,7 +17,14 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { return TypedFilter( types = FeedType.values().toSet(), filter = JsonFilter( - tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())), + tags = mapOf( + "t" to listOf( + hashToLoad, + hashToLoad.lowercase(), + hashToLoad.uppercase(), + hashToLoad.capitalize() + ) + ), kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), limit = 200 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 29f6cf38c..aa4e87eb5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter -import java.util.Date object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { private var eventsToWatch = setOf() @@ -20,8 +19,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return null } - val now = Date().time / 1000 - return addressesToWatch.mapNotNull { it.address()?.let { aTag -> TypedFilter( @@ -49,8 +46,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return null } - val now = Date().time / 1000 - return addressesToWatch.mapNotNull { it.address()?.let { aTag -> TypedFilter( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt index f1502a24a..7919354c1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -25,7 +25,7 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") { ) } - val loadEventsChannel = requestNewChannel() { eoseTime, relay -> + val loadEventsChannel = requestNewChannel() { _, _ -> // Many relays operate with limits in the amount of filters. // As information comes, the filters will be rotated to get more data. invalidateFilters() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 516ac6e52..b0d6428f4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -9,21 +9,21 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19 class NewMessageTagger(var channel: Channel?, var mentions: List?, var replyTos: List?, var message: String) { - open fun addUserToMentions(user: User) { + fun addUserToMentions(user: User) { mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user) } - open fun addNoteToReplyTos(note: Note) { + fun addNoteToReplyTos(note: Note) { note.author?.let { addUserToMentions(it) } replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note) } - open fun tagIndex(user: User): Int { + fun tagIndex(user: User): Int { // Postr Events assembles replies before mentions in the tag order return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0) } - open fun tagIndex(note: Note): Int { + fun tagIndex(note: Note): Int { // Postr Events assembles replies before mentions in the tag order return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt index 876134cff..62c607638 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt @@ -7,7 +7,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview @@ -15,15 +14,6 @@ import com.vitorpamplona.amethyst.R @Composable fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) { - val colorInValid = TextFieldDefaults.outlinedTextFieldColors( - focusedBorderColor = MaterialTheme.colors.error, - unfocusedBorderColor = Color.Red - ) - val colorValid = TextFieldDefaults.outlinedTextFieldColors( - focusedBorderColor = MaterialTheme.colors.primary, - unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - Row { val deleteIcon: @Composable (() -> Unit) = { IconButton( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt index 12977d096..a238e1285 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt @@ -116,7 +116,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n Text(stringResource(R.string.poll_heading_required)) // NewPollRecipientsField(pollViewModel, account) NewPollPrimaryDescription(pollViewModel) - pollViewModel.pollOptions.values.forEachIndexed { index, element -> + pollViewModel.pollOptions.values.forEachIndexed { index, _ -> NewPollOption(pollViewModel, index) } Button( @@ -150,7 +150,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n itemsIndexed( userSuggestions, key = { _, item -> item.pubkeyHex } - ) { index, item -> + ) { _, item -> UserLine(item, account) { pollViewModel.autocompleteWithUser(item) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 213573b22..35210be77 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -167,7 +167,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n ) if (postViewModel.wantsPoll) { - postViewModel.pollOptions.values.forEachIndexed { index, element -> + postViewModel.pollOptions.values.forEachIndexed { index, _ -> NewPollOption(postViewModel, index) } @@ -189,7 +189,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n val user = postViewModel.account?.userProfile() val lud16 = user?.info?.lnAddress() - if (lud16 != null && user != null && postViewModel.wantsInvoice) { + if (lud16 != null && postViewModel.wantsInvoice) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { InvoiceRequest( lud16, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 6a4e24272..a406e9337 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -180,7 +180,7 @@ fun RichTextViewer( navController ) } else if (hashTagsPattern.matcher(word).matches()) { - HashTag(word, accountViewModel, navController) + HashTag(word, navController) } else { Text( text = "$word ", @@ -193,8 +193,15 @@ fun RichTextViewer( val url = matcher.group(1) // url val additionalChars = matcher.group(4) ?: "" // additional chars - ClickableUrl(url, "https://$url") - Text("$additionalChars ") + if (url != null) { + ClickableUrl(url, "https://$url") + Text("$additionalChars ") + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } } else { Text( text = "$word ", @@ -237,7 +244,7 @@ fun RichTextViewer( navController ) } else if (hashTagsPattern.matcher(word).matches()) { - HashTag(word, accountViewModel, navController) + HashTag(word, navController) } else { Text( text = "$word ", @@ -250,8 +257,15 @@ fun RichTextViewer( val url = matcher.group(1) // url val additionalChars = matcher.group(4) ?: "" // additional chars - ClickableUrl(url, "https://$url") - Text("$additionalChars ") + if (url != null) { + ClickableUrl(url, "https://$url") + Text("$additionalChars ") + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } } else { Text( text = "$word ", @@ -328,7 +342,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV } @Composable -fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) { +fun HashTag(word: String, navController: NavController) { var tagSuffixPair by remember { mutableStateOf?>(null) } LaunchedEffect(key1 = word) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index e8453aff5..561d86ab3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -41,24 +41,24 @@ fun UrlPreviewCard( ) ) { Column { - val url = URL(previewInfo.url) + val validatedUrl = URL(previewInfo.url) // correctly treating relative images val imageUrl = if (previewInfo.image.startsWith("/")) { - URL(url, previewInfo.image).toString() + URL(validatedUrl, previewInfo.image).toString() } else { previewInfo.image } AsyncImage( model = imageUrl, - contentDescription = stringResource(R.string.preview_card_image_for, url), + contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl), contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxWidth() ) Text( - text = url.host, + text = validatedUrl.host, style = MaterialTheme.typography.caption, modifier = Modifier .fillMaxWidth() From d9396ce453ad62cd0123afd54acda520e2a1481f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 10:55:00 -0400 Subject: [PATCH 40/61] Migrating to the default Pager from Accompanist's library. --- app/build.gradle | 4 ---- .../amethyst/ui/components/SlidingCarousel.kt | 10 +++++----- .../ui/components/ZoomableImageView.kt | 5 ++--- .../amethyst/ui/navigation/AppNavigation.kt | 6 +++--- .../ui/screen/loggedIn/BookmarkListScreen.kt | 20 ++++++------------- .../ui/screen/loggedIn/ChatroomListScreen.kt | 20 ++++++------------- .../ui/screen/loggedIn/HiddenUsersScreen.kt | 20 ++++++------------- .../amethyst/ui/screen/loggedIn/HomeScreen.kt | 20 ++++++------------- .../ui/screen/loggedIn/ProfileScreen.kt | 18 +++++------------ 9 files changed, 39 insertions(+), 84 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7af3bc96b..2af92aec8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -151,10 +151,6 @@ dependencies { // Permission to upload pictures: implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" - // tabs for user profiles - implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager - implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version" - // Parses URLs from Text: implementation "io.github.url-detector:url-detector:0.1.23" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt index 03636135b..52f2328a7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.components +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Box @@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -22,11 +25,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.PagerState -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun SlidingCarousel( modifier: Modifier = Modifier, @@ -39,7 +39,7 @@ fun SlidingCarousel( Box( modifier = modifier.fillMaxWidth() ) { - HorizontalPager(count = itemsCount, state = pagerState) { page -> + HorizontalPager(pageCount = itemsCount, state = pagerState) { page -> itemContent(page) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt index e547da6bd..20f82bfc6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -38,8 +39,6 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import coil.compose.AsyncImagePainter -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.PagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation @@ -130,7 +129,7 @@ fun ZoomableImageView(word: String, images: List = listOf(word)) { } } -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun ZoomableImageDialog(imageUrl: String, allImages: List = listOf(imageUrl), onDismiss: () -> Unit) { Dialog( 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 d7418272c..fc655954e 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 @@ -1,5 +1,7 @@ package com.vitorpamplona.amethyst.ui.navigation +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -11,8 +13,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter @@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun AppNavigation( navController: NavHostController, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt index df3289bca..ab92f670d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt @@ -1,12 +1,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -18,10 +20,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.pagerTabIndicatorOffset -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter @@ -30,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -57,13 +55,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -80,7 +72,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon } ) } - HorizontalPager(count = 2, state = pagerState) { + HorizontalPager(pageCount = 2, state = pagerState) { when (pagerState.currentPage) { 0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null) 1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt index 6406aced9..b86669ebc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight @@ -7,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon @@ -14,7 +17,6 @@ import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert @@ -37,10 +39,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.pagerTabIndicatorOffset -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter @@ -50,7 +48,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) { val pagerState = rememberPagerState() @@ -68,13 +66,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon Box(Modifier.fillMaxWidth()) { TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -115,7 +107,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon } } - HorizontalPager(count = 2, state = pagerState) { + HorizontalPager(pageCount = 2, state = pagerState) { when (pagerState.currentPage) { 0 -> TabKnown(accountViewModel, navController, markKnownAsRead) 1 -> TabNew(accountViewModel, navController, markNewAsRead) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 436c1b0b0..40dfa3099 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -1,12 +1,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -17,17 +19,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.pagerTabIndicatorOffset -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.UserFeedView import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -45,13 +43,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -61,7 +53,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont } ) } - HorizontalPager(count = 1, state = pagerState) { + HorizontalPager(pageCount = 1, state = pagerState) { when (pagerState.currentPage) { 0 -> UserFeedView(feedViewModel, accountViewModel, navController) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index c191de3aa..af8adb2c3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -1,12 +1,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +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.TabRowDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -23,10 +25,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.PagerState -import com.google.accompanist.pager.pagerTabIndicatorOffset import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter @@ -39,7 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun HomeScreen( homeFeedViewModel: NostrHomeFeedViewModel, @@ -90,13 +88,7 @@ fun HomeScreen( ) { TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -114,7 +106,7 @@ fun HomeScreen( } ) } - HorizontalPager(count = 2, state = pagerState) { + HorizontalPager(pageCount = 2, state = pagerState) { when (pagerState.currentPage) { 0 -> FeedView(homeFeedViewModel, accountViewModel, navController, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS, scrollToTop) 1 -> FeedView(repliesFeedViewModel, accountViewModel, navController, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES, scrollToTop) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 2b69981de..77da720bc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -5,6 +5,8 @@ import android.net.Uri import androidx.compose.foundation.* import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText @@ -48,10 +50,6 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import coil.compose.AsyncImage -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.pagerTabIndicatorOffset -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -98,7 +96,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.math.BigDecimal -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -187,12 +185,6 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro ScrollableTabRow( backgroundColor = MaterialTheme.colors.background, selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - }, edgePadding = 8.dp, modifier = Modifier.onSizeChanged { tabsSize = it @@ -268,7 +260,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro } } HorizontalPager( - count = 8, + pageCount = 8, state = pagerState, modifier = with(LocalDensity.current) { Modifier.height((columnSize.height - tabsSize.height).toDp()) @@ -498,7 +490,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode userBadge.acceptedBadges?.let { note -> (note.event as? BadgeProfilesEvent)?.let { event -> - FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { + FlowRow(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp)) { event.badgeAwardEvents().forEach { badgeAwardEvent -> val baseNote = LocalCache.notes[badgeAwardEvent] if (baseNote != null) { From 48f4fc4a0fa070bbb681da8b97b1d541e126afba Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 12:11:58 -0400 Subject: [PATCH 41/61] v0.33.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2af92aec8..56da19b5b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 118 - versionName "0.32.3" + versionCode 119 + versionName "0.33.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 0a15e079dc7ef5c0a25bdb4b921ca4aaf5efc88f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 14:08:58 -0400 Subject: [PATCH 42/61] Compares room messages by reference. --- app/src/main/java/com/vitorpamplona/amethyst/model/User.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 476f4ef7d..9696a5795 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -295,7 +295,7 @@ class User(val pubkeyHex: String) { fun hasSentMessagesTo(user: User?): Boolean { val messagesToUser = privateChatrooms[user] ?: return false - return messagesToUser.roomMessages.any { this == it.author } + return messagesToUser.roomMessages.any { this === it.author } } fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean { From 3e27567532ae00224fe7cb78b1f174ff38562273 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 14:09:26 -0400 Subject: [PATCH 43/61] Moves home and Notification bubbles to the adaptive feed. --- .../ui/dal/ChatroomListKnownFeedFilter.kt | 2 +- .../amethyst/ui/navigation/AppBottomBar.kt | 96 +++++++++++-------- .../amethyst/ui/navigation/Routes.kt | 84 +++++++++++----- 3 files changed, 114 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index ddafd9e4a..396933e94 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -10,7 +10,7 @@ object ChatroomListKnownFeedFilter : FeedFilter() { override fun feed(): List { val me = account.userProfile() - val privateChatrooms = account.userProfile().privateChatrooms + val privateChatrooms = me.privateChatrooms val messagingWith = privateChatrooms.keys.filter { me.hasSentMessagesTo(it) && account.isAcceptable(it) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index e275cf4f6..dd08efd7e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.ui.navigation import android.graphics.Rect +import android.util.Log import android.view.ViewTreeObserver import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -42,6 +43,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.time.ExperimentalTime +import kotlin.time.measureTimedValue val bottomNavigationItems = listOf( Route.Home, @@ -135,66 +138,75 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView } } +@OptIn(ExperimentalTime::class) @Composable private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) { - Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { - Icon( - painter = painterResource(id = route.icon), - contentDescription = null, - modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), - tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified - ) + println("Notifiable Icon") - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val (value, elapsed) = measureTimedValue { + Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { + Icon( + painter = painterResource(id = route.icon), + contentDescription = null, + modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), + tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified + ) - // Notification - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return + println("Notifiable Icon") - val notifState = NotificationCache.live.observeAsState() - val notif = notifState.value ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - var hasNewItems by remember { mutableStateOf(false) } + // Notification + val dbState = LocalCache.live.observeAsState() + val db = dbState.value ?: return - LaunchedEffect(key1 = notif) { - withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache) + val notifState = NotificationCache.live.observeAsState() + val notif = notifState.value ?: return + + var hasNewItems by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = notif) { + withContext(Dispatchers.IO) { + hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) + } } - } - LaunchedEffect(key1 = db) { - withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache) + LaunchedEffect(key1 = db) { + withContext(Dispatchers.IO) { + hasNewItems = route.hasNewItems(account, notif.cache, db) + } } - } - if (hasNewItems) { - Box( - Modifier - .width(10.dp) - .height(10.dp) - .align(Alignment.TopEnd) - ) { + if (hasNewItems) { Box( - modifier = Modifier + Modifier .width(10.dp) .height(10.dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.primary), - contentAlignment = Alignment.TopEnd + .align(Alignment.TopEnd) ) { - Text( - "", - color = Color.White, - textAlign = TextAlign.Center, - fontSize = 12.sp, + Box( modifier = Modifier - .wrapContentHeight() - .align(Alignment.TopEnd) - ) + .width(10.dp) + .height(10.dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.primary), + contentAlignment = Alignment.TopEnd + ) { + Text( + "", + color = Color.White, + textAlign = TextAlign.Center, + fontSize = 12.sp, + modifier = Modifier + .wrapContentHeight() + .align(Alignment.TopEnd) + ) + } } } } } + + Log.d("Notification time", "$elapsed") } 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 ddbe4d307..216a5531f 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 @@ -10,6 +10,7 @@ import androidx.navigation.navArgument import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter @@ -17,7 +18,7 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter sealed class Route( val route: String, val icon: Int, - val hasNewItems: (Account, NotificationCache) -> Boolean = { _, _ -> false }, + val hasNewItems: (Account, NotificationCache, Set) -> Boolean = { _, _, _ -> false }, val arguments: List = emptyList() ) { val base: String @@ -30,7 +31,7 @@ sealed class Route( navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }, navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null } ), - hasNewItems = { accountViewModel, cache -> homeHasNewItems(accountViewModel, cache) } + hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) } ) object Search : Route( @@ -43,13 +44,13 @@ sealed class Route( route = "Notification?scrollToTop={scrollToTop}", icon = R.drawable.ic_notifications, arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }), - hasNewItems = { accountViewModel, cache -> notificationHasNewItems(accountViewModel, cache) } + hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) } ) object Message : Route( route = "Message", icon = R.drawable.ic_dm, - hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) } + hasNewItems = { accountViewModel, cache, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, cache, newNotes) } ) object BlockedUsers : Route( @@ -108,36 +109,69 @@ fun currentRoute(navController: NavHostController): String? { return navBackStackEntry?.destination?.route } -private fun homeHasNewItems(account: Account, cache: NotificationCache): Boolean { - val lastTime = cache.load("HomeFollows") +object HomeLatestItem { + private var newestItem: Note? = null - HomeNewThreadFeedFilter.account = account + fun hasNewItems( + account: Account, + cache: NotificationCache, + newNotes: Set + ): Boolean { + val lastTime = cache.load("HomeFollows") + HomeNewThreadFeedFilter.account = account - return ( - HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() - ?: 0 - ) > lastTime + if (newestItem == null) { + newestItem = HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null } + } else { + newestItem = + HomeNewThreadFeedFilter.sort( + HomeNewThreadFeedFilter.applyFilter(newNotes + newestItem!!) + ).first() + } + + return (newestItem?.createdAt() ?: 0) > lastTime + } } -private fun notificationHasNewItems(account: Account, cache: NotificationCache): Boolean { - val lastTime = cache.load("Notification") +object NotificationLatestItem { + private var newestItem: Note? = null - NotificationFeedFilter.account = account + fun hasNewItems( + account: Account, + cache: NotificationCache, + newNotes: Set + ): Boolean { + val lastTime = cache.load("Notification") + NotificationFeedFilter.account = account - return ( - NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() - ?: 0 - ) > lastTime + if (newestItem == null) { + newestItem = NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null } + } else { + newestItem = HomeNewThreadFeedFilter.sort( + NotificationFeedFilter.applyFilter(newNotes) + newestItem!! + ).first() + } + + return (newestItem?.createdAt() ?: 0) > lastTime + } } -private fun messagesHasNewItems(account: Account, cache: NotificationCache): Boolean { - ChatroomListKnownFeedFilter.account = account +object MessagesLatestItem { + private var newestItem: Note? = null - val note = ChatroomListKnownFeedFilter.feed().firstOrNull { - it.createdAt() != null && it.channel() == null && it.author != account.userProfile() - } ?: return false + fun hasNewItems( + account: Account, + cache: NotificationCache, + newNotes: Set + ): Boolean { + ChatroomListKnownFeedFilter.account = account - val lastTime = cache.load("Room/${note.author?.pubkeyHex}") + val note = ChatroomListKnownFeedFilter.loadTop().firstOrNull { + it.createdAt() != null && it.channel() == null && it.author != account.userProfile() + } ?: return false - return (note.createdAt() ?: 0) > lastTime + val lastTime = cache.load("Room/${note.author?.pubkeyHex}") + + return (note.createdAt() ?: 0) > lastTime + } } From fe6ef1df0a82db1fcc89d4bb58ee993502249fce Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 17:18:20 -0400 Subject: [PATCH 44/61] Migration to Gradle 8.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 56da19b5b..49cc2bdc4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -78,10 +78,10 @@ android { excludes += '/META-INF/{AL2.0,LGPL2.1}' } } - - lintOptions { + lint { disable 'MissingTranslation' } + } dependencies { From cca7a103223a2650cd20e2fed2b9de764b456b73 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 17:18:53 -0400 Subject: [PATCH 45/61] Caching images that cause errors and avoid calling them again. --- .../ui/components/RobohashAsyncImage.kt | 68 ++++++++++++------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 845037266..c50d08163 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -50,11 +50,13 @@ fun RobohashAsyncImage( } } +var imageErrors = setOf() + @Composable fun RobohashFallbackAsyncImage( robot: String, robotSize: Dp, - model: String?, + model: String, contentDescription: String?, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, @@ -63,30 +65,47 @@ fun RobohashFallbackAsyncImage( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - val context = LocalContext.current - val painter = with(LocalDensity.current) { - rememberAsyncImagePainter( - model = Robohash.imageRequest( - context, - robot, - Size(robotSize.roundToPx(), robotSize.roundToPx()) + if (imageErrors.contains(model)) { + RobohashAsyncImage( + robot = robot, + robotSize = robotSize, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) + } else { + val context = LocalContext.current + val painter = with(LocalDensity.current) { + rememberAsyncImagePainter( + model = Robohash.imageRequest( + context, + robot, + Size(robotSize.roundToPx(), robotSize.roundToPx()) + ) ) + } + + AsyncImage( + model = model, + contentDescription = contentDescription, + modifier = modifier, + placeholder = painter, + fallback = painter, + error = painter, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality, + onError = { + imageErrors = imageErrors + model + } ) } - - AsyncImage( - model = model, - contentDescription = contentDescription, - modifier = modifier, - placeholder = painter, - fallback = painter, - error = painter, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality - ) } @Composable @@ -101,7 +120,8 @@ fun RobohashAsyncImageProxy( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - if (model.url == null) { + val proxy = model.proxyUrl() + if (proxy == null) { RobohashAsyncImage( robot = robot, robotSize = model.size, @@ -117,7 +137,7 @@ fun RobohashAsyncImageProxy( RobohashFallbackAsyncImage( robot = robot, robotSize = model.size, - model = model.proxyUrl(), + model = proxy, contentDescription = contentDescription, modifier = modifier, alignment = alignment, From c40c7bc62ba251c8c3a197a4de9bf929413d26ad Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 17:19:20 -0400 Subject: [PATCH 46/61] Removing LazyGrid from reactions Row due to being slower than the Row itself. --- .../amethyst/ui/note/ReactionsRow.kt | 48 +++++++------------ 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a60fa6184..cf9bc9845 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -90,36 +88,26 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { Spacer(modifier = Modifier.height(8.dp)) - LazyVerticalGrid( - columns = GridCells.Fixed(5), - modifier = Modifier.height(20.dp), - userScrollEnabled = false, - horizontalArrangement = Arrangement.SpaceBetween, - verticalArrangement = Arrangement.Center - ) { - items(5) { - when (it) { - 0 -> Row(verticalAlignment = CenterVertically) { - ReplyReaction(baseNote, accountViewModel) { - wantsToReplyTo = baseNote - } - } - 1 -> Row(verticalAlignment = CenterVertically) { - BoostReaction(baseNote, accountViewModel) { - wantsToQuote = baseNote - } - } - 2 -> Row(verticalAlignment = CenterVertically) { - LikeReaction(baseNote, accountViewModel) - } - 3 -> Row(verticalAlignment = CenterVertically) { - ZapReaction(baseNote, accountViewModel) - } - 4 -> Row(verticalAlignment = CenterVertically) { - ViewCountReaction(baseNote.idHex) - } + Row(verticalAlignment = CenterVertically) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + ReplyReaction(baseNote, accountViewModel) { + wantsToReplyTo = baseNote } } + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + BoostReaction(baseNote, accountViewModel) { + wantsToQuote = baseNote + } + } + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + LikeReaction(baseNote, accountViewModel) + } + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + ZapReaction(baseNote, accountViewModel) + } + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + ViewCountReaction(baseNote.idHex) + } } } From c7a9bd12265de7a94937128c31d09e7f2213fac3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 17:19:34 -0400 Subject: [PATCH 47/61] Using FlowRow to display relay icons. --- .../amethyst/ui/note/NoteCompose.kt | 141 +++++++++++------- 1 file changed, 86 insertions(+), 55 deletions(-) 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 a09fe58ca..f6d4f406c 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 @@ -5,8 +5,6 @@ import android.graphics.Bitmap import android.util.Log import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -61,7 +59,6 @@ import com.vitorpamplona.amethyst.ui.theme.Following import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.math.BigDecimal -import kotlin.math.ceil import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @@ -299,7 +296,7 @@ fun NoteComposeInner( } if (noteEvent is RepostEvent) { - note.replyTo?.lastOrNull()?.let { + baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it) } } else { @@ -871,68 +868,102 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: U @Composable private fun RelayBadges(baseNote: Note) { val noteRelaysState by baseNote.live().relays.observeAsState() - val noteRelays = noteRelaysState?.note?.relays ?: emptySet() + val noteRelays = noteRelaysState?.note ?: return var expanded by remember { mutableStateOf(false) } + var showShowMore by remember { mutableStateOf(false) } + var lazyRelayList by remember { mutableStateOf(emptyList()) } - val relaysToDisplay = (if (expanded) noteRelays else noteRelays.take(3)).toList() - val height = (ceil(relaysToDisplay.size / 3.0f) * 17).dp + LaunchedEffect(key1 = noteRelaysState, key2 = expanded) { + withContext(Dispatchers.IO) { + val relayList = noteRelays.relays.map { + it.removePrefix("wss://").removePrefix("ws://") + } - val uri = LocalUriHandler.current + val relaysToDisplay = if (expanded) relayList else relayList.take(3) + val shouldListChange = lazyRelayList.size < 3 || lazyRelayList.size != relayList.size - Spacer(Modifier.height(10.dp)) + if (shouldListChange) { + lazyRelayList = relaysToDisplay + } - LazyVerticalGrid( - columns = GridCells.Fixed(3), - contentPadding = PaddingValues(start = 4.dp, end = 4.dp), - modifier = Modifier.height(height), - userScrollEnabled = false - ) { - items(relaysToDisplay.size) { - val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://") - - Box( - Modifier - .padding(1.dp) - .size(15.dp) - ) { - RobohashFallbackAsyncImage( - robot = "https://$url/favicon.ico", - robotSize = 15.dp, - model = "https://$url/favicon.ico", - contentDescription = stringResource(R.string.relay_icon), - colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), - modifier = Modifier - .width(13.dp) - .height(13.dp) - .clip(shape = CircleShape) - // .border(1.dp, Color.Red) - .background(MaterialTheme.colors.background) - .clickable(onClick = { uri.openUri("https://$url") }) - ) + val nextShowMore = relayList.size > 3 && !expanded + if (nextShowMore != showShowMore) { + // only triggers recomposition when actually different + showShowMore = nextShowMore } } } - if (noteRelays.size > 3 && !expanded) { - Row( - Modifier - .fillMaxWidth() - .height(25.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.Top + Spacer(Modifier.height(10.dp)) + + VerticalRelayPanelWithFlow(lazyRelayList) + + if (showShowMore) { + ShowMoreRelaysButton { + expanded = true + } + } +} + +@Composable +@Stable +private fun VerticalRelayPanelWithFlow( + relays: List +) { + // FlowRow Seems to be a lot faster than LazyVerticalGrid + FlowRow() { + relays.forEach { url -> + RelayIconCompose(url) + } + } +} + +@Composable +@Stable +private fun RelayIconCompose(url: String) { + val uri = LocalUriHandler.current + + Box( + Modifier + .padding(1.dp) + .size(15.dp) + ) { + RobohashFallbackAsyncImage( + robot = "https://$url/favicon.ico", + robotSize = 15.dp, + model = "https://$url/favicon.ico", + contentDescription = stringResource(R.string.relay_icon), + colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), + modifier = Modifier + .width(13.dp) + .height(13.dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.background) + .clickable(onClick = { uri.openUri("https://$url") }) + ) + } +} + +@Composable +private fun ShowMoreRelaysButton(onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .height(25.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Top + ) { + IconButton( + modifier = Modifier.then(Modifier.size(24.dp)), + onClick = onClick ) { - IconButton( - modifier = Modifier.then(Modifier.size(24.dp)), - onClick = { expanded = true } - ) { - Icon( - imageVector = Icons.Default.ExpandMore, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) } } } From 48d890b354e30dcd6513a5fee2b48c1ee0c70ecf Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 20 Apr 2023 17:24:33 -0400 Subject: [PATCH 48/61] v0.33.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 49cc2bdc4..e334d8e6f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 119 - versionName "0.33.0" + versionCode 120 + versionName "0.33.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From f7de66c6a5c637a806bce021215412245e7e7fc8 Mon Sep 17 00:00:00 2001 From: Bardesss <30936735+Bardesss@users.noreply.github.com> Date: Fri, 21 Apr 2023 07:34:42 +0000 Subject: [PATCH 49/61] Update strings.xml (NL) Added missing strings. --- app/src/main/res/values-nl/strings.xml | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7063684f9..07562603e 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -226,4 +226,80 @@ Heeft privésleutel Alleen lezen, geen privésleutel Terug + Selecteer + Deel browserlink + Delen + Autheur ID + Note ID + Kopieer tekst + Verwijderen + Ontvolgen + Volgen + Verzoek verwijdering + Amethyst zal vragen dat uw note wordt verwijderd van de relays waarmee u momenteel verbonden bent. Er is geen garantie dat uw note permanent wordt verwijderd van deze relays, of van andere relays waar het kan worden opgeslagen. + Blokkeren + Verwijderen + Blokkeren + Rapporteren + Verwijderen + Niet meer laten zien + Spam of oplichting + Godslastering of haatdragend gedrag + Kwaadwillige imitatie + Naaktheid of grafische inhoud + Illegaal gedrag + Als u een gebruiker blokkeert, wordt zijn inhoud in uw app verborgen. Uw notes zijn nog steeds publiekelijk zichtbaar, ook voor mensen die u blokkeert. Geblokkeerde gebruikers worden vermeld in het scherm Beveiligingsfilters. + + Misbruik melden + Alle geplaatste rapporteringen zijn openbaar. + Geef optioneel extra context over uw rapportering... + Extra context + Reden + Selecteer een reden… + Rapporteer + Blokkeer en rapporteer + Blokkeren + + Bladwijzers + Privé bladwijzers + PPublieke Bladwijzers + Toevoegen aan privé bladwijzers + Toevoegen aan publieke bladwijzers + Verwijderen uit privé bladwijzers + Verwijderen uit publieke bladwijzers + + Wallet Connect Service + Machtigt een Nostr Secret om zaps te betalen zonder de app te verlaten. Houd het geheim veilig en gebruik indien mogelijk een privé relays. + Wallet Connect publieke sleutel + Wallet Connect Relay + Wallet Connect Secret + Toon secret sleutel + nsec / hex privésleutel + + Bedrag in sats + Verzend poll + Verplichte velden: + Zap ontvangers + Primaire poll beschrijving… + Optie %s + Poll optie beschrijvingen + Optionele velden: + Zap minimum + Zap maximum + Consensus + (0–100)% + Sluit na + dagen + Poll is gesloten voor nieuwe stemmen + Zap bedrag + Slechts één stem per gebruiker is toegestaan bij dit type peiling. + + "Zoeken naar event %1$s" + + Een openbaar bericht toevoegen + Bedankt voor al jullie werk! + + Maken en toevoegen + Poll auteurs kunnen niet stemmen in hun eigen poll. + #zappoll From 94ee80d8ea009431d77d809260731524c18ba0d7 Mon Sep 17 00:00:00 2001 From: Bardesss <30936735+Bardesss@users.noreply.github.com> Date: Fri, 21 Apr 2023 08:59:36 +0000 Subject: [PATCH 50/61] Update strings.xml --- app/src/main/res/values-nl/strings.xml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 07562603e..3a3db447e 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -226,17 +226,6 @@ Heeft privésleutel Alleen lezen, geen privésleutel Terug - Selecteer - Deel browserlink - Delen - Autheur ID - Note ID - Kopieer tekst - Verwijderen - Ontvolgen - Volgen - Verzoek verwijdering - Amethyst zal vragen dat uw note wordt verwijderd van de relays waarmee u momenteel verbonden bent. Er is geen garantie dat uw note permanent wordt verwijderd van deze relays, of van andere relays waar het kan worden opgeslagen. Blokkeren Verwijderen Blokkeren From a291741c37e4995328fcdc4756f9e0ec5596d016 Mon Sep 17 00:00:00 2001 From: Bardesss <30936735+Bardesss@users.noreply.github.com> Date: Fri, 21 Apr 2023 09:06:54 +0000 Subject: [PATCH 51/61] Update strings.xml --- app/src/main/res/values-nl/strings.xml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 3a3db447e..b85757a84 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -226,12 +226,6 @@ Heeft privésleutel Alleen lezen, geen privésleutel Terug - Blokkeren - Verwijderen - Blokkeren - Rapporteren - Verwijderen - Niet meer laten zien Spam of oplichting Godslastering of haatdragend gedrag Kwaadwillige imitatie From 5e3db99675af3c945a5b8c0486ec841d912c0aa0 Mon Sep 17 00:00:00 2001 From: vivganes Date: Fri, 21 Apr 2023 17:20:50 +0530 Subject: [PATCH 52/61] fix #361 --- .../amethyst/ui/components/RichTextViewer.kt | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index a406e9337..d28752b85 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -14,6 +14,7 @@ import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -125,11 +126,13 @@ fun RichTextViewer( ) ) + val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content) + MaterialRichText( style = myMarkDownStyle ) { Markdown( - content = content, + content = markdownWithSpecialContent, markdownParseOptions = MarkdownParseOptions.Default ) } @@ -280,6 +283,53 @@ fun RichTextViewer( } } +@Composable +private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? { + if (parsedNip19.type == Nip19.Type.USER) { + val userHex = parsedNip19.hex + val userBase = LocalCache.getOrCreateUser(userHex) + + val userState by userBase.live().metadata.observeAsState() + val displayName = userState?.user?.bestDisplayName() + if (displayName !== null) { + return displayName + } + } + return null +} + +@Composable +private fun returnMarkdownWithSpecialContent(content: String): String { + var returnContent = "" + content.split('\n').forEach { paragraph -> + paragraph.split(' ').forEach { word: String -> + if (isValidURL(word)) { + returnContent += "[$word]($word) " + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + returnContent += "[$word](mailto:$word) " + } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { + returnContent += "[$word](tel:$word) " + } else if (isBechLink(word)) { + val parsedNip19 = Nip19.uriToRoute(word) + returnContent += if (parsedNip19 !== null) { + val displayName = getDisplayNameFromUserNip19(parsedNip19) + if (displayName != null) { + "[@$displayName](nostr://$word) " + } else { + "$word " + } + } else { + "$word " + } + } else { + returnContent += "$word " + } + } + returnContent += "\n" + } + return returnContent +} + private fun isArabic(text: String): Boolean { return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } } From 8040608e1450802239dcaad068961dc9d86c1655 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 10:21:24 -0400 Subject: [PATCH 53/61] Fixes: https://github.com/vitorpamplona/amethyst/issues/359 --- .../java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d4fd4765e..97775bfb9 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 @@ -22,7 +22,7 @@ object HashtagFeedFilter : AdditiveFeedFilter() { } override fun applyFilter(collection: Set): Set { - return applyFilter(collection) + return innerApplyFilter(collection) } private fun innerApplyFilter(collection: Collection): Set { From 3f579793ca03df1293b1a03df44e5513e5bdd23f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 10:21:50 -0400 Subject: [PATCH 54/61] v0.33.2 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index e334d8e6f..527fad218 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 120 - versionName "0.33.1" + versionCode 121 + versionName "0.33.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 1adb2b2caa1d2c76016cccddf2c83f8c283df607 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 11:00:06 -0400 Subject: [PATCH 55/61] Fix crashing when changing users. --- .../amethyst/ui/navigation/Routes.kt | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) 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 216a5531f..f0266f41a 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 @@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter @@ -109,9 +110,29 @@ fun currentRoute(navController: NavHostController): String? { return navBackStackEntry?.destination?.route } -object HomeLatestItem { - private var newestItem: Note? = null +open class LatestItem { + var newestItemPerAccount: Map = mapOf() + fun updateNewestItem(newNotes: Set, account: Account, filter: AdditiveFeedFilter): Note? { + val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex] + + if (newestItem == null) { + newestItemPerAccount = newestItemPerAccount + Pair( + account.userProfile().pubkeyHex, + filter.feed().firstOrNull { it.createdAt() != null } + ) + } else { + newestItemPerAccount = newestItemPerAccount + Pair( + account.userProfile().pubkeyHex, + filter.sort(filter.applyFilter(newNotes) + newestItem).first() + ) + } + + return newestItemPerAccount[account.userProfile().pubkeyHex] + } +} + +object HomeLatestItem : LatestItem() { fun hasNewItems( account: Account, cache: NotificationCache, @@ -120,22 +141,13 @@ object HomeLatestItem { val lastTime = cache.load("HomeFollows") HomeNewThreadFeedFilter.account = account - if (newestItem == null) { - newestItem = HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null } - } else { - newestItem = - HomeNewThreadFeedFilter.sort( - HomeNewThreadFeedFilter.applyFilter(newNotes + newestItem!!) - ).first() - } + val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter) return (newestItem?.createdAt() ?: 0) > lastTime } } -object NotificationLatestItem { - private var newestItem: Note? = null - +object NotificationLatestItem : LatestItem() { fun hasNewItems( account: Account, cache: NotificationCache, @@ -144,21 +156,13 @@ object NotificationLatestItem { val lastTime = cache.load("Notification") NotificationFeedFilter.account = account - if (newestItem == null) { - newestItem = NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null } - } else { - newestItem = HomeNewThreadFeedFilter.sort( - NotificationFeedFilter.applyFilter(newNotes) + newestItem!! - ).first() - } + val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter) return (newestItem?.createdAt() ?: 0) > lastTime } } object MessagesLatestItem { - private var newestItem: Note? = null - fun hasNewItems( account: Account, cache: NotificationCache, From 783204b57f47b9dd348e39f1d8598f06e7f02889 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 17:01:42 -0400 Subject: [PATCH 56/61] Support for NIP-94 --- app/build.gradle | 3 + .../vitorpamplona/amethyst/model/Account.kt | 20 + .../amethyst/model/LocalCache.kt | 13 + .../amethyst/service/BlurHashDecoder.kt | 203 +++++++++ .../amethyst/service/BlurHashImage.kt | 65 +++ .../amethyst/service/FileHeader.kt | 65 +++ .../amethyst/service/NostrDataSource.kt | 1 + .../service/NostrSingleEventDataSource.kt | 9 +- .../amethyst/service/model/Event.kt | 1 + .../amethyst/service/model/FileHeaderEvent.kt | 71 +++ .../amethyst/ui/actions/ImageUploader.kt | 4 +- .../amethyst/ui/actions/NewPostViewModel.kt | 39 +- .../ui/actions/NewUserMetadataViewModel.kt | 2 +- .../amethyst/ui/components/RichTextViewer.kt | 19 +- .../ui/components/ZoomableContentView.kt | 408 ++++++++++++++++++ .../ui/components/ZoomableImageView.kt | 188 -------- .../amethyst/ui/note/NoteCompose.kt | 26 ++ .../ui/screen/loggedIn/ChannelScreen.kt | 2 + .../ui/screen/loggedIn/ChatroomScreen.kt | 2 + .../ui/screen/loggedIn/ProfileScreen.kt | 8 +- app/src/main/res/values/strings.xml | 5 + 21 files changed, 945 insertions(+), 209 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt delete mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt diff --git a/app/build.gradle b/app/build.gradle index 527fad218..db60144bb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -146,6 +146,9 @@ dependencies { // view svgs implementation "io.coil-kt:coil-svg:$coil_version" + // create blurhash + implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0' + // Rendering clickable text implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version" // Permission to upload pictures: 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 8a0a85a82..df4c61fb7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model import android.content.res.Resources import androidx.core.os.ConfigurationCompat import androidx.lifecycle.LiveData +import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Constants @@ -357,6 +358,25 @@ class Account( } } + fun sendHeader(headerInfo: FileHeader): Note? { + if (!isWriteable()) return null + + val signedEvent = FileHeaderEvent.create( + url = headerInfo.url, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + blurhash = headerInfo.blurHash, + description = headerInfo.description, + privateKey = loggedIn.privKey!! + ) + + Client.send(signedEvent) + LocalCache.consume(signedEvent) + + return LocalCache.notes[signedEvent.id] + } + fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) { if (!isWriteable()) return 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 41539afa1..11316a775 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -651,6 +651,19 @@ object LocalCache { refreshObservers(note) } + fun consume(event: FileHeaderEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + val author = getOrCreateUser(event.pubKey) + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + fun findUsersStartingWith(username: String): List { return users.values.filter { (it.anyNameStartsWith(username)) || diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt new file mode 100644 index 000000000..02f980056 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt @@ -0,0 +1,203 @@ +package com.vitorpamplona.amethyst.service + +import android.graphics.Bitmap +import android.graphics.Color +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +object BlurHashDecoder { + + // cache Math.cos() calculations to improve performance. + // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps + // the cache is enabled by default, it is recommended to disable it only when just a few images are displayed + private val cacheCosinesX = HashMap() + private val cacheCosinesY = HashMap() + + /** + * Clear calculations stored in memory cache. + * The cache is not big, but will increase when many image sizes are used, + * if the app needs memory it is recommended to clear it. + */ + fun clearCache() { + cacheCosinesX.clear() + cacheCosinesY.clear() + } + + /** + * Returns width/height + */ + fun aspectRatio(blurHash: String?): Float? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + + return numCompX.toFloat() / numCompY.toFloat() + } + + /** + * Decode a blur hash into a new bitmap. + * + * @param useCache use in memory cache for the calculated math, reused by images with same size. + * if the cache does not exist yet it will be created and populated with new calculations. + * By default it is true. + */ + fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = Array(numCompX * numCompY) { i -> + if (i == 0) { + val colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc) + } else { + val from = 4 + i * 2 + val colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch) + } + } + return composeBitmap(width, height, numCompX, numCompY, colors, useCache) + } + + private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { + var result = 0 + for (i in from until to) { + val index = charMap[str[i]] ?: -1 + if (index != -1) { + result = result * 83 + index + } + } + return result + } + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) { + (v / 12.92f) + } else { + ((v + 0.055f) / 1.055f).pow(2.4f) + } + } + + private fun decodeAc(value: Int, maxAc: Float): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc + ) + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeBitmap( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array, + useCache: Boolean + ): Bitmap { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) + val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) + val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) + val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) + val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) + val basis = (cosX * cosY).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + } + } + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when { + calculate -> { + DoubleArray(height * numCompY).also { + cacheCosinesY[height * numCompY] = it + } + } + else -> { + cacheCosinesY[height * numCompY]!! + } + } + + private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when { + calculate -> { + DoubleArray(width * numCompX).also { + cacheCosinesX[width * numCompX] = it + } + } + else -> cacheCosinesX[width * numCompX]!! + } + + private fun DoubleArray.getCos( + calculate: Boolean, + x: Int, + numComp: Int, + y: Int, + size: Int + ): Double { + if (calculate) { + this[x + numComp * y] = cos(Math.PI * y * x / size) + } + return this[x + numComp * y] + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } + + private val charMap = listOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', + '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' + ) + .mapIndexed { i, c -> c to i } + .toMap() +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt new file mode 100644 index 000000000..b94f35e91 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt @@ -0,0 +1,65 @@ +package com.vitorpamplona.amethyst.service + +import android.content.Context +import android.net.Uri +import androidx.core.graphics.drawable.toDrawable +import coil.ImageLoader +import coil.decode.DataSource +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.request.ImageRequest +import coil.request.Options +import java.net.URLDecoder +import java.net.URLEncoder +import kotlin.math.roundToInt + +class BlurHashFetcher( + private val options: Options, + private val data: Uri +) : Fetcher { + + override suspend fun fetch(): FetchResult { + val encodedHash = data.toString().removePrefix("bluehash:") + val hash = URLDecoder.decode(encodedHash, "utf-8") + + val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f + + val preferredWidth = 100 + + val bitmap = BlurHashDecoder.decode( + hash, + preferredWidth, + (preferredWidth * (1 / aspectRatio)).roundToInt() + ) + + if (bitmap == null) { + throw Exception("Unable to convert Bluehash $hash") + } + + return DrawableResult( + drawable = bitmap.toDrawable(options.context.resources), + isSampled = false, + dataSource = DataSource.MEMORY + ) + } + + object Factory : Fetcher.Factory { + override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher { + return BlurHashFetcher(options, data) + } + } +} + +object BlurHashRequester { + fun imageRequest(context: Context, message: String): ImageRequest { + val encodedMessage = URLEncoder.encode(message, "utf-8") + + return ImageRequest + .Builder(context) + .data("bluehash:$encodedMessage") + .fetcherFactory(BlurHashFetcher.Factory) + .crossfade(100) + .build() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt new file mode 100644 index 000000000..a7543e56f --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -0,0 +1,65 @@ +package com.vitorpamplona.amethyst.service + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import com.vitorpamplona.amethyst.model.toHexKey +import io.trbl.blurhash.BlurHash +import java.net.URL +import java.security.MessageDigest +import kotlin.math.roundToInt + +class FileHeader( + val url: String, + val mimeType: String?, + val hash: String, + val size: Int, + val blurHash: String?, + val description: String? = null +) { + companion object { + fun prepare(fileUrl: String, mimeType: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { + try { + val imageData = URL(fileUrl).readBytes() + val sha256 = MessageDigest.getInstance("SHA-256") + + val hash = sha256.digest(imageData).toHexKey() + val size = imageData.size + + val blurHash = if (mimeType?.startsWith("image/") == true) { + val opt = BitmapFactory.Options() + opt.inPreferredConfig = Bitmap.Config.ARGB_8888 + val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt) + + val intArray = IntArray(mBitmap.width * mBitmap.height) + mBitmap.getPixels( + intArray, + 0, + mBitmap.width, + 0, + 0, + mBitmap.width, + mBitmap.height + ) + + val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat() + + if (aspectRatio > 1) { + BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()) + } else if (aspectRatio < 1) { + BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9) + } else { + BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4) + } + } else { + null + } + + onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, "")) + } catch (e: Exception) { + Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") + onError() + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 6d4fbadf9..61fa8bedf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -75,6 +75,7 @@ abstract class NostrDataSource(val debugName: String) { is ContactListEvent -> LocalCache.consume(event) is DeletionEvent -> LocalCache.consume(event) + is FileHeaderEvent -> LocalCache.consume(event) is LnZapEvent -> { event.zapRequest?.let { onEvent(it, subscriptionId, relay) } LocalCache.consume(event) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index aa4e87eb5..9a6a6568a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -111,9 +111,12 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { types = FeedType.values().toSet(), filter = JsonFilter( kinds = listOf( - TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, - ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, - PollNoteEvent.kind, PrivateDmEvent.kind + TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, + ReactionEvent.kind, RepostEvent.kind, + LnZapEvent.kind, LnZapRequestEvent.kind, + ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, + BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, + PrivateDmEvent.kind, FileHeaderEvent.kind ), ids = interestedEvents.toList() ) 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 818a2d3b2..6d857328c 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 @@ -222,6 +222,7 @@ open class Event( ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) + FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt new file mode 100644 index 000000000..53988d943 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt @@ -0,0 +1,71 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class FileHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) + fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) + fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) + fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + + companion object { + const val kind = 1063 + + private const val URL = "url" + private const val ENCRYPTION_KEY = "aes-256-gcm" + private const val MIME_TYPE = "m" + private const val FILE_SIZE = "size" + private const val HASH = "x" + private const val MAGNET_URI = "magnet" + private const val TORRENT_INFOHASH = "i" + private const val BLUR_HASH = "blurhash" + + fun create( + url: String, + mimeType: String? = null, + description: String? = null, + hash: String? = null, + size: String? = null, + blurhash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): FileHeaderEvent { + var tags = listOfNotNull( + listOf(URL, url), + mimeType?.let { listOf(MIME_TYPE, mimeType) }, + hash?.let { listOf(HASH, it) }, + size?.let { listOf(FILE_SIZE, it) }, + blurhash?.let { listOf(BLUR_HASH, it) }, + magnetURI?.let { listOf(MAGNET_URI, it) }, + torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) } + ) + + val content = description ?: "" + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} + +data class AESGCM(val key: String, val nonce: String) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt index 574fef2b1..cf15b8c3a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt @@ -15,7 +15,7 @@ object ImageUploader { fun uploadImage( uri: Uri, contentResolver: ContentResolver, - onSuccess: (String) -> Unit, + onSuccess: (String, String?) -> Unit, onError: (Throwable) -> Unit ) { val contentType = contentResolver.getType(uri) @@ -64,7 +64,7 @@ object ImageUploader { "There must be an uploaded image URL in the response" } - onSuccess(url) + onSuccess(url, contentType) } } catch (e: Exception) { e.printStackTrace() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 8be28a491..5fccd7a43 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.* +import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.isValidURL @@ -110,13 +111,39 @@ open class NewPostViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, contentResolver = context.contentResolver, - onSuccess = { imageUrl -> - isUploadingImage = false - message = TextFieldValue(message.text + "\n\n" + imageUrl) - + onSuccess = { imageUrl, mimeType -> viewModelScope.launch(Dispatchers.IO) { - delay(2000) - urlPreview = findUrlInMessage() + // Images don't seem to be ready immediately after upload + + if (mimeType?.startsWith("image/") == true) { + delay(2000) + } else { + delay(5000) + } + + FileHeader.prepare( + imageUrl, + mimeType, + onReady = { + val note = account?.sendHeader(it) + + isUploadingImage = false + + if (note == null) { + message = TextFieldValue(message.text + "\n\n" + imageUrl) + } else { + message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote()) + } + + urlPreview = findUrlInMessage() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) } }, onError = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index e6a9bf2e0..5a252ef02 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -171,7 +171,7 @@ class NewUserMetadataViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, contentResolver = context.contentResolver, - onSuccess = { imageUrl -> + onSuccess = { imageUrl, mimeType -> onUploading(false) onUploaded(imageUrl) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index d28752b85..5a4fd7e74 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -139,11 +139,17 @@ fun RichTextViewer( } else { val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } - val imagesForPager = urlSet.filter { fullUrl -> + val imagesForPager = urlSet.mapNotNull { fullUrl -> val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() - imageExtensions.any { removedParamsFromUrl.endsWith(it) } || videoExtensions.any { removedParamsFromUrl.endsWith(it) } - } - val imagesForPagerSet = imagesForPager.toSet() + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + ZoomableImage(fullUrl) + } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + ZoomableVideo(fullUrl) + } else { + null + } + }.associateBy { it.url } + val imageList = imagesForPager.values.toList() // FlowRow doesn't work well with paragraphs. So we need to split them content.split('\n').forEach { paragraph -> @@ -152,8 +158,9 @@ fun RichTextViewer( s.forEach { word: String -> if (canPreview) { // Explicit URL - if (imagesForPagerSet.contains(word)) { - ZoomableImageView(word, imagesForPager) + val img = imagesForPager[word] + if (img != null) { + ZoomableContentView(img, imageList) } else if (urlSet.contains(word)) { UrlPreview(word, "$word ") } else if (word.startsWith("lnbc", true)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt new file mode 100644 index 000000000..567730af9 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -0,0 +1,408 @@ +package com.vitorpamplona.amethyst.ui.components + +import android.content.Context +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Report +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.annotation.ExperimentalCoilApi +import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import coil.imageLoader +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.service.BlurHashRequester +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation +import com.vitorpamplona.amethyst.ui.actions.SaveToGallery +import com.vitorpamplona.amethyst.ui.theme.Nip05 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable +import java.security.MessageDigest + +abstract class ZoomableContent( + val url: String, + val description: String? = null, + val hash: String? = null +) + +class ZoomableImage( + url: String, + description: String? = null, + hash: String? = null, + val bluehash: String? = null +) : ZoomableContent(url, description, hash) + +class ZoomableVideo( + url: String, + description: String? = null, + hash: String? = null +) : ZoomableContent(url, description, hash) + +fun figureOutMimeType(fullUrl: String): ZoomableContent { + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + + return if (isImage) { + ZoomableImage(fullUrl) + } else if (isVideo) { + ZoomableVideo(fullUrl) + } else { + ZoomableImage(fullUrl) + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun ZoomableContentView(content: ZoomableContent, images: List = listOf(content)) { + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + val context = LocalContext.current + + // store the dialog open or close state + var dialogOpen by remember { + mutableStateOf(false) + } + + // store the dialog open or close state + var imageState by remember { + mutableStateOf(null) + } + + var verifiedHash by remember { + mutableStateOf(null) + } + + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + + val mainImageModifier = Modifier + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ) + .combinedClickable( + onClick = { dialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) } + ) + + if (content is ZoomableImage) { + Box() { + AsyncImage( + model = content.url, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = mainImageModifier, + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + + if (imageState !is AsyncImagePainter.State.Success) { + if (content.bluehash != null) { + DisplayBlueHash(content, mainImageModifier) + } else { + DisplayUrlWithLoadingSymbol(content) + } + } else { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + } + } else { + VideoView(content.url) { dialogOpen = true } + } + + if (dialogOpen) { + ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }) + } +} + +@Composable +private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { + ClickableUrl(urlText = "$content ", url = content.url) + + val myId = "inlineContent" + val emptytext = buildAnnotatedString { + withStyle( + LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() + ) { + append("") + appendInlineContent(myId, "[icon]") + } + } + val inlineContent = mapOf( + Pair( + myId, + InlineTextContent( + Placeholder( + width = 17.sp, + height = 17.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + LoadingAnimation() + } + ) + ) + + // Empty Text for Size of Icon + Text( + text = emptytext, + inlineContent = inlineContent + ) +} + +@Composable +private fun DisplayBlueHash( + content: ZoomableImage, + modifier: Modifier +) { + if (content.bluehash == null) return + + val context = LocalContext.current + AsyncImage( + model = BlurHashRequester.imageRequest( + context, + content.bluehash + ), + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = modifier + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List = listOf(imageUrl), onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + Column() { + val pagerState: PagerState = remember { PagerState() } + + Row( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = onDismiss) + + SaveToGallery(url = allImages[pagerState.currentPage].url) + } + + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + itemsCount = allImages.size, + itemContent = { index -> + RenderImageOrVideo(allImages[index]) + } + ) + } else { + RenderImageOrVideo(imageUrl) + } + } + } + } +} + +@Composable +private fun RenderImageOrVideo(content: ZoomableContent) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + // store the dialog open or close state + var imageState by remember { + mutableStateOf(null) + } + + var verifiedHash by remember { + mutableStateOf(null) + } + + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + + if (content is ZoomableImage) { + Box() { + AsyncImage( + model = content.url, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .zoomable(rememberZoomState()), + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + if (imageState !is AsyncImagePainter.State.Success) { + DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth()) + } else { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + } + } else { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { + VideoView(content.url) + } + } +} + +@OptIn(ExperimentalCoilApi::class) +private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? { + if (content.hash == null) return null + + context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> + val imageFile = snapshot.data.toFile() + val bytes = imageFile.readBytes() + val sha256 = MessageDigest.getInstance("SHA-256") + + val hash = sha256.digest(bytes).toHexKey() + + Log.d("Image Hash Verification", "$hash == ${content.hash}") + + return hash == content.hash + } + + return null +} + +@Composable +private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) { + if (verifiedHash == null) return + + val localContext = LocalContext.current + val scope = rememberCoroutineScope() + + Box( + modifier + .width(40.dp) + .height(40.dp) + .padding(10.dp) + ) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colors.background) + ) + + if (verifiedHash == true) { + IconButton( + onClick = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.hash_verification_passed), + Toast.LENGTH_LONG + ).show() + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_verified), + "Hash Verified", + tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background), + modifier = Modifier.size(30.dp) + ) + } + } else if (verifiedHash == false) { + IconButton( + onClick = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.hash_verification_failed), + Toast.LENGTH_LONG + ).show() + } + } + ) { + Icon( + tint = Color.Red, + imageVector = Icons.Default.Report, + contentDescription = "Invalid Hash", + modifier = Modifier.size(30.dp) + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt deleted file mode 100644 index 20f82bfc6..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt +++ /dev/null @@ -1,188 +0,0 @@ -package com.vitorpamplona.amethyst.ui.components - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.border -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import coil.compose.AsyncImage -import coil.compose.AsyncImagePainter -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.CloseButton -import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation -import com.vitorpamplona.amethyst.ui.actions.SaveToGallery -import net.engawapg.lib.zoomable.rememberZoomState -import net.engawapg.lib.zoomable.zoomable - -@Composable -@OptIn(ExperimentalFoundationApi::class) -fun ZoomableImageView(word: String, images: List = listOf(word)) { - val clipboardManager = LocalClipboardManager.current - - // store the dialog open or close state - var dialogOpen by remember { - mutableStateOf(false) - } - - // store the dialog open or close state - var imageState by remember { - mutableStateOf(null) - } - - val removedParamsFromUrl = word.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - AsyncImage( - model = word, - contentDescription = word, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 1.dp, - MaterialTheme.colors.onSurface.copy(alpha = 0.12f), - RoundedCornerShape(15.dp) - ) - .combinedClickable( - onClick = { dialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(word)) } - ), - onLoading = { - imageState = it - }, - onSuccess = { - imageState = it - } - ) - - if (imageState !is AsyncImagePainter.State.Success) { - ClickableUrl(urlText = "$word ", url = word) - - val myId = "inlineContent" - val emptytext = buildAnnotatedString { - withStyle( - LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() - ) { - append("") - appendInlineContent(myId, "[icon]") - } - } - val inlineContent = mapOf( - Pair( - myId, - InlineTextContent( - Placeholder( - width = 17.sp, - height = 17.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ) - ) { - LoadingAnimation() - } - ) - ) - - // Empty Text for Size of Icon - Text( - text = emptytext, - inlineContent = inlineContent - ) - } - } else { - VideoView(word) { dialogOpen = true } - } - - if (dialogOpen) { - ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false }) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ZoomableImageDialog(imageUrl: String, allImages: List = listOf(imageUrl), onDismiss: () -> Unit) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - Column() { - var pagerState: PagerState = remember { PagerState() } - - Row( - modifier = Modifier - .padding(10.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onCancel = onDismiss) - - SaveToGallery(url = allImages[pagerState.currentPage]) - } - - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState, - itemsCount = allImages.size, - itemContent = { index -> - RenderImageOrVideo(allImages[index]) - } - ) - } else { - RenderImageOrVideo(imageUrl) - } - } - } - } -} - -@Composable -private fun RenderImageOrVideo(imageUrl: String) { - val removedParamsFromUrl = imageUrl.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - AsyncImage( - model = imageUrl, - contentDescription = stringResource(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxSize() - .zoomable(rememberZoomState()) - ) - } else { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { - VideoView(imageUrl) - } - } -} 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 f6d4f406c..a08a2336b 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 @@ -167,6 +167,8 @@ fun NoteComposeInner( ChannelHeader(baseChannel = baseChannel, account = account, navController = navController) } else if (noteEvent is BadgeDefinitionEvent) { BadgeDisplay(baseNote = note) + } else if (noteEvent is FileHeaderEvent) { + FileHeaderDisplay(note) } else { var isNew by remember { mutableStateOf(false) } @@ -776,6 +778,30 @@ fun BadgeDisplay(baseNote: Note) { } } +@Composable +fun FileHeaderDisplay(note: Note) { + val event = (note.event as? FileHeaderEvent) ?: return + + val fullUrl = event.url() ?: return + val blurHash = event.blurhash() + val hash = event.hash() + val description = event.content + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + + if (isImage || isVideo) { + val content = if (isImage) { + ZoomableImage(fullUrl, description, hash, blurHash) + } else { + ZoomableVideo(fullUrl, description, hash) + } + ZoomableContentView(content = content, listOf(content)) + } else { + UrlPreview(fullUrl, "$fullUrl ") + } +} + @Composable private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) { Row( 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 32c382803..784d61262 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 @@ -89,6 +89,8 @@ fun ChannelScreen( val context = LocalContext.current val channelScreenModel: NewPostViewModel = viewModel() + channelScreenModel.account = account + if (account != null && channelId != null) { val replyTo = remember { mutableStateOf(null) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 0f26c18bc..07381c5e7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -67,6 +67,8 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr val context = LocalContext.current val chatRoomScreenModel: NewPostViewModel = viewModel() + chatRoomScreenModel.account = account + if (account != null && userId != null) { val replyTo = remember { mutableStateOf(null) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 77da720bc..4d9bf93cc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog +import com.vitorpamplona.amethyst.ui.components.figureOutMimeType import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter @@ -401,8 +402,9 @@ private fun ProfileHeader( } } - if (zoomImageDialogOpen) { - ZoomableImageDialog(baseUser.profilePicture()!!, onDismiss = { zoomImageDialogOpen = false }) + val profilePic = baseUser.profilePicture() + if (zoomImageDialogOpen && profilePic != null) { + ZoomableImageDialog(figureOutMimeType(profilePic), onDismiss = { zoomImageDialogOpen = false }) } } @@ -706,7 +708,7 @@ private fun DrawBanner(baseUser: User) { ) if (zoomImageDialogOpen) { - ZoomableImageDialog(imageUrl = banner, onDismiss = { zoomImageDialogOpen = false }) + ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false }) } } else { Image( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1878907d..3e3109e95 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -292,4 +292,9 @@ Create and Add Poll authors can\'t vote in their own polls. #zappoll + + + Image is the same since the post + Image has changed. The author might not have seen the change + From 79e3450a4695989ca9bd198d59abd84743d88d78 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 17:07:07 -0400 Subject: [PATCH 57/61] v0.34.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index db60144bb..a9d1ca074 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 121 - versionName "0.33.2" + versionCode 122 + versionName "0.34.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From e7909beaabbef318eda0c7cc25d8eec3b1ec840f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:20:00 -0400 Subject: [PATCH 58/61] Remove spaces after bench and image previews --- .../amethyst/ui/components/RichTextViewer.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 5a4fd7e74..c2ed2dfb0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -154,7 +154,7 @@ fun RichTextViewer( // FlowRow doesn't work well with paragraphs. So we need to split them content.split('\n').forEach { paragraph -> FlowRow() { - val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ') + val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ') s.forEach { word: String -> if (canPreview) { // Explicit URL @@ -385,9 +385,11 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV isQuotedNote = true, navController = navController ) - Text( - "${it.second} " - ) + if (!it.second.isNullOrEmpty()) { + Text( + "${it.second} " + ) + } } ?: nip19Route?.let { ClickableRoute(it, navController) } ?: Text(text = "$word ") From 9aa5993748da1278bd112489460757f62ea40870 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:20:15 -0400 Subject: [PATCH 59/61] Removes unnecessary logs --- .../amethyst/ui/navigation/AppBottomBar.kt | 94 +++++++++---------- 1 file changed, 42 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index dd08efd7e..db0ab3644 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -1,7 +1,6 @@ package com.vitorpamplona.amethyst.ui.navigation import android.graphics.Rect -import android.util.Log import android.view.ViewTreeObserver import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -44,7 +43,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.ExperimentalTime -import kotlin.time.measureTimedValue val bottomNavigationItems = listOf( Route.Home, @@ -141,72 +139,64 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView @OptIn(ExperimentalTime::class) @Composable private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) { - println("Notifiable Icon") + Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { + Icon( + painter = painterResource(id = route.icon), + contentDescription = null, + modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), + tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified + ) - val (value, elapsed) = measureTimedValue { - Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { - Icon( - painter = painterResource(id = route.icon), - contentDescription = null, - modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), - tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified - ) + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - println("Notifiable Icon") + // Notification + val dbState = LocalCache.live.observeAsState() + val db = dbState.value ?: return - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val notifState = NotificationCache.live.observeAsState() + val notif = notifState.value ?: return - // Notification - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return + var hasNewItems by remember { mutableStateOf(false) } - val notifState = NotificationCache.live.observeAsState() - val notif = notifState.value ?: return - - var hasNewItems by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = notif) { - withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) - } + LaunchedEffect(key1 = notif) { + withContext(Dispatchers.IO) { + hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) } + } - LaunchedEffect(key1 = db) { - withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache, db) - } + LaunchedEffect(key1 = db) { + withContext(Dispatchers.IO) { + hasNewItems = route.hasNewItems(account, notif.cache, db) } + } - if (hasNewItems) { + if (hasNewItems) { + Box( + Modifier + .width(10.dp) + .height(10.dp) + .align(Alignment.TopEnd) + ) { Box( - Modifier + modifier = Modifier .width(10.dp) .height(10.dp) - .align(Alignment.TopEnd) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.primary), + contentAlignment = Alignment.TopEnd ) { - Box( + Text( + "", + color = Color.White, + textAlign = TextAlign.Center, + fontSize = 12.sp, modifier = Modifier - .width(10.dp) - .height(10.dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.primary), - contentAlignment = Alignment.TopEnd - ) { - Text( - "", - color = Color.White, - textAlign = TextAlign.Center, - fontSize = 12.sp, - modifier = Modifier - .wrapContentHeight() - .align(Alignment.TopEnd) - ) - } + .wrapContentHeight() + .align(Alignment.TopEnd) + ) } } } } - - Log.d("Notification time", "$elapsed") } From ed07248f7d1d8ca06c8804dcd3fd2624eb92a9c3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:20:26 -0400 Subject: [PATCH 60/61] Correctly calculates the total of Zaps in a poll --- .../vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index 4ce21c372..b24d78fbd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -110,7 +110,13 @@ class PollNoteViewModel { fun totalZapped(): BigDecimal { return pollNote?.zaps?.values?.sumOf { - (it?.event as? LnZapEvent)?.amount ?: BigDecimal(0) + val zapEvent = (it?.event as? LnZapEvent) + + if (zapEvent?.zappedPollOption() != null) { + zapEvent.amount ?: BigDecimal(0) + } else { + BigDecimal(0) + } } ?: BigDecimal(0) } } From 830ade9ad67ac17fd62909c3677c100840321d39 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:22:11 -0400 Subject: [PATCH 61/61] v0.34.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a9d1ca074..5a206becf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 122 - versionName "0.34.0" + versionCode 123 + versionName "0.34.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables {