diff --git a/app/build.gradle b/app/build.gradle index 6b57d88ab..e92f7687f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,20 +7,20 @@ plugins { android { namespace 'com.vitorpamplona.amethyst' - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "com.vitorpamplona.amethyst" minSdk 26 - targetSdk 33 - versionCode 272 - versionName "0.73.3" + targetSdk 34 + versionCode 276 + versionName "0.74.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary true } - resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW', 'ja'] + resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW'] } buildTypes { @@ -111,6 +111,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" + // Zoomable images implementation "net.engawapg.lib:zoomable:1.4.3" // Biometrics @@ -119,7 +120,7 @@ dependencies { // Websockets API implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11' - // link preview + // HTML Parsing for Link Preview implementation 'org.jsoup:jsoup:1.16.1' // Encrypted Key Storage @@ -159,9 +160,9 @@ dependencies { // Language picker and Theme chooser implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'androidx.media3:media3-exoplayer-hls:1.1.0' - implementation 'androidx.media3:media3-ui:1.1.0' - implementation 'androidx.media3:media3-session:1.1.0' + implementation "androidx.media3:media3-exoplayer-hls:$media3_version" + implementation "androidx.media3:media3-ui:$media3_version" + implementation "androidx.media3:media3-session:$media3_version" // Local model for language identification playImplementation 'com.google.mlkit:language-id:17.0.4' @@ -182,6 +183,9 @@ dependencies { // GeoHash implementation 'com.github.drfonfon:android-kotlin-geohash:1.0' + // Waveform visualizer + implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1' + // Video compression lib implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1' // Image compression lib diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 6ae3e935f..c6f9017bc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -299,7 +299,8 @@ object LocalPreferences { val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf() val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf() val localRelays = getString(PrefKeys.RELAYS, "[]")?.let { - Event.mapper.readValue>(it) + println("LocalRelays: $it") + Event.mapper.readValue?>(it) } ?: setOf() val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() @@ -310,24 +311,24 @@ object LocalPreferences { val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS val zapAmountChoices = getString(PrefKeys.ZAP_AMOUNTS, "[]")?.let { - Event.mapper.readValue>(it) + Event.mapper.readValue?>(it) }?.ifEmpty { listOf(500L, 1000L, 5000L) } ?: listOf(500L, 1000L, 5000L) val reactionChoices = getString(PrefKeys.REACTION_CHOICES, "[]")?.let { - Event.mapper.readValue>(it) + Event.mapper.readValue?>(it) }?.ifEmpty { listOf("+") } ?: listOf("+") val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName -> - LnZapEvent.ZapType.values().firstOrNull { it.name == serverName } + LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName } } ?: LnZapEvent.ZapType.PUBLIC val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName -> - ServersAvailable.values().firstOrNull { it.name == serverName } + ServersAvailable.values().firstOrNull() { it.name == serverName } } ?: ServersAvailable.NOSTR_BUILD val zapPaymentRequestServer = try { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { - Event.mapper.readValue(it) + Event.mapper.readValue(it) } } catch (e: Throwable) { e.printStackTrace() @@ -345,7 +346,7 @@ object LocalPreferences { val languagePreferences = try { getString(PrefKeys.LANGUAGE_PREFS, null)?.let { - Event.mapper.readValue>(it) + Event.mapper.readValue?>(it) } ?: mapOf() } catch (e: Throwable) { e.printStackTrace() @@ -369,7 +370,7 @@ object LocalPreferences { val lastReadPerRoute = try { getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let { - Event.mapper.readValue>(it) + Event.mapper.readValue?>(it) } ?: mapOf() } catch (e: Throwable) { e.printStackTrace() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt b/app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt index 335f0de2b..9e11c4843 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt @@ -26,15 +26,35 @@ class PlaybackService : MediaSessionService() { return ProgressiveMediaSource.Factory(VideoCache.get(Amethyst.instance, HttpClient.getHttpClient())) } + fun lazyHlsDS(): MultiPlayerPlaybackManager { + managerHls?.let { return it } + + val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache) + managerHls = newInstance + return newInstance + } + + fun lazyProgressiveDS(): MultiPlayerPlaybackManager { + managerProgressive?.let { return it } + + val newInstance = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache) + managerProgressive = newInstance + return newInstance + } + + fun lazyLocalDS(): MultiPlayerPlaybackManager { + managerLocal?.let { return it } + + val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache) + managerLocal = newInstance + return newInstance + } + // Create your Player and MediaSession in the onCreate lifecycle event @OptIn(UnstableApi::class) override fun onCreate() { super.onCreate() - managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache) - managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache) - managerLocal = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache) - // Stop all videos and recreates all managers when the proxy changes. HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated) } @@ -62,11 +82,11 @@ class PlaybackService : MediaSessionService() { fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? { return if (fileName.startsWith("file")) { - managerLocal + lazyLocalDS() } else if (fileName.endsWith("m3u8")) { - managerHls + lazyHlsDS() } else { - managerProgressive + lazyProgressiveDS() } } 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 a45df8832..a4774fc25 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -49,8 +49,8 @@ fun getLanguagesSpokenByUser(): Set { return codedList } -val GLOBAL_FOLLOWS = " Global " -val KIND3_FOLLOWS = " All Follows " +val GLOBAL_FOLLOWS = " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. +val KIND3_FOLLOWS = " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. @OptIn(DelicateCoroutinesApi::class) @Stable @@ -492,7 +492,13 @@ class Account( fun broadcast(note: Note) { note.event?.let { - Client.send(it) + if (it is WrappedEvent && it.host != null) { + it.host?.let { hostEvent -> + Client.send(hostEvent) + } + } else { + Client.send(it) + } } } @@ -908,6 +914,23 @@ class Account( Client.send(signedEvent, relayList = relayList) LocalCache.consume(signedEvent) + + // broadcast replied notes + replyingTo?.let { + LocalCache.getNoteIfExists(replyingTo)?.event?.let { + Client.send(it, relayList = relayList) + } + } + replyTo?.forEach { + it.event?.let { + Client.send(it, relayList = relayList) + } + } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } + } return null } @@ -958,6 +981,17 @@ class Account( Client.send(signedEvent, relayList = relayList) LocalCache.consume(signedEvent) + + replyTo?.forEach { + it.event?.let { + Client.send(it, relayList = relayList) + } + } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } + } return null } @@ -1800,8 +1834,8 @@ class Account( }.toTypedArray() } - fun convertGlobalRelays(): Array { - return localRelays.filter { it.feedTypes.contains(FeedType.GLOBAL) } + fun activeGlobalRelays(): Array { + return (activeRelays() ?: convertLocalRelays()).filter { it.activeTypes.contains(FeedType.GLOBAL) } .map { it.url } .toTypedArray() } 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 e7a774ba2..867e7e153 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1051,6 +1051,23 @@ object LocalCache { refreshObservers(note) } + fun consume(event: AudioHeaderEvent, relay: Relay?) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + fun consume(event: FileHeaderEvent, relay: Relay?) { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -1284,7 +1301,7 @@ object LocalCache { } return notes.values.filter { - (it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent) && + (it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) && ( it.event?.content()?.contains(text, true) ?: false || it.event?.matchTag1With(text) ?: false || @@ -1292,7 +1309,7 @@ object LocalCache { it.idNote().startsWith(text, true) ) } + addressables.values.filter { - (it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent) && + (it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) && ( it.event?.content()?.contains(text, true) ?: false || it.event?.matchTag1With(text) ?: false || @@ -1524,6 +1541,7 @@ object LocalCache { is AdvertisedRelayListEvent -> consume(event) is AppDefinitionEvent -> consume(event) is AppRecommendationEvent -> consume(event) + is AudioHeaderEvent -> consume(event, relay) is AudioTrackEvent -> consume(event) is BadgeAwardEvent -> consume(event) is BadgeDefinitionEvent -> consume(event) 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 5045abc16..855e2ed2b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -129,12 +129,14 @@ open class Note(val idHex: String) { return "/" + formattedDateTime(createdAt() ?: 0) + ";" } - val mySignature = replyTo - .filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots - .map { - cachedSignatures[it] ?: it.replyLevelSignature(eventsToConsider, cachedSignatures).apply { cachedSignatures.put(it, this) } - } - .maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(createdAt() ?: 0) + ";" + val mySignature = ( + replyTo + .filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots + .map { + cachedSignatures[it] ?: it.replyLevelSignature(eventsToConsider, cachedSignatures).apply { cachedSignatures.put(it, this) } + } + .maxByOrNull { it.length }?.removeSuffix(";") ?: "" + ) + "/" + formattedDateTime(createdAt() ?: 0) + ";" cachedSignatures[this] = mySignature return mySignature @@ -613,6 +615,7 @@ open class Note(val idHex: String) { fun clearLive() { if (liveSet != null && liveSet?.isInUse() == false) { + liveSet?.destroy() liveSet = null } } @@ -654,12 +657,26 @@ class NoteLiveSet(u: Note) { relays.hasObservers() || zaps.hasObservers() } + + fun destroy() { + metadata.destroy() + reactions.destroy() + boosts.destroy() + replies.destroy() + reports.destroy() + relays.destroy() + zaps.destroy() + } } class NoteLiveData(val note: Note) : LiveData(NoteState(note)) { // Refreshes observers in batches. private val bundler = BundledUpdate(500, Dispatchers.IO) + fun destroy() { + bundler.cancel() + } + fun invalidateData() { if (!hasObservers()) return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt index c636884db..48a1be853 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -15,7 +15,12 @@ class ThreadAssembler { testedNotes.add(note) val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) - if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot) + if (markedAsRoot != null) { + // Check to ssee if there is an error in the tag and the root has replies + if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) { + return LocalCache.checkGetOrCreateNote(markedAsRoot) + } + } val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true } if (hasNoReplyTo != null) return hasNoReplyTo 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 ff33dccb1..f2ee77c99 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -372,6 +372,7 @@ class User(val pubkeyHex: String) { fun clearLive() { if (liveSet != null && liveSet?.isInUse() == false) { + liveSet?.destroy() liveSet = null } } @@ -400,6 +401,18 @@ class UserLiveSet(u: User) { zaps.hasObservers() || bookmarks.hasObservers() } + + fun destroy() { + follows.destroy() + followers.destroy() + reports.destroy() + messages.destroy() + relays.destroy() + relayInfo.destroy() + metadata.destroy() + zaps.destroy() + bookmarks.destroy() + } } @Immutable @@ -470,6 +483,10 @@ class UserLiveData(val user: User) : LiveData(UserState(user)) { // Refreshes observers in batches. private val bundler = BundledUpdate(500, Dispatchers.IO) + fun destroy() { + bundler.cancel() + } + fun invalidateData() { if (!hasObservers()) return checkNotInMainThread() 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 f403aaab3..0c4e6fb31 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -76,6 +76,7 @@ abstract class NostrDataSource(val debugName: String) { fun destroy() { stop() Client.unsubscribe(clientListener) + bundler.cancel() } open fun start() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt index 783988400..8ff6d369d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter +import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent @@ -26,7 +27,7 @@ object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") { hashToLoad ) ), - kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind), + kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind), limit = 200 ) ) 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 02ef50fba..7a382577f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter +import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent @@ -29,7 +30,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { hashToLoad.capitalize() ) ), - kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind), + kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind), limit = 200 ) ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index f666d3075..2c49f5fad 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter +import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent @@ -56,10 +57,10 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) val followKeys = follows?.map { - it.substring(0, 6) + it.substring(0, 8) } - val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 6)) + val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 8)) return TypedFilter( types = setOf(FeedType.FOLLOWS), @@ -73,6 +74,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { PollNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, + AudioHeaderEvent.kind, PinListEvent.kind, LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind @@ -92,7 +94,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind), tags = mapOf( "t" to hashToLoad.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) @@ -112,7 +114,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind), tags = mapOf( "g" to hashToLoad.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) @@ -137,6 +139,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, + AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, CommunityPostApprovalEvent.kind diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index 315f91de3..d795ce980 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -56,8 +56,9 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") { filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, LongTextNoteEvent.kind, BadgeDefinitionEvent.kind, - PeopleListEvent.kind, BookmarkListEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, - PollNoteEvent.kind, ChannelCreateEvent.kind + PeopleListEvent.kind, BookmarkListEvent.kind, AudioHeaderEvent.kind, + AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind, + ChannelCreateEvent.kind ), search = mySearchString, limit = 100 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 7ecd639f7..24296990b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -34,6 +34,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { RepostEvent.kind, LongTextNoteEvent.kind, AudioTrackEvent.kind, + AudioHeaderEvent.kind, PinListEvent.kind, PollNoteEvent.kind, HighlightEvent.kind 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 b7732e6b3..ef452f774 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -20,7 +20,6 @@ import androidx.compose.material.Surface import androidx.compose.ui.Modifier import androidx.core.os.LocaleListCompat import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus @@ -102,9 +101,9 @@ class MainActivity : AppCompatActivity() { } override fun onPause() { - if (BuildConfig.DEBUG) { - debugState(this) - } + // if (BuildConfig.DEBUG) { + debugState(this) + // } ServiceManager.pause() super.onPause() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 1672056e3..fca593fd7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -241,6 +241,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo postViewModel.galleryUri?.let { VideoView( videoUri = it.toString(), + roundedCorner = false, accountViewModel = accountViewModel ) } 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 897c9c0cd..5bb4f02fa 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 @@ -113,7 +113,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OptIn(ExperimentalComposeUiApi::class) @Composable fun NewPostView( onClose: () -> Unit, @@ -409,7 +408,7 @@ fun NewPostView( ) ) } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - VideoView(myUrlPreview, accountViewModel = accountViewModel) + VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel) } else { UrlPreview(myUrlPreview, myUrlPreview, accountViewModel) } @@ -1389,7 +1388,7 @@ fun ImageVideoDescription( ) } } else { - VideoView(uri.toString(), accountViewModel = accountViewModel) + VideoView(uri.toString(), roundedCorner = true, accountViewModel = accountViewModel) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt index c2a41cacc..41f67f0a1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt @@ -48,7 +48,7 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalPermissionsApi::class) @Composable -fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) { +fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit, navScrollToTop: (Route, Boolean) -> Unit) { var wantsToPost by remember { mutableStateOf(false) } @@ -62,11 +62,9 @@ fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) { val postViewModel: NewMediaModel = viewModel() postViewModel.onceUploaded { scope.launch(Dispatchers.Default) { - // awaits an refresh on the list - delay(250) + delay(500) withContext(Dispatchers.Main) { - val route = Route.Video.route.replace("{scrollToTop}", "true") - nav(route) + navScrollToTop(Route.Video, true) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt new file mode 100644 index 000000000..b7f68f3bb --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt @@ -0,0 +1,160 @@ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.DrawStyle +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.coerceIn +import androidx.compose.ui.unit.dp +import com.linc.audiowaveform.model.AmplitudeType +import com.linc.audiowaveform.model.WaveformAlignment +import kotlin.math.ceil +import kotlin.math.roundToInt + +private val MinSpikeWidthDp: Dp = 1.dp +private val MaxSpikeWidthDp: Dp = 24.dp +private val MinSpikePaddingDp: Dp = 0.dp +private val MaxSpikePaddingDp: Dp = 12.dp +private val MinSpikeRadiusDp: Dp = 0.dp +private val MaxSpikeRadiusDp: Dp = 12.dp + +private const val MinProgress: Float = 0F +private const val MaxProgress: Float = 1F + +private const val MinSpikeHeight: Float = 1F +private const val DefaultGraphicsLayerAlpha: Float = 0.99F + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun AudioWaveformReadOnly( + modifier: Modifier = Modifier, + style: DrawStyle = Fill, + waveformBrush: Brush = SolidColor(Color.White), + progressBrush: Brush = SolidColor(Color.Blue), + waveformAlignment: WaveformAlignment = WaveformAlignment.Center, + amplitudeType: AmplitudeType = AmplitudeType.Avg, + onProgressChangeFinished: (() -> Unit)? = null, + spikeAnimationSpec: AnimationSpec = tween(500), + spikeWidth: Dp = 3.dp, + spikeRadius: Dp = 2.dp, + spikePadding: Dp = 2.dp, + progress: Float = 0F, + amplitudes: List, + onProgressChange: (Float) -> Unit +) { + val backgroundColor = MaterialTheme.colors.background + val _progress = remember(progress) { progress.coerceIn(MinProgress, MaxProgress) } + val _spikeWidth = remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) } + val _spikePadding = remember(spikePadding) { spikePadding.coerceIn(MinSpikePaddingDp, MaxSpikePaddingDp) } + val _spikeRadius = remember(spikeRadius) { spikeRadius.coerceIn(MinSpikeRadiusDp, MaxSpikeRadiusDp) } + val _spikeTotalWidth = remember(spikeWidth, spikePadding) { _spikeWidth + _spikePadding } + var canvasSize by remember { mutableStateOf(Size(0f, 0f)) } + var spikes by remember { mutableStateOf(0F) } + val spikesAmplitudes = remember(amplitudes, spikes, amplitudeType) { + amplitudes.toDrawableAmplitudes( + amplitudeType = amplitudeType, + spikes = spikes.toInt(), + minHeight = MinSpikeHeight, + maxHeight = canvasSize.height.coerceAtLeast(MinSpikeHeight) + ) + }.map { animateFloatAsState(it, spikeAnimationSpec).value } + Canvas( + modifier = Modifier + .fillMaxWidth() + .requiredHeight(48.dp) + .graphicsLayer(alpha = DefaultGraphicsLayerAlpha) + .then(modifier) + ) { + canvasSize = size + spikes = size.width / _spikeTotalWidth.toPx() + spikesAmplitudes.forEachIndexed { index, amplitude -> + drawRoundRect( + brush = waveformBrush, + topLeft = Offset( + x = index * _spikeTotalWidth.toPx(), + y = when (waveformAlignment) { + WaveformAlignment.Top -> 0F + WaveformAlignment.Bottom -> size.height - amplitude + WaveformAlignment.Center -> size.height / 2F - amplitude / 2F + } + ), + size = Size( + width = _spikeWidth.toPx(), + height = amplitude + ), + cornerRadius = CornerRadius(_spikeRadius.toPx(), _spikeRadius.toPx()), + style = style + ) + drawRect( + brush = progressBrush, + size = Size( + width = _progress * size.width, + height = size.height + ), + blendMode = BlendMode.SrcAtop + ) + } + } +} + +private fun List.toDrawableAmplitudes( + amplitudeType: AmplitudeType, + spikes: Int, + minHeight: Float, + maxHeight: Float +): List { + val amplitudes = map(Int::toFloat) + if (amplitudes.isEmpty() || spikes == 0) { + return List(spikes) { minHeight } + } + val transform = { data: List -> + when (amplitudeType) { + AmplitudeType.Avg -> data.average() + AmplitudeType.Max -> data.max() + AmplitudeType.Min -> data.min() + }.toFloat().coerceIn(minHeight, maxHeight) + } + return when { + spikes > amplitudes.count() -> amplitudes.fillToSize(spikes, transform) + else -> amplitudes.chunkToSize(spikes, transform) + }.normalize(minHeight, maxHeight) +} + +internal fun Iterable.fillToSize(size: Int, transform: (List) -> T): List { + val capacity = ceil(size.safeDiv(count())).roundToInt() + return map { data -> List(capacity) { data } }.flatten().chunkToSize(size, transform) +} + +internal fun Iterable.chunkToSize(size: Int, transform: (List) -> T): List { + val chunkSize = count() / size + val remainder = count() % size + val remainderIndex = ceil(count().safeDiv(remainder)).roundToInt() + val chunkIteration = filterIndexed { index, _ -> + remainderIndex == 0 || index % remainderIndex != 0 + }.chunked(chunkSize, transform) + return when (size) { + chunkIteration.count() -> chunkIteration + else -> chunkIteration.chunkToSize(size, transform) + } +} + +internal fun Iterable.normalize(min: Float, max: Float): List { + return map { (max - min) * ((it - min()) / (max() - min())) + min } +} + +private fun Int.safeDiv(value: Int): Float { + return if (value == 0) return 0F else this / value.toFloat() +} 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 890704ae7..f5e702150 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 @@ -7,6 +7,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -21,6 +23,8 @@ class BundledUpdate( val delay: Long, val dispatcher: CoroutineDispatcher = Dispatchers.Default ) { + val scope = CoroutineScope(dispatcher + SupervisorJob()) + private var onlyOneInBlock = AtomicBoolean() private var invalidatesAgain = false @@ -32,7 +36,6 @@ class BundledUpdate( return } - val scope = CoroutineScope(Job() + dispatcher) scope.launch { try { onUpdate() @@ -48,6 +51,10 @@ class BundledUpdate( } } } + + fun cancel() { + scope.cancel() + } } /** @@ -58,6 +65,8 @@ class BundledInsert( val delay: Long, val dispatcher: CoroutineDispatcher = Dispatchers.Default ) { + val scope = CoroutineScope(dispatcher + SupervisorJob()) + private var onlyOneInBlock = AtomicBoolean() private var queue = LinkedBlockingQueue() @@ -88,4 +97,8 @@ class BundledInsert( } } } + + fun cancel() { + scope.cancel() + } } 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 70c5944e9..95450735e 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 @@ -287,7 +287,7 @@ private fun ZoomableContentView( ) { state.imagesForPager[word]?.let { Box(modifier = HalfVertPadding) { - ZoomableContentView(it, state.imageList, accountViewModel) + ZoomableContentView(it, state.imageList, roundedCorner = true, accountViewModel) } } } 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 73452d896..22e677cbe 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 @@ -9,6 +9,8 @@ import android.view.ViewGroup import android.widget.FrameLayout import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background @@ -17,6 +19,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -34,6 +37,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned @@ -54,6 +59,7 @@ import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import coil.imageLoader import coil.request.ImageRequest +import com.linc.audiowaveform.infiniteLinearGradient import com.vitorpamplona.amethyst.PlaybackClientController import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus @@ -66,8 +72,12 @@ import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize +import com.vitorpamplona.amethyst.ui.theme.imageModifier +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import java.util.UUID import kotlin.math.abs @@ -80,6 +90,7 @@ fun LoadThumbAndThenVideoView( title: String? = null, thumbUri: String, authorName: String? = null, + roundedCorner: Boolean, nostrUriCallback: String? = null, accountViewModel: AccountViewModel, onDialog: ((Boolean) -> Unit)? = null @@ -111,6 +122,7 @@ fun LoadThumbAndThenVideoView( videoUri = videoUri, title = title, thumb = VideoThumb(loadingFinished.second), + roundedCorner = roundedCorner, artworkUri = thumbUri, authorName = authorName, nostrUriCallback = nostrUriCallback, @@ -122,6 +134,7 @@ fun LoadThumbAndThenVideoView( videoUri = videoUri, title = title, thumb = null, + roundedCorner = roundedCorner, artworkUri = thumbUri, authorName = authorName, nostrUriCallback = nostrUriCallback, @@ -137,6 +150,8 @@ fun VideoView( videoUri: String, title: String? = null, thumb: VideoThumb? = null, + roundedCorner: Boolean, + waveform: ImmutableList? = null, artworkUri: String? = null, authorName: String? = null, nostrUriCallback: String? = null, @@ -151,6 +166,8 @@ fun VideoView( defaultToStart, title, thumb, + roundedCorner, + waveform, artworkUri, authorName, nostrUriCallback, @@ -167,6 +184,8 @@ fun VideoViewInner( defaultToStart: Boolean = false, title: String? = null, thumb: VideoThumb? = null, + roundedCorner: Boolean, + waveform: ImmutableList? = null, artworkUri: String? = null, authorName: String? = null, nostrUriCallback: String? = null, @@ -220,7 +239,16 @@ fun VideoViewInner( defaultToStart = defaultToStart, nostrUriCallback = nostrUriCallback ) { controller, keepPlaying -> - RenderVideoPlayer(controller, thumb, keepPlaying, automaticallyStartPlayback, activeOnScreen, onDialog) + RenderVideoPlayer( + controller = controller, + thumbData = thumb, + roundedCorner = roundedCorner, + waveform = waveform, + keepPlaying = keepPlaying, + automaticallyStartPlayback = automaticallyStartPlayback, + activeOnScreen = activeOnScreen, + onDialog = onDialog + ) } } } @@ -472,6 +500,8 @@ data class VideoThumb( private fun RenderVideoPlayer( controller: MediaController, thumbData: VideoThumb?, + roundedCorner: Boolean, + waveform: ImmutableList? = null, keepPlaying: MutableState, automaticallyStartPlayback: MutableState, activeOnScreen: MutableState, @@ -487,10 +517,15 @@ private fun RenderVideoPlayer( BoxWithConstraints() { AndroidView( - modifier = Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 70.dp) - .align(Alignment.Center), + modifier = if (roundedCorner) { + MaterialTheme.colors.imageModifier + .defaultMinSize(minHeight = 100.dp) + .align(Alignment.Center) + } else { + Modifier.fillMaxWidth() + .defaultMinSize(minHeight = 100.dp) + .align(Alignment.Center) + }, factory = { PlayerView(context).apply { player = controller @@ -518,6 +553,10 @@ private fun RenderVideoPlayer( } ) + waveform?.let { + Waveform(it, controller, Modifier.align(Alignment.Center)) + } + val startingMuteState = remember(controller) { controller.volume < 0.001 } @@ -555,6 +594,69 @@ private fun RenderVideoPlayer( } } +private fun pollCurrentDuration(controller: MediaController) = flow { + while (controller.currentPosition <= controller.duration) { + emit(controller.currentPosition / controller.duration.toFloat()) + delay(100) + } +}.conflate() + +@Composable +fun Waveform( + waveform: ImmutableList, + controller: MediaController, + modifier: Modifier +) { + val waveformProgress = remember { mutableStateOf(0F) } + + DrawWaveform(waveform, waveformProgress, modifier) + + val restartFlow = remember { + mutableStateOf(0) + } + + // Keeps the screen on while playing and viewing videos. + DisposableEffect(key1 = controller) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + if (isPlaying) { + restartFlow.value += 1 + } + } + } + + controller.addListener(listener) + onDispose { + controller.removeListener(listener) + } + } + + LaunchedEffect(key1 = restartFlow.value) { + pollCurrentDuration(controller).collect() { value -> + waveformProgress.value = value + } + } +} + +@Composable +fun DrawWaveform(waveform: ImmutableList, waveformProgress: MutableState, modifier: Modifier) { + AudioWaveformReadOnly( + modifier = modifier.padding(start = 10.dp, end = 10.dp), + amplitudes = waveform, + progress = waveformProgress.value, + progressBrush = Brush.infiniteLinearGradient( + colors = listOf(Color(0xff2598cf), Color(0xff652d80)), + animation = tween(durationMillis = 6000, easing = LinearEasing), + width = 128F + ), + onProgressChange = { + waveformProgress.value = it + } + ) +} + @Composable fun ControlWhenPlayerIsActive( controller: Player, 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 index 1e0616cde..9e11efe18 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -29,11 +29,18 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +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.Share import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable @@ -46,10 +53,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign @@ -77,11 +86,15 @@ import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon import com.vitorpamplona.amethyst.ui.note.HashCheckIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Font17SP +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.amethyst.ui.theme.Size30dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.toHexKey import kotlinx.collections.immutable.ImmutableList @@ -181,26 +194,38 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent { fun ZoomableContentView( content: ZoomableContent, images: ImmutableList = listOf(content).toImmutableList(), + roundedCorner: Boolean, accountViewModel: AccountViewModel ) { - val clipboardManager = LocalClipboardManager.current - // store the dialog open or close state var dialogOpen by remember { mutableStateOf(false) } - var mainImageModifier = MaterialTheme.colors.imageModifier + // store the dialog open or close state + val shareOpen = remember { + mutableStateOf(false) + } + + if (shareOpen.value) { + ShareImageAction(shareOpen, content) { shareOpen.value = false } + } + + var mainImageModifier = if (roundedCorner) { + MaterialTheme.colors.imageModifier + } else { + Modifier.fillMaxWidth() + } if (content is ZoomableUrlContent) { mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) } + onLongClick = { shareOpen.value = true } ) } else if (content is ZoomablePreloadedContent) { mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) } + onLongClick = { shareOpen.value = true } ) } else { mainImageModifier = mainImageModifier.clickable { @@ -215,6 +240,7 @@ fun ZoomableContentView( title = content.description, artworkUri = content.artworkUri, authorName = content.authorName, + roundedCorner = roundedCorner, nostrUriCallback = content.uri, onDialog = { dialogOpen = true }, accountViewModel = accountViewModel @@ -228,6 +254,7 @@ fun ZoomableContentView( title = content.description, artworkUri = content.artworkUri, authorName = content.authorName, + roundedCorner = roundedCorner, nostrUriCallback = content.uri, onDialog = { dialogOpen = true }, accountViewModel = accountViewModel @@ -624,11 +651,11 @@ fun ZoomableImageDialog( pagerState = pagerState, itemsCount = allImages.size, itemContent = { index -> - RenderImageOrVideo(allImages[index], accountViewModel) + RenderImageOrVideo(allImages[index], false, accountViewModel) } ) } else { - RenderImageOrVideo(imageUrl, accountViewModel) + RenderImageOrVideo(imageUrl, false, accountViewModel) } Row( @@ -642,7 +669,10 @@ fun ZoomableImageDialog( val myContent = allImages[pagerState.currentPage] if (myContent is ZoomableUrlContent) { - SaveToGallery(url = myContent.url) + Row() { + CopyToClipboard(content = myContent) + SaveToGallery(url = myContent.url) + } } else if (myContent is ZoomableLocalImage && myContent.localFile != null) { SaveToGallery( localFile = myContent.localFile, @@ -656,7 +686,65 @@ fun ZoomableImageDialog( } @Composable -private fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: AccountViewModel) { +private fun CopyToClipboard( + content: ZoomableContent +) { + val popupExpanded = remember { mutableStateOf(false) } + + Button( + modifier = Modifier.padding(horizontal = Size5dp), + onClick = { popupExpanded.value = true }, + shape = ButtonBorder, + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.placeholderText + ) + ) { + Icon( + tint = Color.White, + imageVector = Icons.Default.Share, + modifier = Size20Modifier, + contentDescription = stringResource(R.string.copy_url_to_clipboard) + ) + + ShareImageAction(popupExpanded, content) { popupExpanded.value = false } + } +} + +@Composable +private fun ShareImageAction( + popupExpanded: + MutableState, + content: ZoomableContent, + onDismiss: () -> Unit +) { + DropdownMenu( + expanded = popupExpanded.value, + onDismissRequest = onDismiss + ) { + val clipboardManager = LocalClipboardManager.current + + if (content is ZoomableUrlContent) { + DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.url)); onDismiss() }) { + Text(stringResource(R.string.copy_url_to_clipboard)) + } + if (content.uri != null) { + DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.uri)); onDismiss() }) { + Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) + } + } + } + + if (content is ZoomablePreloadedContent) { + DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.uri)); onDismiss() }) { + Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) + } + } + } +} + +@Composable +private fun RenderImageOrVideo(content: ZoomableContent, roundedCorner: Boolean, accountViewModel: AccountViewModel) { val mainModifier = Modifier .fillMaxSize() .zoomable(rememberZoomState()) @@ -670,6 +758,7 @@ private fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: Accou title = content.description, artworkUri = content.artworkUri, authorName = content.authorName, + roundedCorner = roundedCorner, accountViewModel = accountViewModel, alwaysShowVideo = true ) @@ -684,6 +773,7 @@ private fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: Accou title = content.description, artworkUri = content.artworkUri, authorName = content.authorName, + roundedCorner = roundedCorner, accountViewModel = accountViewModel, alwaysShowVideo = true ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt index 7ff5230c2..81e1c27e1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt @@ -11,13 +11,19 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED import com.vitorpamplona.quartz.utils.TimeUtils -open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter() { +open class DiscoverLiveFeedFilter( + val account: Account +) : AdditiveFeedFilter() { override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList + return account.userProfile().pubkeyHex + "-" + followList() + } + + open fun followList(): String { + return account.defaultDiscoveryFollowList } override fun showHiddenKey(): Boolean { - return account.defaultDiscoveryFollowList == PeopleListEvent.blockList + return followList() == PeopleListEvent.blockList } override fun feed(): List { @@ -39,15 +45,15 @@ open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter): List { - val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) + val followingKeySet = account.selectedUsersFollowList(followList()) val counter = ParticipantListBuilder() val participantCounts = collection.associate { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt index a55fb8dfb..609fb5cab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt @@ -1,12 +1,26 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.quartz.events.* import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE -class DiscoverLiveNowFeedFilter(account: Account) : DiscoverLiveFeedFilter(account) { +class DiscoverLiveNowFeedFilter( + account: Account +) : DiscoverLiveFeedFilter(account) { + override fun followList(): String { + // uses follows by default, but other lists if they were selected in the top bar + val currentList = super.followList() + return if (currentList == GLOBAL_FOLLOWS) { + KIND3_FOLLOWS + } else { + currentList + } + } + override fun innerApplyFilter(collection: Collection): Set { val allItems = super.innerApplyFilter(collection) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt index d87af75b9..1555feec5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent @@ -32,7 +33,8 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent || - it.event is PrivateDmEvent + it.event is PrivateDmEvent || + it.event is AudioHeaderEvent ) && it.event?.isTaggedGeoHash(myTag) == true } 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 b9fe23c35..9319908ec 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 @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent @@ -32,7 +33,8 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent || - it.event is PrivateDmEvent + it.event is PrivateDmEvent || + it.event is AudioHeaderEvent ) && it.event?.isTaggedHash(myTag) == true } 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 60466abb4..d5eab0853 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 @@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.GenericRepostEvent @@ -37,7 +38,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter() private fun innerApplyFilter(collection: Collection, ignoreAddressables: Boolean): Set { val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS - val gRelays = account.convertGlobalRelays() + val gRelays = account.activeGlobalRelays() val isHiddenList = showHiddenKey() val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet() @@ -53,7 +54,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter() .filter { it -> val noteEvent = it.event val isGlobalRelay = it.relays.any { gRelays.contains(it) } - (noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) && + (noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent || noteEvent is AudioHeaderEvent) && (!ignoreAddressables || noteEvent.kind() < 10000) && ((isGlobal && isGlobalRelay) || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedGeoHashes(followingGeoSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) && // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable 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 1e5369065..d28045c30 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 @@ -40,7 +40,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter() it.event !is BadgeDefinitionEvent && it.event !is BadgeProfilesEvent && it.event !is GiftWrapEvent && - it.author !== loggedInUser && + (it.event is LnZapEvent || it.author !== loggedInUser) && (isGlobal || it.author?.pubkeyHex in followingKeySet) && it.event?.isTaggedUser(loggedInUserHex) ?: false && (isHiddenList || it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index 3abfbeb8d..857c1be34 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.GenericRepostEvent @@ -41,7 +42,8 @@ class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : Add it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent || - it.event is AudioTrackEvent + it.event is AudioTrackEvent || + it.event is AudioHeaderEvent ) && it.isNewThread() && account.isAcceptable(it) == true diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 23c3224da..816a63d46 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -732,9 +732,9 @@ fun debugState(context: Context) { Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB") Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB") - Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size) - Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.event != null }.size + "/" + LocalCache.addressables.size) - Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size) + Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.liveSet != null }.size + " / " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size) + Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.liveSet != null }.size + " / " + LocalCache.addressables.filter { it.value.event != null }.size + "/" + LocalCache.addressables.size) + Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.liveSet != null }.size + " / " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size) Log.d("STATE DUMP", "Memory used by Events: " + LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + " MB") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index c9c9b82e4..6a7e79ad1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -58,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size35dp @@ -361,6 +363,10 @@ fun InnerChannelCardWithReactions( ) } } + + Divider( + thickness = DividerThickness + ) } @Composable 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 8469d2e0f..f2f0d857a 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -25,6 +26,7 @@ import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.screen.MessageSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -118,5 +120,9 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String, NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) } } + + Divider( + thickness = DividerThickness + ) } } 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 d4d555e1e..05c679f99 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 @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -54,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.screen.CombinedZap import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller import com.vitorpamplona.amethyst.ui.theme.Size10dp @@ -159,6 +161,10 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) } + + Divider( + thickness = DividerThickness + ) } } 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 ac0969f59..2a0179820 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 @@ -154,6 +154,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.events.AppDefinitionEvent +import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.BadgeAwardEvent import com.vitorpamplona.quartz.events.BadgeDefinitionEvent @@ -201,7 +202,6 @@ import java.io.File import java.math.BigDecimal import java.net.URL import java.util.Locale -import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @OptIn(ExperimentalFoundationApi::class) @@ -480,8 +480,8 @@ fun NormalNote( ) } is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) - is FileHeaderEvent -> FileHeaderDisplay(baseNote, accountViewModel) - is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, accountViewModel) + is FileHeaderEvent -> FileHeaderDisplay(baseNote, isQuotedNote, accountViewModel) + is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, isQuotedNote, accountViewModel) else -> LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> CheckNewAndRenderNote( @@ -896,7 +896,6 @@ fun ClickableNote( } } -@OptIn(ExperimentalTime::class) @Composable fun InnerNoteWithReactions( baseNote: Note, @@ -1045,6 +1044,14 @@ private fun RenderNoteRow( RenderAppDefinition(baseNote, accountViewModel, nav) } + is AudioTrackEvent -> { + RenderAudioTrack(baseNote, accountViewModel, nav) + } + + is AudioHeaderEvent -> { + RenderAudioHeader(baseNote, accountViewModel, nav) + } + is ReactionEvent -> { RenderReaction(baseNote, backgroundColor, accountViewModel, nav) } @@ -1077,10 +1084,6 @@ private fun RenderNoteRow( DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav) } - is AudioTrackEvent -> { - RenderAudioTrack(baseNote, accountViewModel, nav) - } - is PinListEvent -> { RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) } @@ -2251,6 +2254,17 @@ private fun RenderAudioTrack( AudioTrackHeader(noteEvent, note, accountViewModel, nav) } +@Composable +private fun RenderAudioHeader( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val noteEvent = note.event as? AudioHeaderEvent ?: return + + AudioHeader(noteEvent, note, accountViewModel, nav) +} + @Composable private fun RenderLongFormContent( note: Note, @@ -3178,7 +3192,7 @@ private fun RenderBadge( } @Composable -fun FileHeaderDisplay(note: Note, accountViewModel: AccountViewModel) { +fun FileHeaderDisplay(note: Note, isQuotedNote: Boolean, accountViewModel: AccountViewModel) { val event = (note.event as? FileHeaderEvent) ?: return val fullUrl = event.url() ?: return @@ -3214,18 +3228,18 @@ fun FileHeaderDisplay(note: Note, accountViewModel: AccountViewModel) { } SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView(content = content, accountViewModel = accountViewModel) + ZoomableContentView(content = content, roundedCorner = isQuotedNote, accountViewModel = accountViewModel) } } @Composable -fun FileStorageHeaderDisplay(baseNote: Note, accountViewModel: AccountViewModel) { +fun FileStorageHeaderDisplay(baseNote: Note, isQuotedNote: Boolean, accountViewModel: AccountViewModel) { val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return val dataEventId = eventHeader.dataEventId() ?: return LoadNote(baseNoteHex = dataEventId) { contentNote -> if (contentNote != null) { - ObserverAndRenderNIP95(baseNote, contentNote, accountViewModel) + ObserverAndRenderNIP95(baseNote, contentNote, isQuotedNote, accountViewModel) } } } @@ -3234,6 +3248,7 @@ fun FileStorageHeaderDisplay(baseNote: Note, accountViewModel: AccountViewModel) private fun ObserverAndRenderNIP95( header: Note, content: Note, + isQuotedNote: Boolean, accountViewModel: AccountViewModel ) { val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return @@ -3280,7 +3295,7 @@ private fun ObserverAndRenderNIP95( Crossfade(targetState = content) { if (it != null) { SensitivityWarning(note = header, accountViewModel = accountViewModel) { - ZoomableContentView(content = it, accountViewModel = accountViewModel) + ZoomableContentView(content = it, roundedCorner = isQuotedNote, accountViewModel = accountViewModel) } } } @@ -3343,8 +3358,7 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: A media?.let { media -> Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(10.dp) + verticalAlignment = Alignment.CenterVertically ) { cover?.let { cover -> LoadThumbAndThenVideoView( @@ -3352,6 +3366,7 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: A title = noteEvent.subject(), thumbUri = cover, authorName = note.author?.toBestDisplayName(), + roundedCorner = true, nostrUriCallback = "nostr:${note.toNEvent()}", accountViewModel = accountViewModel ) @@ -3360,6 +3375,7 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: A videoUri = media, title = noteEvent.subject(), authorName = note.author?.toBestDisplayName(), + roundedCorner = true, accountViewModel = accountViewModel ) } @@ -3368,6 +3384,66 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: A } } +@Composable +fun AudioHeader(noteEvent: AudioHeaderEvent, note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val media = remember { noteEvent.stream() ?: noteEvent.download() } + val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } } + val content = remember { noteEvent.content().ifBlank { null } } + + val defaultBackground = MaterialTheme.colors.background + val background = remember { mutableStateOf(defaultBackground) } + val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() } + + val eventContent = remember(note.event) { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + val body = accountViewModel.decrypt(note) + + if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { + "## $subject\n$body" + } else { + body + } + } + + Row(modifier = Modifier.padding(top = 5.dp)) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + media?.let { media -> + Row( + verticalAlignment = Alignment.CenterVertically + ) { + VideoView( + videoUri = media, + waveform = waveform, + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + nostrUriCallback = note.toNostrUri() + ) + } + } + + content?.let { + Row(verticalAlignment = CenterVertically, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) { + TranslatableRichTextViewer( + content = it, + canPreview = true, + tags = tags, + backgroundColor = background, + accountViewModel = accountViewModel, + nav = nav + ) + } + } + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() } + DisplayUncitedHashtags(hashtags, content ?: "", nav) + } + } + } +} + @Composable fun RenderLiveActivityEvent(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { Row(modifier = Modifier.padding(top = 5.dp)) { @@ -3486,6 +3562,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo title = subject, artworkUri = cover, authorName = baseNote.author?.toBestDisplayName(), + roundedCorner = true, accountViewModel = accountViewModel, nostrUriCallback = "nostr:${baseNote.toNEvent()}" ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt index 1be65dba1..68e7b267b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -191,7 +191,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() { takenIntoAccount.add(noteEvent.id()) } } else if (noteEvent is LnZapEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + if (noteEvent.isTaggedUser(currentUser)) { // the user might be sending his own receipts noteEvent.pubKey != currentUser val netDate = formatDate(noteEvent.createdAt) zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) takenIntoAccount.add(noteEvent.id()) @@ -245,7 +245,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() { hasNewElements = true } } else if (noteEvent is LnZapEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + if (noteEvent.isTaggedUser(currentUser)) { // && noteEvent.pubKey != currentUser User might be sending his own receipts val netDate = formatDate(noteEvent.createdAt) zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) takenIntoAccount.add(noteEvent.id()) @@ -326,6 +326,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() { override fun onCleared() { collectorJob?.cancel() + bundlerInsert.cancel() super.onCleared() } 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 3d04e44eb..0a31f430f 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 @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -22,6 +23,7 @@ import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor @@ -89,5 +91,9 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false, UserCompose(baseUser = zapSetCard.user, accountViewModel = accountViewModel, nav = nav) } } + + Divider( + thickness = DividerThickness + ) } } 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 0e5cdef3b..1133fc121 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 @@ -353,6 +353,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { override fun onCleared() { clear() + bundlerInsert.cancel() + bundler.cancel() collectorJob?.cancel() super.onCleared() } 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 b12369b6c..4a02d8f93 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 @@ -340,6 +340,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel(), I } override fun onCleared() { + bundlerInsert.cancel() + bundler.cancel() collectorJob?.cancel() super.onCleared() } 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 45a766158..b9494a78c 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 @@ -96,6 +96,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter) : View } override fun onCleared() { + bundler.cancel() collectorJob?.cancel() super.onCleared() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt index 498ebc65c..f41eb6f7f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt @@ -91,6 +91,11 @@ class RelayFeedViewModel : ViewModel() { refreshSuspended() } } + + override fun onCleared() { + bundler.cancel() + super.onCleared() + } } @OptIn(ExperimentalMaterialApi::class) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index ef644c570..580c14d5b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote import com.vitorpamplona.quartz.events.AppDefinitionEvent +import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.BadgeDefinitionEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent @@ -376,13 +377,15 @@ fun NoteMaster( nav = nav ) } else if (noteEvent is FileHeaderEvent) { - FileHeaderDisplay(baseNote, accountViewModel) + FileHeaderDisplay(baseNote, true, accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { - FileStorageHeaderDisplay(baseNote, accountViewModel) + FileStorageHeaderDisplay(baseNote, true, accountViewModel) } else if (noteEvent is PeopleListEvent) { DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is AudioTrackEvent) { AudioTrackHeader(noteEvent, baseNote, accountViewModel, nav) + } else if (noteEvent is AudioHeaderEvent) { + AudioHeader(noteEvent, baseNote, accountViewModel, nav) } else if (noteEvent is CommunityPostApprovalEvent) { RenderPostApproval( baseNote, 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 a31a8a5cf..d2ca45832 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 @@ -125,6 +125,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel(), In } override fun onCleared() { + bundler.cancel() collectorJob?.cancel() super.onCleared() } 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 8d04c2ba5..f40b00600 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 @@ -644,6 +644,7 @@ fun ShowVideoStreaming( ZoomableContentView( content = zoomableUrlVideo, + roundedCorner = false, accountViewModel = accountViewModel ) } 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 ce9cce988..004f378a8 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 @@ -219,7 +219,7 @@ fun MainScreen( } }, floatingActionButton = { - FloatingButtons(navState, accountViewModel, accountStateViewModel, nav) + FloatingButtons(navState, accountViewModel, accountStateViewModel, nav, navBottomRow) }, scaffoldState = scaffoldState ) { @@ -249,7 +249,8 @@ fun FloatingButtons( navEntryState: State, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, - nav: (String) -> Unit + nav: (String) -> Unit, + navScrollToTop: (Route, Boolean) -> Unit ) { val accountState by accountStateViewModel.accountContent.collectAsState() val context = LocalContext.current @@ -265,7 +266,7 @@ fun FloatingButtons( // Does nothing. } is AccountState.LoggedIn -> { - WritePermissionButtons(navEntryState, accountViewModel, nav) + WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop) } } } @@ -275,7 +276,8 @@ fun FloatingButtons( private fun WritePermissionButtons( navEntryState: State, accountViewModel: AccountViewModel, - nav: (String) -> Unit + nav: (String) -> Unit, + navScrollToTop: (Route, Boolean) -> Unit ) { val currentRoute by remember(navEntryState.value) { derivedStateOf { @@ -286,7 +288,7 @@ private fun WritePermissionButtons( when (currentRoute) { Route.Home.base -> NewNoteButton(accountViewModel, nav) Route.Message.base -> ChannelFabColumn(accountViewModel, nav) - Route.Video.base -> NewImageButton(accountViewModel, nav) + Route.Video.base -> NewImageButton(accountViewModel, nav, navScrollToTop) Route.Community.base -> { val communityId by remember(navEntryState.value) { derivedStateOf { 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 742d1f73a..64bb7d5e9 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 @@ -690,9 +690,6 @@ private fun ProfileHeader( ) { MessageButton(baseUser, accountViewModel, nav) - // No need for this button anymore - // NPubCopyButton(baseUser) - ProfileActions(baseUser, accountViewModel) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index acf734db7..49acad049 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -195,6 +195,11 @@ class SearchBarViewModel(val account: Account) : ViewModel() { } } + override fun onCleared() { + bundler.cancel() + super.onCleared() + } + fun isSearchingFun() = searchValue.isNotBlank() class Factory(val account: Account) : ViewModelProvider.Factory { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 1c075d859..2195ee070 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -302,9 +302,9 @@ private fun RenderVideoOrPictureNote( Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) { val noteEvent = remember { note.event } if (noteEvent is FileHeaderEvent) { - FileHeaderDisplay(note, accountViewModel) + FileHeaderDisplay(note, false, accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { - FileStorageHeaderDisplay(note, accountViewModel) + FileStorageHeaderDisplay(note, false, accountViewModel) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b29f806c6..7f28a6bcb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -103,7 +103,7 @@ Uploading… User does not have a lightning address setup to receive sats "reply here.. " - Copies the Note ID to the clipboard for sharing + Copies the Note ID to the clipboard for sharing in Nostr Copy Channel ID (Note) to the Clipboard Edits the Channel Metadata Join @@ -545,4 +545,7 @@ Automatically plays videos and GIFs Show URL previews When to load images + + Copy URL to clipboard + Copy Note ID to clipboard diff --git a/benchmark/build.gradle b/benchmark/build.gradle index 63c6e2931..164ea3f9e 100644 --- a/benchmark/build.gradle +++ b/benchmark/build.gradle @@ -6,20 +6,19 @@ plugins { android { namespace 'com.vitorpamplona.amethyst.benchmark' - compileSdk 33 + compileSdk 34 compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "1.8" + jvmTarget = '17' } defaultConfig { minSdk 26 - targetSdk 33 + targetSdk 34 testInstrumentationRunner 'androidx.benchmark.junit4.AndroidBenchmarkRunner' } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt new file mode 100644 index 000000000..c332fa76c --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt @@ -0,0 +1,39 @@ +package com.vitorpamplona.amethyst.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.encoders.Bech32 +import com.vitorpamplona.quartz.encoders.Hex +import com.vitorpamplona.quartz.encoders.bechToBytes +import com.vitorpamplona.quartz.encoders.toNpub +import com.vitorpamplona.quartz.events.Event +import junit.framework.TestCase.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BechBenchmark { + + @get:Rule + val benchmarkRule = BenchmarkRule() + + @Test + fun npubEncoding() { + val myUser = Hex.decode("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + benchmarkRule.measureRepeated { + assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",myUser.toNpub()) + } + } + + @Test + fun npubDecoding() { + val myUser = "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + val expected = listOf(70, 12, 37, -26, -126, -3, -89, -125, 43, 82, -47, -14, 45, 61, 34, -77, 23, 109, -105, 47, 96, -36, -36, 50, 18, -19, -116, -110, -17, -123, 6, 92).map { it.toByte() } + benchmarkRule.measureRepeated { + assertEquals(expected, myUser.bechToBytes().toList()) + } + } + +} \ No newline at end of file diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt new file mode 100644 index 000000000..13e862dc0 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt @@ -0,0 +1,102 @@ +package com.vitorpamplona.amethyst.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.Bech32 +import com.vitorpamplona.quartz.encoders.Hex +import com.vitorpamplona.quartz.encoders.bechToBytes +import com.vitorpamplona.quartz.encoders.toNpub +import com.vitorpamplona.quartz.events.Event +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CryptoBenchmark { + + @get:Rule + val benchmarkRule = BenchmarkRule() + + @Test + fun getSharedKeyNip04() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() + + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.getSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) + } + } + + @Test + fun getSharedKeyNip44() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() + + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.getSharedSecretNIP24(keyPair1.privKey!!, keyPair2.pubKey)) + } + } + + @Test + fun computeSharedKeyNip04() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() + + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.computeSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) + } + } + + @Test + fun computeSharedKeyNip44() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() + + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.computeSharedSecretNIP24(keyPair1.privKey!!, keyPair2.pubKey)) + } + } + + @Test + fun random() { + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.random(1000)) + } + } + + @Test + fun sha256() { + val keyPair = KeyPair() + + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.sha256(keyPair.pubKey)) + } + } + + @Test + fun sign() { + val keyPair = KeyPair() + val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) + + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.sign(msg, keyPair.privKey!!)) + } + } + + @Test + fun verify() { + val keyPair = KeyPair() + val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) + val signature = CryptoUtils.sign(msg, keyPair.privKey!!) + + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.verifySignature(signature, msg, keyPair.pubKey)) + } + } + +} \ No newline at end of file diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt index c63d2c58e..e9f6ffe7a 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt @@ -3,8 +3,11 @@ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.NIP24Factory import com.vitorpamplona.quartz.events.SealedGossipEvent import org.junit.Assert @@ -53,6 +56,38 @@ class GiftWrapBenchmark { } } + fun receivePerformanceTest(message: String) { + val sender = KeyPair() + val receiver = KeyPair() + + val giftWrap = NIP24Factory().createMsgNIP24( + message, + listOf(receiver.pubKey.toHexKey()), + sender.privKey!! + ).first() + + val keyToUse = if (giftWrap.recipientPubKey() == sender.pubKey.toHexKey()) sender.privKey!! else receiver.privKey!! + val giftWrapJson = giftWrap.toJson() + + // Simulate Receiver + benchmarkRule.measureRepeated { + CryptoUtils.clearCache() + + val wrap = Event.fromJson(giftWrapJson) as GiftWrapEvent + wrap.checkSignature() + + val seal = wrap.unwrap(keyToUse) + seal?.checkSignature() + + if (seal is SealedGossipEvent) { + val innerData = seal.unseal(keyToUse) + Assert.assertEquals(message, innerData?.content) + } else { + Assert.fail("Wrong Event") + } + } + } + @Test fun tinyMessageHardCoded() { @@ -77,6 +112,25 @@ class GiftWrapBenchmark { ) } } + + @Test + fun receivesTinyMessage() { + receivePerformanceTest("Hola, que tal?") + } + + @Test + fun receivesRegularMessage() { + receivePerformanceTest("Hi, honey, can you drop by the market and get some bread?") + } + + @Test + fun receivesLongMessageHardCoded() { + receivePerformanceTest( + "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. " + ) + } + + /* @Test fun tinyMessageHardCodedCompressed() { diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt index 10cd3307c..f4f634346 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt @@ -74,6 +74,50 @@ class GiftWrapReceivingBenchmark { } } + @Test + fun checkId() { + val sender = KeyPair() + val receiver = KeyPair() + + val senderPublicKey = CryptoUtils.pubkeyCreate(sender.privKey!!).toHexKey() + val senderMessage = createMessage(sender, receiver) + + val wrap = GiftWrapEvent.create( + event = SealedGossipEvent.create( + event = senderMessage, + encryptTo = senderPublicKey, + privateKey = sender.privKey!! + ), + recipientPubKey = senderPublicKey + ) + + benchmarkRule.measureRepeated { + wrap.hasCorrectIDHash() + } + } + + @Test + fun checkSignature() { + val sender = KeyPair() + val receiver = KeyPair() + + val senderPublicKey = CryptoUtils.pubkeyCreate(sender.privKey!!).toHexKey() + val senderMessage = createMessage(sender, receiver) + + val wrap = GiftWrapEvent.create( + event = SealedGossipEvent.create( + event = senderMessage, + encryptTo = senderPublicKey, + privateKey = sender.privKey!! + ), + recipientPubKey = senderPublicKey + ) + + benchmarkRule.measureRepeated { + wrap.hasVerifedSignature() + } + } + @Test fun decodeWrapEvent() { val sender = KeyPair() diff --git a/build.gradle b/build.gradle index d17ae80dd..73dba8106 100644 --- a/build.gradle +++ b/build.gradle @@ -11,6 +11,7 @@ buildscript { coil_version = '2.4.0' vico_version = '1.7.3' exoplayer_version = '1.1.0' + media3_version = '1.1.0' } dependencies { classpath 'com.google.gms:google-services:4.3.15' diff --git a/quartz/build.gradle b/quartz/build.gradle index add2ed196..916b6afdc 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -6,11 +6,11 @@ plugins { android { namespace 'com.vitorpamplona.quartz' - compileSdk 33 + compileSdk 34 defaultConfig { minSdk 26 - targetSdk 33 + targetSdk 34 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt index 0817c059b..b2b3a89ac 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt @@ -1,10 +1,11 @@ package com.vitorpamplona.quartz.crypto import android.util.Log +import android.util.LruCache import com.goterl.lazysodium.SodiumAndroid import com.goterl.lazysodium.utils.Key -import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.encoders.Hex +import com.vitorpamplona.quartz.events.Event import fr.acinq.secp256k1.Secp256k1 import java.security.MessageDigest import java.security.SecureRandom @@ -15,11 +16,19 @@ import javax.crypto.spec.SecretKeySpec object CryptoUtils { + private val sharedKeyCache04 = LruCache(200) + private val sharedKeyCache24 = LruCache(200) + private val secp256k1 = Secp256k1.get() private val libSodium = SodiumAndroid() private val random = SecureRandom() private val h02 = Hex.decode("02") + fun clearCache() { + sharedKeyCache04.evictAll() + sharedKeyCache24.evictAll() + } + fun randomInt(bound: Int): Int { return random.nextInt(bound) } @@ -27,8 +36,10 @@ object CryptoUtils { /** * Provides a 32B "private key" aka random number */ - fun privkeyCreate(): ByteArray { - val bytes = ByteArray(32) + fun privkeyCreate() = random(32) + + fun random(size: Int): ByteArray { + val bytes = ByteArray(size) random.nextBytes(bytes) return bytes } @@ -52,12 +63,6 @@ object CryptoUtils { return MessageDigest.getInstance("SHA-256").digest(data) } - /** - * @return 32B shared secret - */ - fun getSharedSecretNIP04(privateKey: ByteArray, pubKey: ByteArray): ByteArray = - secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) - fun encryptNIP04(msg: String, privateKey: ByteArray, pubKey: ByteArray): String { val info = encryptNIP04(msg, getSharedSecretNIP04(privateKey, pubKey)) val encryptionInfo = EncryptedInfoString( @@ -150,7 +155,39 @@ object CryptoUtils { /** * @return 32B shared secret */ - fun getSharedSecretNIP24(privateKey: ByteArray, pubKey: ByteArray): ByteArray = + fun getSharedSecretNIP04(privateKey: ByteArray, pubKey: ByteArray): ByteArray { + val hash = combinedHashCode(privateKey, pubKey) + val preComputed = sharedKeyCache04[hash] + if (preComputed != null) return preComputed + + val computed = computeSharedSecretNIP04(privateKey, pubKey) + sharedKeyCache04.put(hash, computed) + return computed + } + + /** + * @return 32B shared secret + */ + fun computeSharedSecretNIP04(privateKey: ByteArray, pubKey: ByteArray): ByteArray = + secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + + /** + * @return 32B shared secret + */ + fun getSharedSecretNIP24(privateKey: ByteArray, pubKey: ByteArray): ByteArray { + val hash = combinedHashCode(privateKey, pubKey) + val preComputed = sharedKeyCache24[hash] + if (preComputed != null) return preComputed + + val computed = computeSharedSecretNIP24(privateKey, pubKey) + sharedKeyCache24.put(hash, computed) + return computed + } + + /** + * @return 32B shared secret + */ + fun computeSharedSecretNIP24(privateKey: ByteArray, pubKey: ByteArray): ByteArray = sha256(secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33)) } @@ -213,6 +250,13 @@ fun decodeJackson(json: String): EncryptedInfo { ) } +fun combinedHashCode(a: ByteArray, b: ByteArray): Int { + var result = 1 + for (element in a) result = 31 * result + element + for (element in b) result = 31 * result + element + return result +} + /* OLD Versions used for the Benchmark diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt index 0a27bfa24..6398225dc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt @@ -30,6 +30,7 @@ private typealias Int5 = Byte */ object Bech32 { const val alphabet: String = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + const val alphabetUpperCase: String = "QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L" enum class Encoding(public val constant: Int) { Bech32(1), @@ -44,20 +45,26 @@ object Bech32 { for (i in 0..alphabet.lastIndex) { map[alphabet[i].code] = i.toByte() } - } - - private fun expand(hrp: String): Array { - val result = Array(hrp.length + 1 + hrp.length) { 0 } - for (i in hrp.indices) { - result[i] = hrp[i].code.shr(5).toByte() - result[hrp.length + 1 + i] = (hrp[i].code and 31).toByte() + for (i in 0..alphabetUpperCase.lastIndex) { + map[alphabetUpperCase[i].code] = i.toByte() } - result[hrp.length] = 0 - return result } - private fun polymod(values: Array, values1: Array): Int { - val GEN = arrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + fun expand(hrp: String): Array { + val half = hrp.length + 1 + val size = half + hrp.length + return Array(size) { + when (it) { + in hrp.indices -> hrp[it].code.shr(5).toByte() + in half until size -> (hrp[it - half].code and 31).toByte() + else -> 0 + } + } + } + + private val GEN = arrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + + fun polymod(values: Array, values1: Array): Int { var chk = 1 values.forEach { v -> val b = chk shr 25 @@ -83,14 +90,18 @@ object Bech32 { * @return hrp + data encoded as a Bech32 string */ @JvmStatic - public fun encode(hrp: String, int5s: Array, encoding: Encoding): String { + public fun encode(hrp: String, int5s: ArrayList, encoding: Encoding): String { require(hrp.lowercase() == hrp || hrp.uppercase() == hrp) { "mixed case strings are not valid bech32 prefixes" } - val data = int5s.toByteArray().toTypedArray() - val checksum = when (encoding) { - Encoding.Beck32WithoutChecksum -> arrayOf() - else -> checksum(hrp, data, encoding) + val dataWithChecksum = when (encoding) { + Encoding.Beck32WithoutChecksum -> int5s + else -> addChecksum(hrp, int5s, encoding) } - return hrp + "1" + (data + checksum).map { i -> alphabet[i.toInt()] }.toCharArray().concatToString() + + val charArray = CharArray(dataWithChecksum.size) { + alphabet[dataWithChecksum[it].toInt()] + }.concatToString() + + return hrp + "1" + charArray } /** @@ -110,14 +121,21 @@ object Bech32 { */ @JvmStatic public fun decode(bech32: String, noChecksum: Boolean = false): Triple, Encoding> { - require(bech32.lowercase() == bech32 || bech32.uppercase() == bech32) { "mixed case strings are not valid bech32" } - bech32.forEach { require(it.code in 33..126) { "invalid character " } } - val input = bech32.lowercase() - val pos = input.lastIndexOf('1') - val hrp = input.take(pos) + var pos = 0 + bech32.forEachIndexed { index, char -> + require( char.code in 33..126 ) { "invalid character ${char}" } + if (char == '1') { + pos = index + } + } + + val hrp = bech32.take(pos) require(hrp.length in 1..83) { "hrp must contain 1 to 83 characters" } - val data = Array(input.length - pos - 1) { 0 } - for (i in 0..data.lastIndex) data[i] = map[input[pos + 1 + i].code] + + val data = Array(bech32.length - pos - 1) { + map[bech32[pos + 1 + it].code] + } + return if (noChecksum) { Triple(hrp, data, Encoding.Beck32WithoutChecksum) } else { @@ -126,7 +144,7 @@ object Bech32 { Encoding.Bech32m.constant -> Encoding.Bech32m else -> throw IllegalArgumentException("invalid checksum for $bech32") } - Triple(hrp, data.dropLast(6).toTypedArray(), encoding) + Triple(hrp, data.copyOfRange(0, data.size-6), encoding) } } @@ -142,16 +160,23 @@ object Bech32 { return Triple(hrp, five2eight(int5s, 0), encoding) } + val ZEROS = arrayOf(0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte()) + /** * @param hrp Human Readable Part * @param data data (a sequence of 5 bits integers) * @param encoding encoding to use (bech32 or bech32m) * @return a checksum computed over hrp and data */ - private fun checksum(hrp: String, data: Array, encoding: Encoding): Array { + private fun addChecksum(hrp: String, data: ArrayList, encoding: Encoding): ArrayList { val values = expand(hrp) + data - val poly = polymod(values, arrayOf(0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte())) xor encoding.constant - return Array(6) { i -> (poly.shr(5 * (5 - i)) and 31).toByte() } + val poly = polymod(values, ZEROS) xor encoding.constant + + for (i in 0 until 6) { + data.add((poly.shr(5 * (5 - i)) and 31).toByte()) + } + + return data } /** @@ -159,9 +184,9 @@ object Bech32 { * @return a sequence of 5 bits integers */ @JvmStatic - public fun eight2five(input: ByteArray): Array { + public fun eight2five(input: ByteArray): ArrayList { var buffer = 0L - val output = ArrayList() + val output = ArrayList(input.size * 2) //larger array on purpose. Checksum is added later. var count = 0 input.forEach { b -> buffer = (buffer shl 8) or (b.toLong() and 0xff) @@ -172,7 +197,7 @@ object Bech32 { } } if (count > 0) output.add(((buffer shl (5 - count)) and 31).toByte()) - return output.toTypedArray() + return output } /** @@ -182,7 +207,7 @@ object Bech32 { @JvmStatic public fun five2eight(input: Array, offset: Int): ByteArray { var buffer = 0L - val output = ArrayList() + val output = ArrayList(input.size) var count = 0 for (i in offset..input.lastIndex) { val b = input[i] diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt new file mode 100644 index 000000000..a9ea96d6f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt @@ -0,0 +1,62 @@ +package com.vitorpamplona.quartz.events + +import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.HexKey + +@Immutable +class AudioHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun download() = tags.firstOrNull { it.size > 1 && it[0] == DOWNLOAD_URL }?.get(1) + fun stream() = tags.firstOrNull { it.size > 1 && it[0] == STREAM_URL }?.get(1) + fun wavefrom() = tags.firstOrNull { it.size > 1 && it[0] == WAVEFORM }?.get(1)?.let { + mapper.readValue>(it) + } + + companion object { + const val kind = 1808 + + private const val DOWNLOAD_URL = "download_url" + private const val STREAM_URL = "stream_url" + private const val WAVEFORM = "waveform" + + fun create( + description: String, + downloadUrl: String, + streamUrl: String? = null, + wavefront: String? = null, + sensitiveContent: Boolean? = null, + privateKey: ByteArray, + createdAt: Long = TimeUtils.now() + ): AudioHeaderEvent { + val tags = listOfNotNull( + downloadUrl.let { listOf(DOWNLOAD_URL, it) }, + streamUrl?.let { listOf(STREAM_URL, it) }, + wavefront?.let { listOf(WAVEFORM, it) }, + sensitiveContent?.let { + if (it) { + listOf("content-warning", "") + } else { + null + } + } + ) + + val content = description + val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = CryptoUtils.sign(id, privateKey) + return AudioHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt index 47a552f18..b41c60429 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt @@ -17,7 +17,7 @@ class ChatMessageEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable { +) : WrappedEvent(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable { /** * Recepients intended to receive this conversation */ diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt index ea9e15ca9..3563650af 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt @@ -215,7 +215,7 @@ class ContactListEvent( return create( content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag }, + tags = earlierVersion.tags.filter { it.size > 1 && !it[1].equals(hashtag, true) }, privateKey = privateKey, createdAt = createdAt ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 594e17e47..2bf84d6dd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -362,6 +362,22 @@ open class Event( } } +@Immutable +open class WrappedEvent( + id: HexKey, + @JsonProperty("pubkey") + pubKey: HexKey, + @JsonProperty("created_at") + createdAt: Long, + kind: Int, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient + var host: Event? = null // host event to broadcast when needed +} + @Immutable interface AddressableEvent { fun dTag(): String diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index f2c2043a9..745d03ecd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -16,6 +16,7 @@ class EventFactory { AdvertisedRelayListEvent.kind -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + AudioHeaderEvent.kind -> AudioHeaderEvent(id, pubKey, createdAt, tags, content, sig) AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig) BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt index 84ab92e36..9c58f89ff 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt @@ -28,6 +28,10 @@ class GiftWrapEvent( if (cachedInnerEvent.contains(hex)) return cachedInnerEvent[hex] val myInnerEvent = unwrap(privKey = privKey) + if (myInnerEvent is WrappedEvent) { + myInnerEvent.host = this + } + cachedInnerEvent = cachedInnerEvent + Pair(hex, myInnerEvent) return myInnerEvent } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt index 43e4a25ae..83f89d044 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt @@ -40,6 +40,10 @@ class LiveActivitiesEvent( } } + fun participantsIntersect(keySet: Set): Boolean { + return tags.any { it.size > 1 && it[0] == "p" && it[1] in keySet } + } + companion object { const val kind = 30311 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt index 3bdf735d9..c3eadf865 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt @@ -20,7 +20,7 @@ class SealedGossipEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { +): WrappedEvent(id, pubKey, createdAt, kind, tags, content, sig) { @Transient private var cachedInnerEvent: Map = mapOf() @@ -30,6 +30,10 @@ class SealedGossipEvent( val gossip = unseal(privKey = privKey) val event = gossip?.mergeWith(this) + if (event is WrappedEvent) { + event.host = host ?: this + } + cachedInnerEvent = cachedInnerEvent + Pair(hex, event) return event }