diff --git a/PRIVACY.md b/PRIVACY.md index 7c481a0c4d..50504e609e 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 29060a9147..a1cf46a63f 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 7f7800a058..ed3e9d0cf5 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 c391efeb60..e8920e69f1 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 ebf3425dbb..735036304b 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 e4233c1067..9b278e09c1 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 d831512057..a4277b5766 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 e1b663965f..41edd2886b 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 49d4e2bc46..2246c7c805 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 0000000000..82dc30aa0f --- /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 42f5598775..5e8be53504 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 4995c413ba..f616bb7a44 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 6747b1f13e..38a99ac765 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 cbb8b2facb..57cadcd66c 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 8586849a6a..eeb2bb81a5 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 46f136248b..529c96fd3b 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 a9f639a367..9774888362 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 13188520c4..91b34d4e72 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 d3bd9dd97a..b05e55870c 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 d5e0f1eb91..c00c67617e 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 fd06198ae7..03be8b972a 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 d8d5d5d0b7..36a5bcb847 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 05651dcbc4..2a1036fb9b 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 ba0d79464f..8d622093f6 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 17d165a3d1..a833e977da 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 561d86ab3c..88de0195be 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 7a6441fcbe..909b366a71 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 bdecb38e27..055f31624a 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 aa37f11abf..e048b2e356 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 fcd1750209..055513180e 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 a25d4c8e2c..8b0501619f 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 d1d224dbb3..f028c90253 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 67517b5438..02369406b0 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 d5cbcd80b1..12342c2925 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 73c2738062..fa142446ba 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 63fb4d985e..b0909124e2 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 0a4bdedd13..9cd24bf61a 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 4c6e17a6ce..8e55bfcca1 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) {