diff --git a/PRIVACY.md b/PRIVACY.md index 7c481a0c4..50504e609 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -4,7 +4,7 @@ Effective as of Jan 20, 2023 for the distributed applications in the Play Store The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them. -Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app. +The app may collect a per-device token, your public key and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app. You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling. diff --git a/app/build.gradle b/app/build.gradle index 29060a914..a1cf46a63 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 155 - versionName "0.46.1" + versionCode 161 + versionName "0.47.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 7f7800a05..ed3e9d0cf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -281,6 +281,18 @@ object LocalCache { refreshObservers(note) } + private fun consume(event: AudioTrackEvent) { + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + fun consume(event: BadgeDefinitionEvent) { val note = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) @@ -920,6 +932,7 @@ object LocalCache { try { when (event) { + is AudioTrackEvent -> consume(event) is BadgeAwardEvent -> consume(event) is BadgeDefinitionEvent -> consume(event) is BadgeProfilesEvent -> 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 c391efeb6..e8920e69f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -411,9 +411,9 @@ class NoteLiveSet(u: Note) { class NoteLiveData(val note: Note) : LiveData(NoteState(note)) { // Refreshes observers in batches. private val bundler = BundledUpdate(300, Dispatchers.IO) { - if (hasActiveObservers()) { - postValue(NoteState(note)) - } + // if (hasObservers()) { + postValue(NoteState(note)) + // } } fun invalidateData() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt index ebf3425db..735036304 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent @@ -13,7 +14,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") { fun createGlobalFilter() = TypedFilter( types = setOf(FeedType.GLOBAL), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), + kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, LongTextNoteEvent.kind, HighlightEvent.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 e4233c106..9b278e09c 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 androidx.compose.ui.text.capitalize import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter @@ -25,7 +26,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { hashToLoad.capitalize() ) ), - kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.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 d83151205..a4277b576 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent @@ -56,7 +57,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind), authors = followSet, limit = 400, since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList @@ -72,7 +73,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind), tags = mapOf( "t" to hashToLoad.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index e1b663965..41edd2886 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -29,7 +29,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind, - PollNoteEvent.kind + PollNoteEvent.kind, AudioTrackEvent.kind ), tags = mapOf("a" to listOf(aTag.toTag())), since = it.lastReactionsDownloadTime @@ -80,7 +80,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { LnZapEvent.kind, LnZapRequestEvent.kind, PollNoteEvent.kind, - HighlightEvent.kind + HighlightEvent.kind, + AudioTrackEvent.kind ), tags = mapOf("e" to listOf(it.idHex)), since = it.lastReactionsDownloadTime @@ -119,7 +120,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, PrivateDmEvent.kind, FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind, - HighlightEvent.kind + HighlightEvent.kind, AudioTrackEvent.kind ), ids = interestedEvents.toList() ) 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 49d4e2bc4..2246c7c80 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -28,7 +28,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), + kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, AudioTrackEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), authors = listOf(it.pubkeyHex), limit = 200 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/AudioTrackEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/AudioTrackEvent.kt new file mode 100644 index 000000000..82dc30aa0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/AudioTrackEvent.kt @@ -0,0 +1,61 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class AudioTrackEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + + override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) + + fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) } + fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) + fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1) + fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1) + fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1) + fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1) + + companion object { + const val kind = 31337 + + private const val TYPE = "c" + private const val PRICE = "price" + private const val COVER = "cover" + private const val SUBJECT = "subject" + private const val MEDIA = "media" + + fun create( + type: String, + media: String, + price: String? = null, + cover: String? = null, + subject: String? = null, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): AudioTrackEvent { + val tags = listOfNotNull( + listOf(MEDIA, media), + listOf(TYPE, type), + price?.let { listOf(PRICE, it) }, + cover?.let { listOf(COVER, it) }, + subject?.let { listOf(SUBJECT, it) } + ) + + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, "") + val sig = Utils.sign(id, privateKey) + return AudioTrackEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey()) + } + } +} + +data class Participant(val key: String, val role: String?) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt index 42f559877..5e8be5350 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt @@ -9,9 +9,9 @@ class BadgeDefinitionEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" - fun address() = ATag(kind, pubKey, dTag(), null) +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt index 4995c413b..f616bb7a4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt @@ -9,7 +9,7 @@ class BadgeProfilesEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { val aTagValue = it.getOrNull(1) @@ -18,8 +18,8 @@ class BadgeProfilesEvent( if (aTagValue != null) ATag.parse(aTagValue, relay) else null } - fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag(), null) + override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) companion object { const val kind = 30008 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 6747b1f13..38a99ac76 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -220,6 +220,7 @@ open class Event( fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient) fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) { + 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) BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) @@ -282,3 +283,8 @@ open class Event( } } } + +interface AddressableEvent { + fun dTag(): String + fun address(): ATag +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt index cbb8b2fac..57cadcd66 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt @@ -14,9 +14,9 @@ abstract class GeneralListEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag(), null) +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) fun category() = dTag() fun bookmarkedPosts() = taggedEvents() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index 8586849a6..eeb2bb81a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -71,7 +71,7 @@ class LnZapRequestEvent( listOf("p", originalNote.pubKey()), listOf("relays") + relays ) - if (originalNote is LongTextNoteEvent) { + if (originalNote is AddressableEvent) { tags = tags + listOf(listOf("a", originalNote.address().toTag())) } if (pollOption != null && pollOption >= 0) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt index 46f136248..529c96fd3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt @@ -12,9 +12,9 @@ class LongTextNoteEvent( tags: List>, content: String, sig: HexKey -) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag(), null) +) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt index a9f639a36..977488836 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt @@ -15,9 +15,9 @@ class MuteListEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag(), null) +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) fun plainContent(privKey: ByteArray): String? { return try { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt index 13188520c..91b34d4e7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt @@ -32,7 +32,7 @@ class ReactionEvent( val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey())) - if (originalNote is LongTextNoteEvent) { + if (originalNote is AddressableEvent) { tags = tags + listOf(listOf("a", originalNote.address().toTag())) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt index d3bd9dd97..b05e55870 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt @@ -63,7 +63,7 @@ class ReportEvent( val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() var tags: List> = listOf(reportPostTag, reportAuthorTag) - if (reportedPost is LongTextNoteEvent) { + if (reportedPost is AddressableEvent) { tags = tags + listOf(listOf("a", reportedPost.address().toTag())) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt index d5e0f1eb9..c00c67617 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt @@ -36,7 +36,7 @@ class RepostEvent( val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() var tags: List> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor)) - if (boostedPost is LongTextNoteEvent) { + if (boostedPost is AddressableEvent) { tags = tags + listOf(listOf("a", boostedPost.address().toTag())) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index fd06198ae..03be8b972 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -46,7 +46,7 @@ class EventNotificationConsumer(private val applicationContext: Context) { val user = note.author?.toBestDisplayName() ?: "" val userPicture = note.author?.profilePicture() val noteUri = note.toNEvent() - notificationManager().sendDMNotification(content, user, userPicture, noteUri, applicationContext) + notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext) } } } @@ -88,7 +88,7 @@ class EventNotificationConsumer(private val applicationContext: Context) { } val userPicture = senderInfo?.first?.profilePicture() val noteUri = "nostr:Notifications" - notificationManager().sendZapNotification(content, title, userPicture, noteUri, applicationContext) + notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index d8d5d5d0b..36a5bcb84 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -15,10 +15,6 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity object NotificationUtils { - - // Notification ID. - private var notificationId = 0 - private var dmChannel: NotificationChannel? = null private var zapChannel: NotificationChannel? = null @@ -63,6 +59,7 @@ object NotificationUtils { } fun NotificationManager.sendZapNotification( + id: String, messageBody: String, messageTitle: String, pictureUrl: String?, @@ -72,10 +69,11 @@ object NotificationUtils { val zapChannel = getOrCreateZapChannel(applicationContext) val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id) - sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext) + sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext) } fun NotificationManager.sendDMNotification( + id: String, messageBody: String, messageTitle: String, pictureUrl: String?, @@ -85,10 +83,11 @@ object NotificationUtils { val dmChannel = getOrCreateDMChannel(applicationContext) val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id) - sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext) + sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext) } fun NotificationManager.sendNotification( + id: String, messageBody: String, messageTitle: String, pictureUrl: String?, @@ -104,6 +103,7 @@ object NotificationUtils { val imageLoader = ImageLoader(applicationContext) val imageResult = imageLoader.executeBlocking(request) sendNotification( + id = id, messageBody = messageBody, messageTitle = messageTitle, picture = imageResult.drawable as? BitmapDrawable, @@ -113,6 +113,7 @@ object NotificationUtils { ) } else { sendNotification( + id = id, messageBody = messageBody, messageTitle = messageTitle, picture = null, @@ -124,6 +125,7 @@ object NotificationUtils { } private fun NotificationManager.sendNotification( + id: String, messageBody: String, messageTitle: String, picture: BitmapDrawable?, @@ -137,7 +139,7 @@ object NotificationUtils { val contentPendingIntent = PendingIntent.getActivity( applicationContext, - notificationId, + id.hashCode(), contentIntent, PendingIntent.FLAG_MUTABLE ) @@ -171,9 +173,7 @@ object NotificationUtils { .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) - notify(notificationId, builder.build()) - - notificationId++ + notify(id.hashCode(), builder.build()) } /** 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 05651dcbc..2a1036fb9 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 @@ -71,9 +71,9 @@ class BundledInsert( delay(delay) - mySet.clear() - queue.drainTo(mySet) - onUpdate(mySet) + val mySet2 = mutableSetOf() + queue.drainTo(mySet2) + onUpdate(mySet2) } finally { withContext(NonCancellable) { onlyOneInBlock.set(false) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index ba0d79464..8d622093f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -13,6 +13,7 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,10 +51,14 @@ fun ExpandableRichTextViewer( minOf(firstSpaceAfterCut, firstNewLineAfterCut) } - val text = if (showFullText) { - content - } else { - content.take(whereToCut) + val text by remember(content) { + derivedStateOf { + if (showFullText) { + content + } else { + content.take(whereToCut) + } + } } Box { 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 17d165a3d..a833e977d 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 @@ -50,7 +50,6 @@ import java.net.MalformedURLException import java.net.URISyntaxException import java.net.URL import java.util.regex.Pattern -import kotlin.time.ExperimentalTime val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3") @@ -113,7 +112,6 @@ class RichTextViewerState( val imageList: List ) -@OptIn(ExperimentalTime::class) @Composable private fun RenderRegular( content: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index 561d86ab3..88de0195b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -41,13 +42,15 @@ fun UrlPreviewCard( ) ) { Column { - val validatedUrl = URL(previewInfo.url) + val validatedUrl = remember { URL(previewInfo.url) } // correctly treating relative images - val imageUrl = if (previewInfo.image.startsWith("/")) { - URL(validatedUrl, previewInfo.image).toString() - } else { - previewInfo.image + val imageUrl = remember { + if (previewInfo.image.startsWith("/")) { + URL(validatedUrl, previewInfo.image).toString() + } else { + previewInfo.image + } } AsyncImage( 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 7a6441fcb..909b366a7 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 @@ -1,6 +1,8 @@ package com.vitorpamplona.amethyst.ui.components +import android.graphics.drawable.Drawable import android.net.Uri +import android.util.Log import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -28,6 +30,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -46,6 +49,8 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import coil.imageLoader +import coil.request.ImageRequest import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem @@ -55,6 +60,8 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.StyledPlayerView import com.vitorpamplona.amethyst.VideoCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.io.File public var muted = mutableStateOf(true) @@ -62,20 +69,55 @@ public var muted = mutableStateOf(true) @Composable fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { if (localFile != null) { - VideoView(localFile.toUri(), description, onDialog) + VideoView(localFile.toUri(), description, null, onDialog) } } @Composable fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { - VideoView(Uri.parse(videoUri), description, onDialog) + VideoView(Uri.parse(videoUri), description, null, onDialog) } @Composable -fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { +fun VideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) { + var loadingFinished by remember { mutableStateOf>(Pair(false, null)) } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + scope.launch(Dispatchers.IO) { + try { + val request = ImageRequest.Builder(context).data(thumbUri).build() + val myCover = context.imageLoader.execute(request).drawable + if (myCover != null) { + loadingFinished = Pair(true, myCover) + } else { + loadingFinished = Pair(true, null) + } + } catch (e: Exception) { + Log.e("VideoView", "Fail to load cover $thumbUri", e) + loadingFinished = Pair(true, null) + } + } + } + + if (loadingFinished.first) { + if (loadingFinished.second != null) { + VideoView(Uri.parse(videoUri), description, loadingFinished.second, onDialog) + } else { + VideoView(videoUri, description, onDialog) + } + } +} + +@Composable +fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = null, onDialog: ((Boolean) -> Unit)? = null) { val context = LocalContext.current val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + println("loading audio with artwork $thumb") + val exoPlayer = remember(videoUri) { val mediaBuilder = MediaItem.Builder().setUri(videoUri) @@ -89,7 +131,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_ALL - videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT volume = 0f if (videoUri.scheme?.startsWith("file") == true) { setMediaItem(media) @@ -116,9 +158,9 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - .defaultMinSize(minHeight = 70.dp) .align(Alignment.Center) .onVisibilityChanges { visible -> - if (visible) { + if (visible && !exoPlayer.isPlaying) { exoPlayer.play() - } else { + } else if (!visible && exoPlayer.isPlaying) { exoPlayer.pause() } }, @@ -130,6 +172,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - ViewGroup.LayoutParams.WRAP_CONTENT ) controllerAutoShow = false + thumb?.let { defaultArtwork = thumb } hideController() resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH onDialog?.let { innerOnDialog -> @@ -146,7 +189,6 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - muted.value = !muted.value } } - ) { val observer = LifecycleEventObserver { _, event -> when (event) { @@ -175,7 +217,10 @@ fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifi } onGloballyPositioned { coordinates -> - isVisible = coordinates.isCompletelyVisible(view) + val newIsVisible = coordinates.isCompletelyVisible(view) + if (isVisible != newIsVisible) { + isVisible = newIsVisible + } } } 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 bdecb38e2..055f31624 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 @@ -17,9 +17,11 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -209,17 +211,21 @@ private fun LocalImageView( mutableStateOf(null) } - val ratio = remember { - aspectRatio(content.dim) - } - BoxWithConstraints(contentAlignment = Alignment.Center) { - val myModifier = mainImageModifier.also { - if (ratio != null) { - it.aspectRatio(ratio, maxHeight.isFinite) - } + val myModifier = remember { + mainImageModifier + .widthIn(max = maxWidth) + .heightIn(max = maxHeight) + .run { + aspectRatio(content.dim)?.let { ratio -> + this.aspectRatio(ratio, false) + } ?: this + } + } + + val contentScale = remember { + if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth } - val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth if (content.localFile != null && content.localFile.exists()) { AsyncImage( @@ -280,12 +286,20 @@ private fun UrlImageView( } BoxWithConstraints(contentAlignment = Alignment.Center) { - val myModifier = mainImageModifier.run { - aspectRatio(content.dim)?.let { ratio -> - this.aspectRatio(ratio, maxHeight.isFinite) - } ?: this + val myModifier = remember { + mainImageModifier + .widthIn(max = maxWidth) + .heightIn(max = maxHeight) + .run { + aspectRatio(content.dim)?.let { ratio -> + this.aspectRatio(ratio, false) + } ?: this + } + } + + val contentScale = remember { + if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth } - val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth AsyncImage( model = content.url, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt index aa37f11ab..e048b2e35 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt @@ -27,7 +27,7 @@ object GlobalFeedFilter : AdditiveFeedFilter() { return collection .asSequence() .filter { - it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty() + (it.event is BaseTextNoteEvent || it.event is AudioTrackEvent) && it.replyTo.isNullOrEmpty() } .filter { val channel = it.channelHex() 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 fcd175020..055513180 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 @@ -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.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent @@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() { return collection .asSequence() .filter { it -> - (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) && + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent || it.event is AudioTrackEvent) && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) && // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true && diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index a25d4c8e2..8b0501619 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -1,9 +1,11 @@ package com.vitorpamplona.amethyst.ui.navigation +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -294,6 +296,12 @@ fun ListContent( title = textTorProxy, icon = R.drawable.ic_tor, tint = MaterialTheme.colors.onBackground, + onLongClick = { + coroutineScope.launch { + scaffoldState.drawerState.close() + } + conectOrbotDialogOpen = true + }, onClick = { if (checked) { disconnectTorDialog = true @@ -401,12 +409,16 @@ fun NavigationRow( }) } +@OptIn(ExperimentalFoundationApi::class) @Composable -fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit) { +fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongClick: (() -> Unit)? = null) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) ) { Row( modifier = Modifier 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 d1d224dbb..f028c9025 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 @@ -66,6 +66,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -87,6 +88,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent @@ -97,6 +99,7 @@ import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.Participant import com.vitorpamplona.amethyst.service.model.PeopleListEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent @@ -113,6 +116,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.VideoView import com.vitorpamplona.amethyst.ui.components.ZoomableContent import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.ZoomableLocalImage @@ -132,6 +136,7 @@ import nostr.postr.toNpub import java.io.File import java.math.BigDecimal import java.net.URL +import java.util.Locale import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @@ -376,6 +381,10 @@ fun NoteComposeInner( RenderPeopleList(noteState, backgroundColor, accountViewModel, navController) } + is AudioTrackEvent -> { + RenderAudioTrack(note, loggedIn, accountViewModel, navController) + } + is PrivateDmEvent -> { RenderPrivateMessage(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, navController) } @@ -868,6 +877,25 @@ private fun RenderRepost( } } +@Composable +private fun RenderAudioTrack( + note: Note, + loggedIn: User, + accountViewModel: AccountViewModel, + navController: NavController +) { + val noteEvent = note.event as? AudioTrackEvent ?: return + + AudioTrackHeader(noteEvent, note, loggedIn, navController) + + ReactionsRow(note, accountViewModel, navController) + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) +} + @Composable private fun RenderLongFormContent( note: Note, @@ -1553,6 +1581,81 @@ fun FileStorageHeaderDisplay(baseNote: Note) { } } +@Composable +fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, loggedIn: User, navController: NavController) { + val media = remember { noteEvent.media() } + val cover = remember { noteEvent.cover() } + val subject = remember { noteEvent.subject() } + val content = remember { noteEvent.content() } + val participants = remember { noteEvent.participants() } + + var participantUsers by remember { mutableStateOf>>(emptyList()) } + + LaunchedEffect(key1 = participants) { + withContext(Dispatchers.IO) { + participantUsers = participants.mapNotNull { part -> LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } } + } + } + + Row(modifier = Modifier.padding(top = 5.dp)) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Row() { + subject?.let { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)) { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + participantUsers.forEach { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 5.dp, start = 10.dp, end = 10.dp).clickable { + navController.navigate("User/${it.second.pubkeyHex}") + } + ) { + UserPicture(it.second, loggedIn, 25.dp) + Spacer(Modifier.width(5.dp)) + UsernameDisplay(it.second, Modifier.weight(1f)) + Spacer(Modifier.width(5.dp)) + it.first.role?.let { + Text( + text = it.capitalize(Locale.ROOT), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1 + ) + } + } + } + + media?.let { media -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(10.dp) + ) { + cover?.let { cover -> + VideoView( + videoUri = media, + description = noteEvent.subject(), + thumbUri = cover + ) + } + ?: VideoView( + videoUri = media, + noteEvent.subject() + ) + } + } + } + } +} + @Composable private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) { Row( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 67517b543..02369406b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -62,6 +62,9 @@ import androidx.core.graphics.ColorUtils import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.AudioTrackEvent +import com.vitorpamplona.amethyst.service.model.FileHeaderEvent +import com.vitorpamplona.amethyst.service.model.PeopleListEvent import com.vitorpamplona.amethyst.ui.components.SelectTextDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog @@ -81,11 +84,19 @@ val externalLinkForNote = { note: Note -> if (note is AddressableNote) { if (note.event?.getReward() != null) { "https://nostrbounties.com/b/${note.address().toNAddr()}" + } else if (note.event is PeopleListEvent) { + "https://listr.lol/a/${note.address()?.toNAddr()}" + } else if (note.event is AudioTrackEvent) { + "https://zapstr.live/?track=${note.address()?.toNAddr()}" } else { - "https://habla.news/a/${note.address().toNAddr()}" + "https://habla.news/a/${note.address()?.toNAddr()}" } } else { - "https://snort.social/e/${note.toNEvent()}" + if (note.event is FileHeaderEvent) { + "https://filestr.vercel.app/e/${note.toNEvent()}" + } else { + "https://snort.social/e/${note.toNEvent()}" + } } } @@ -128,7 +139,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni if (popupExpanded) { val isOwnNote = accountViewModel.isLoggedUser(note.author) - val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!) + val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) Popup(onDismissRequest = onDismiss) { Card( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index d5cbcd80b..12342c292 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.Popup +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -37,7 +38,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.math.BigDecimal import java.util.* import kotlin.math.roundToInt @@ -49,19 +49,45 @@ fun PollNote( accountViewModel: AccountViewModel, navController: NavController ) { - val zapsState by baseNote.live().zaps.observeAsState() - val zappedNote = zapsState?.note ?: return - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return - val pollViewModel = PollNoteViewModel() - pollViewModel.load(account, zappedNote) + val pollViewModel: PollNoteViewModel = viewModel() - pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op -> + LaunchedEffect(key1 = baseNote) { + pollViewModel.load(account, baseNote) + } + + PollNote( + baseNote = baseNote, + pollViewModel = pollViewModel, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + navController = navController + ) +} + +@Composable +fun PollNote( + baseNote: Note, + pollViewModel: PollNoteViewModel, + canPreview: Boolean, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val zapsState by baseNote.live().zaps.observeAsState() + + LaunchedEffect(key1 = zapsState) { + withContext(Dispatchers.IO) { + pollViewModel.refreshTallies() + } + } + + pollViewModel.tallies.forEach { poll_op -> OptionNote( - poll_op.key, - poll_op.value, + poll_op, pollViewModel, baseNote, accountViewModel, @@ -74,8 +100,7 @@ fun PollNote( @Composable private fun OptionNote( - optionNumber: Int, - optionText: String, + poolOption: PollOption, pollViewModel: PollNoteViewModel, baseNote: Note, accountViewModel: AccountViewModel, @@ -88,32 +113,17 @@ private fun OptionNote( modifier = Modifier.padding(vertical = 3.dp) ) { if (!pollViewModel.canZap()) { - val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) - var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) } - - LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) { - withContext(Dispatchers.IO) { - val myTally = pollViewModel.optionVoteTally(optionNumber) - val color = if ( - pollViewModel.consensusThreshold != null && - myTally >= pollViewModel.consensusThreshold!! - ) { - Color.Green.copy(alpha = 0.32f) - } else { - defaultColor - } - - if (myTally > optionTally.first || color != optionTally.second) { - optionTally = Pair(myTally, color) - } - } + val color = if (poolOption.consensusThreadhold) { + Color.Green.copy(alpha = 0.32f) + } else { + MaterialTheme.colors.primary.copy(alpha = 0.32f) } ZapVote( baseNote, + poolOption, accountViewModel, pollViewModel, - optionNumber, nonClickablePrepend = { Box( Modifier @@ -121,14 +131,14 @@ private fun OptionNote( .clip(shape = RoundedCornerShape(15.dp)) .border( 2.dp, - optionTally.second, + color, RoundedCornerShape(15.dp) ) ) { LinearProgressIndicator( modifier = Modifier.matchParentSize(), - color = optionTally.second, - progress = optionTally.first.toFloat() + color = color, + progress = poolOption.tally.toFloat() ) Row( @@ -141,7 +151,7 @@ private fun OptionNote( .width(40.dp) ) { Text( - text = "${(optionTally.first.toFloat() * 100).roundToInt()}%", + text = "${(poolOption.tally.toFloat() * 100).roundToInt()}%", fontWeight = FontWeight.Bold ) } @@ -152,7 +162,7 @@ private fun OptionNote( .padding(15.dp) ) { TranslatableRichTextViewer( - optionText, + poolOption.descriptor, canPreview, Modifier, pollViewModel.pollEvent?.tags(), @@ -170,9 +180,9 @@ private fun OptionNote( } else { ZapVote( baseNote, + poolOption, accountViewModel, pollViewModel, - optionNumber, nonClickablePrepend = {}, clickablePrepend = { Box( @@ -186,7 +196,7 @@ private fun OptionNote( ) ) { TranslatableRichTextViewer( - optionText, + poolOption.descriptor, canPreview, Modifier.padding(15.dp), pollViewModel.pollEvent?.tags(), @@ -205,9 +215,9 @@ private fun OptionNote( @OptIn(ExperimentalFoundationApi::class) fun ZapVote( baseNote: Note, + poolOption: PollOption, accountViewModel: AccountViewModel, pollViewModel: PollNoteViewModel, - pollOption: Int, modifier: Modifier = Modifier, nonClickablePrepend: @Composable () -> Unit, clickablePrepend: @Composable () -> Unit @@ -264,7 +274,7 @@ fun ZapVote( ) .show() } - } else if (pollViewModel.isVoteAmountAtomic() && pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) { + } else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) { // only allow one vote per option when min==max, i.e. atomic vote amount specified scope.launch { Toast @@ -281,7 +291,7 @@ fun ZapVote( accountViewModel.zap( baseNote, account.zapAmountChoices.first() * 1000, - pollOption, + poolOption.option, "", context, onError = { @@ -311,7 +321,7 @@ fun ZapVote( baseNote, accountViewModel, pollViewModel, - pollOption, + poolOption.option, onDismiss = { wantsToZap = false zappingProgress = 0f @@ -335,17 +345,7 @@ fun ZapVote( clickablePrepend() - var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = zapsState) { - withContext(Dispatchers.IO) { - if (!optionWasZappedByLoggedInUser) { - optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile()) - } - } - } - - if (optionWasZappedByLoggedInUser) { + if (poolOption.zappedByLoggedIn) { zappingProgress = 1f Icon( imageVector = Icons.Default.Bolt, @@ -372,20 +372,10 @@ fun ZapVote( } } - var wasZappedByLoggedInUser by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = zapsState) { - withContext(Dispatchers.IO) { - if (!wasZappedByLoggedInUser) { - wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote) - } - } - } - // only show tallies after a user has zapped note - if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) { + if (baseNote.author == accountViewModel.userProfile() || pollViewModel.wasZappedByLoggedInAccount) { Text( - showAmount(pollViewModel.zappedPollOptionAmount(pollOption)), + showAmount(poolOption.zappedValue), fontSize = 14.sp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = modifier diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index 73c273806..fa142446b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -1,5 +1,9 @@ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -8,19 +12,30 @@ import java.math.BigDecimal import java.math.RoundingMode import java.util.* -class PollNoteViewModel { +data class PollOption( + val option: Int, + val descriptor: String, + val zappedValue: BigDecimal, + val tally: BigDecimal, + val consensusThreadhold: Boolean, + val zappedByLoggedIn: Boolean +) + +class PollNoteViewModel : ViewModel() { var account: Account? = null private var pollNote: Note? = null var pollEvent: PollNoteEvent? = null - private var pollOptions: Map? = null + var pollOptions: Map? = null var valueMaximum: Int? = null var valueMinimum: Int? = null private var closedAt: Int? = null var consensusThreshold: BigDecimal? = null var totalZapped: BigDecimal = BigDecimal.ZERO - var wasZappedByAuthor: Boolean = false + var wasZappedByLoggedInAccount: Boolean = false + + var tallies by mutableStateOf>(emptyList()) fun load(acc: Account, note: Note?) { account = acc @@ -32,15 +47,35 @@ class PollNoteViewModel { consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal() closedAt = pollEvent?.getTagInt(CLOSED_AT) + refreshTallies() + } + + fun refreshTallies() { totalZapped = totalZapped() - wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false + wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false + + tallies = pollOptions?.keys?.map { + val zappedInOption = zappedPollOptionAmount(it) + + val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { + zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP) + } else { + BigDecimal.ZERO + } + + val zappedByLoggedIn = account?.userProfile()?.let { it1 -> isPollOptionZappedBy(it, it1) } ?: false + + val consensus = consensusThreshold != null && myTally >= consensusThreshold!! + + PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn) + } ?: emptyList() } fun canZap(): Boolean { val account = account ?: return false val user = account.userProfile() ?: return false val note = pollNote ?: return false - return user != note.author && !wasZappedByAuthor + return user != note.author && !wasZappedByLoggedInAccount } fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum @@ -88,14 +123,6 @@ class PollNoteViewModel { return false } - fun optionVoteTally(op: Int): BigDecimal { - return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { - zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP) - } else { - BigDecimal.ZERO - } - } - fun isPollOptionZappedBy(option: Int, user: User): Boolean { return pollNote!!.zaps .any { @@ -105,7 +132,7 @@ class PollNoteViewModel { } } - fun zappedPollOptionAmount(option: Int): BigDecimal { + private fun zappedPollOptionAmount(option: Int): BigDecimal { return pollNote?.zaps?.values?.sumOf { val event = it?.event as? LnZapEvent if (event?.zappedPollOption() == option) { @@ -116,7 +143,7 @@ class PollNoteViewModel { } ?: BigDecimal(0) } - fun totalZapped(): BigDecimal { + private fun totalZapped(): BigDecimal { return pollNote?.zaps?.values?.sumOf { val zapEvent = (it?.event as? LnZapEvent) 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 63fb4d985..b0909124e 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 @@ -53,6 +53,7 @@ import androidx.navigation.NavController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent @@ -352,6 +353,8 @@ fun NoteMaster( Column() { if (noteEvent is PeopleListEvent) { DisplayPeopleList(noteState, MaterialTheme.colors.background, accountViewModel, navController) + } else if (noteEvent is AudioTrackEvent) { + AudioTrackHeader(noteEvent, note, account.userProfile(), navController) } else if (noteEvent is HighlightEvent) { DisplayHighlight( noteEvent.quote(), 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 0a4bdedd1..9cd24bf61 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 @@ -335,7 +335,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont } } -val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([A-Za-z0-9_\\-]+)") +val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)") fun findHashtags(content: String): List { val matcher = hashtagSearch.matcher(content) diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 4c6e17a6c..8e55bfcca 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -48,7 +49,7 @@ fun TranslatableRichTextViewer( accountViewModel: AccountViewModel, navController: NavController ) { - val translatedTextState = remember { + var translatedTextState by remember { mutableStateOf(ResultOrError(content, null, null, null)) } @@ -56,7 +57,7 @@ fun TranslatableRichTextViewer( var langSettingsPopupExpanded by remember { mutableStateOf(false) } val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return val scope = rememberCoroutineScope() @@ -72,13 +73,17 @@ fun TranslatableRichTextViewer( val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!) showOriginal = preference == task.result.sourceLang } - translatedTextState.value = task.result + translatedTextState = task.result } } } } - val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content + val toBeViewed by remember { + derivedStateOf { + if (showOriginal) content else translatedTextState.result ?: content + } + } Column() { ExpandableRichTextViewer( @@ -91,8 +96,8 @@ fun TranslatableRichTextViewer( navController ) - val target = translatedTextState.value.targetLang - val source = translatedTextState.value.sourceLang + val target = translatedTextState.targetLang + val source = translatedTextState.sourceLang if (source != null && target != null) { if (source != target) {