Merge branch 'main' into main

This commit is contained in:
Pextar
2023-05-15 20:07:43 +02:00
committed by GitHub
38 changed files with 477 additions and 176 deletions
+1 -1
View File
@@ -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. 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. 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.
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 33 targetSdk 33
versionCode 155 versionCode 161
versionName "0.46.1" versionName "0.47.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -281,6 +281,18 @@ object LocalCache {
refreshObservers(note) 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) { fun consume(event: BadgeDefinitionEvent) {
val note = getOrCreateAddressableNote(event.address()) val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey) val author = getOrCreateUser(event.pubKey)
@@ -920,6 +932,7 @@ object LocalCache {
try { try {
when (event) { when (event) {
is AudioTrackEvent -> consume(event)
is BadgeAwardEvent -> consume(event) is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event) is BadgeDefinitionEvent -> consume(event)
is BadgeProfilesEvent -> consume(event) is BadgeProfilesEvent -> consume(event)
@@ -411,9 +411,9 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) { class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) { private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) { // if (hasObservers()) {
postValue(NoteState(note)) postValue(NoteState(note))
} // }
} }
fun invalidateData() { fun invalidateData() {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service 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.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
@@ -13,7 +14,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter( fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL), types = setOf(FeedType.GLOBAL),
filter = JsonFilter( 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 limit = 200
) )
) )
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent 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.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -25,7 +26,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
hashToLoad.capitalize() hashToLoad.capitalize()
) )
), ),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
limit = 200 limit = 200
) )
) )
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState 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.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent
@@ -56,7 +57,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter( return TypedFilter(
types = setOf(FeedType.FOLLOWS), types = setOf(FeedType.FOLLOWS),
filter = JsonFilter( 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, authors = followSet,
limit = 400, limit = 400,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
@@ -72,7 +73,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter( return TypedFilter(
types = setOf(FeedType.FOLLOWS), types = setOf(FeedType.FOLLOWS),
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind),
tags = mapOf( tags = mapOf(
"t" to hashToLoad.map { "t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
@@ -29,7 +29,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind, BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind PollNoteEvent.kind, AudioTrackEvent.kind
), ),
tags = mapOf("a" to listOf(aTag.toTag())), tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime since = it.lastReactionsDownloadTime
@@ -80,7 +80,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
LnZapEvent.kind, LnZapEvent.kind,
LnZapRequestEvent.kind, LnZapRequestEvent.kind,
PollNoteEvent.kind, PollNoteEvent.kind,
HighlightEvent.kind HighlightEvent.kind,
AudioTrackEvent.kind
), ),
tags = mapOf("e" to listOf(it.idHex)), tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime since = it.lastReactionsDownloadTime
@@ -119,7 +120,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind, PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind, FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind HighlightEvent.kind, AudioTrackEvent.kind
), ),
ids = interestedEvents.toList() ids = interestedEvents.toList()
) )
@@ -28,7 +28,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter( TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = JsonFilter( 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), authors = listOf(it.pubkeyHex),
limit = 200 limit = 200
) )
@@ -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<List<String>>,
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?)
@@ -9,9 +9,9 @@ class BadgeDefinitionEvent(
tags: List<List<String>>, tags: List<List<String>>,
content: String, content: String,
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
fun address() = ATag(kind, pubKey, dTag(), null) override fun address() = ATag(kind, pubKey, dTag(), null)
fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1) fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)
@@ -9,7 +9,7 @@ class BadgeProfilesEvent(
tags: List<List<String>>, tags: List<List<String>>,
content: String, content: String,
sig: HexKey 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 badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1) val aTagValue = it.getOrNull(1)
@@ -18,8 +18,8 @@ class BadgeProfilesEvent(
if (aTagValue != null) ATag.parse(aTagValue, relay) else null if (aTagValue != null) ATag.parse(aTagValue, relay) else null
} }
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null) override fun address() = ATag(kind, pubKey, dTag(), null)
companion object { companion object {
const val kind = 30008 const val kind = 30008
@@ -220,6 +220,7 @@ open class Event(
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient) 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) { 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) BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.kind -> BadgeProfilesEvent(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
}
@@ -14,9 +14,9 @@ abstract class GeneralListEvent(
tags: List<List<String>>, tags: List<List<String>>,
content: String, content: String,
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null) override fun address() = ATag(kind, pubKey, dTag(), null)
fun category() = dTag() fun category() = dTag()
fun bookmarkedPosts() = taggedEvents() fun bookmarkedPosts() = taggedEvents()
@@ -71,7 +71,7 @@ class LnZapRequestEvent(
listOf("p", originalNote.pubKey()), listOf("p", originalNote.pubKey()),
listOf("relays") + relays listOf("relays") + relays
) )
if (originalNote is LongTextNoteEvent) { if (originalNote is AddressableEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag())) tags = tags + listOf(listOf("a", originalNote.address().toTag()))
} }
if (pollOption != null && pollOption >= 0) { if (pollOption != null && pollOption >= 0) {
@@ -12,9 +12,9 @@ class LongTextNoteEvent(
tags: List<List<String>>, tags: List<List<String>>,
content: String, content: String,
sig: HexKey sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { ) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null) override fun address() = ATag(kind, pubKey, dTag(), null)
fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull() fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
@@ -15,9 +15,9 @@ class MuteListEvent(
tags: List<List<String>>, tags: List<List<String>>,
content: String, content: String,
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null) override fun address() = ATag(kind, pubKey, dTag(), null)
fun plainContent(privKey: ByteArray): String? { fun plainContent(privKey: ByteArray): String? {
return try { return try {
@@ -32,7 +32,7 @@ class ReactionEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey())) 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())) tags = tags + listOf(listOf("a", originalNote.address().toTag()))
} }
@@ -63,7 +63,7 @@ class ReportEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = listOf(reportPostTag, reportAuthorTag) var tags: List<List<String>> = listOf(reportPostTag, reportAuthorTag)
if (reportedPost is LongTextNoteEvent) { if (reportedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", reportedPost.address().toTag())) tags = tags + listOf(listOf("a", reportedPost.address().toTag()))
} }
@@ -36,7 +36,7 @@ class RepostEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor)) var tags: List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor))
if (boostedPost is LongTextNoteEvent) { if (boostedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", boostedPost.address().toTag())) tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
} }
@@ -46,7 +46,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val user = note.author?.toBestDisplayName() ?: "" val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture() val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent() 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 userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications" val noteUri = "nostr:Notifications"
notificationManager().sendZapNotification(content, title, userPicture, noteUri, applicationContext) notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
} }
} }
} }
@@ -15,10 +15,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.MainActivity
object NotificationUtils { object NotificationUtils {
// Notification ID.
private var notificationId = 0
private var dmChannel: NotificationChannel? = null private var dmChannel: NotificationChannel? = null
private var zapChannel: NotificationChannel? = null private var zapChannel: NotificationChannel? = null
@@ -63,6 +59,7 @@ object NotificationUtils {
} }
fun NotificationManager.sendZapNotification( fun NotificationManager.sendZapNotification(
id: String,
messageBody: String, messageBody: String,
messageTitle: String, messageTitle: String,
pictureUrl: String?, pictureUrl: String?,
@@ -72,10 +69,11 @@ object NotificationUtils {
val zapChannel = getOrCreateZapChannel(applicationContext) val zapChannel = getOrCreateZapChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id) 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( fun NotificationManager.sendDMNotification(
id: String,
messageBody: String, messageBody: String,
messageTitle: String, messageTitle: String,
pictureUrl: String?, pictureUrl: String?,
@@ -85,10 +83,11 @@ object NotificationUtils {
val dmChannel = getOrCreateDMChannel(applicationContext) val dmChannel = getOrCreateDMChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id) 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( fun NotificationManager.sendNotification(
id: String,
messageBody: String, messageBody: String,
messageTitle: String, messageTitle: String,
pictureUrl: String?, pictureUrl: String?,
@@ -104,6 +103,7 @@ object NotificationUtils {
val imageLoader = ImageLoader(applicationContext) val imageLoader = ImageLoader(applicationContext)
val imageResult = imageLoader.executeBlocking(request) val imageResult = imageLoader.executeBlocking(request)
sendNotification( sendNotification(
id = id,
messageBody = messageBody, messageBody = messageBody,
messageTitle = messageTitle, messageTitle = messageTitle,
picture = imageResult.drawable as? BitmapDrawable, picture = imageResult.drawable as? BitmapDrawable,
@@ -113,6 +113,7 @@ object NotificationUtils {
) )
} else { } else {
sendNotification( sendNotification(
id = id,
messageBody = messageBody, messageBody = messageBody,
messageTitle = messageTitle, messageTitle = messageTitle,
picture = null, picture = null,
@@ -124,6 +125,7 @@ object NotificationUtils {
} }
private fun NotificationManager.sendNotification( private fun NotificationManager.sendNotification(
id: String,
messageBody: String, messageBody: String,
messageTitle: String, messageTitle: String,
picture: BitmapDrawable?, picture: BitmapDrawable?,
@@ -137,7 +139,7 @@ object NotificationUtils {
val contentPendingIntent = PendingIntent.getActivity( val contentPendingIntent = PendingIntent.getActivity(
applicationContext, applicationContext,
notificationId, id.hashCode(),
contentIntent, contentIntent,
PendingIntent.FLAG_MUTABLE PendingIntent.FLAG_MUTABLE
) )
@@ -171,9 +173,7 @@ object NotificationUtils {
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setAutoCancel(true)
notify(notificationId, builder.build()) notify(id.hashCode(), builder.build())
notificationId++
} }
/** /**
@@ -71,9 +71,9 @@ class BundledInsert<T>(
delay(delay) delay(delay)
mySet.clear() val mySet2 = mutableSetOf<T>()
queue.drainTo(mySet) queue.drainTo(mySet2)
onUpdate(mySet) onUpdate(mySet2)
} finally { } finally {
withContext(NonCancellable) { withContext(NonCancellable) {
onlyOneInBlock.set(false) onlyOneInBlock.set(false)
@@ -13,6 +13,7 @@ import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -50,10 +51,14 @@ fun ExpandableRichTextViewer(
minOf(firstSpaceAfterCut, firstNewLineAfterCut) minOf(firstSpaceAfterCut, firstNewLineAfterCut)
} }
val text = if (showFullText) { val text by remember(content) {
content derivedStateOf {
} else { if (showFullText) {
content.take(whereToCut) content
} else {
content.take(whereToCut)
}
}
} }
Box { Box {
@@ -50,7 +50,6 @@ import java.net.MalformedURLException
import java.net.URISyntaxException import java.net.URISyntaxException
import java.net.URL import java.net.URL
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.time.ExperimentalTime
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3") val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
@@ -113,7 +112,6 @@ class RichTextViewerState(
val imageList: List<ZoomableUrlContent> val imageList: List<ZoomableUrlContent>
) )
@OptIn(ExperimentalTime::class)
@Composable @Composable
private fun RenderRegular( private fun RenderRegular(
content: String, content: String,
@@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -41,13 +42,15 @@ fun UrlPreviewCard(
) )
) { ) {
Column { Column {
val validatedUrl = URL(previewInfo.url) val validatedUrl = remember { URL(previewInfo.url) }
// correctly treating relative images // correctly treating relative images
val imageUrl = if (previewInfo.image.startsWith("/")) { val imageUrl = remember {
URL(validatedUrl, previewInfo.image).toString() if (previewInfo.image.startsWith("/")) {
} else { URL(validatedUrl, previewInfo.image).toString()
previewInfo.image } else {
previewInfo.image
}
} }
AsyncImage( AsyncImage(
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
@@ -28,6 +30,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -46,6 +49,8 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import coil.imageLoader
import coil.request.ImageRequest
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem 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.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView import com.google.android.exoplayer2.ui.StyledPlayerView
import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.VideoCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File import java.io.File
public var muted = mutableStateOf(true) public var muted = mutableStateOf(true)
@@ -62,20 +69,55 @@ public var muted = mutableStateOf(true)
@Composable @Composable
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
if (localFile != null) { if (localFile != null) {
VideoView(localFile.toUri(), description, onDialog) VideoView(localFile.toUri(), description, null, onDialog)
} }
} }
@Composable @Composable
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { 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 @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<Boolean, Drawable?>>(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 context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
println("loading audio with artwork $thumb")
val exoPlayer = remember(videoUri) { val exoPlayer = remember(videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(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 { ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL 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 volume = 0f
if (videoUri.scheme?.startsWith("file") == true) { if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media) setMediaItem(media)
@@ -116,9 +158,9 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
.defaultMinSize(minHeight = 70.dp) .defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center) .align(Alignment.Center)
.onVisibilityChanges { visible -> .onVisibilityChanges { visible ->
if (visible) { if (visible && !exoPlayer.isPlaying) {
exoPlayer.play() exoPlayer.play()
} else { } else if (!visible && exoPlayer.isPlaying) {
exoPlayer.pause() exoPlayer.pause()
} }
}, },
@@ -130,6 +172,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
) )
controllerAutoShow = false controllerAutoShow = false
thumb?.let { defaultArtwork = thumb }
hideController() hideController()
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog -> onDialog?.let { innerOnDialog ->
@@ -146,7 +189,6 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
muted.value = !muted.value muted.value = !muted.value
} }
} }
) { ) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
when (event) { when (event) {
@@ -175,7 +217,10 @@ fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifi
} }
onGloballyPositioned { coordinates -> onGloballyPositioned { coordinates ->
isVisible = coordinates.isCompletelyVisible(view) val newIsVisible = coordinates.isCompletelyVisible(view)
if (isVisible != newIsVisible) {
isVisible = newIsVisible
}
} }
} }
@@ -17,9 +17,11 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -209,17 +211,21 @@ private fun LocalImageView(
mutableStateOf<AsyncImagePainter.State?>(null) mutableStateOf<AsyncImagePainter.State?>(null)
} }
val ratio = remember {
aspectRatio(content.dim)
}
BoxWithConstraints(contentAlignment = Alignment.Center) { BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.also { val myModifier = remember {
if (ratio != null) { mainImageModifier
it.aspectRatio(ratio, maxHeight.isFinite) .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()) { if (content.localFile != null && content.localFile.exists()) {
AsyncImage( AsyncImage(
@@ -280,12 +286,20 @@ private fun UrlImageView(
} }
BoxWithConstraints(contentAlignment = Alignment.Center) { BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.run { val myModifier = remember {
aspectRatio(content.dim)?.let { ratio -> mainImageModifier
this.aspectRatio(ratio, maxHeight.isFinite) .widthIn(max = maxWidth)
} ?: this .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( AsyncImage(
model = content.url, model = content.url,
@@ -27,7 +27,7 @@ object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
return collection return collection
.asSequence() .asSequence()
.filter { .filter {
it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty() (it.event is BaseTextNoteEvent || it.event is AudioTrackEvent) && it.replyTo.isNullOrEmpty()
} }
.filter { .filter {
val channel = it.channelHex() val channel = it.channelHex()
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note 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.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent
@@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
return collection return collection
.asSequence() .asSequence()
.filter { it -> .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)) && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true && it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
@@ -1,9 +1,11 @@
package com.vitorpamplona.amethyst.ui.navigation package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -294,6 +296,12 @@ fun ListContent(
title = textTorProxy, title = textTorProxy,
icon = R.drawable.ic_tor, icon = R.drawable.ic_tor,
tint = MaterialTheme.colors.onBackground, tint = MaterialTheme.colors.onBackground,
onLongClick = {
coroutineScope.launch {
scaffoldState.drawerState.close()
}
conectOrbotDialogOpen = true
},
onClick = { onClick = {
if (checked) { if (checked) {
disconnectTorDialog = true disconnectTorDialog = true
@@ -401,12 +409,16 @@ fun NavigationRow(
}) })
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -66,6 +66,7 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.Note
import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User 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.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent 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.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent 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.PeopleListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent 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.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer 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.ZoomableContent
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.ZoomableLocalImage import com.vitorpamplona.amethyst.ui.components.ZoomableLocalImage
@@ -132,6 +136,7 @@ import nostr.postr.toNpub
import java.io.File import java.io.File
import java.math.BigDecimal import java.math.BigDecimal
import java.net.URL import java.net.URL
import java.util.Locale
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
@@ -376,6 +381,10 @@ fun NoteComposeInner(
RenderPeopleList(noteState, backgroundColor, accountViewModel, navController) RenderPeopleList(noteState, backgroundColor, accountViewModel, navController)
} }
is AudioTrackEvent -> {
RenderAudioTrack(note, loggedIn, accountViewModel, navController)
}
is PrivateDmEvent -> { is PrivateDmEvent -> {
RenderPrivateMessage(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, navController) 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 @Composable
private fun RenderLongFormContent( private fun RenderLongFormContent(
note: Note, 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<List<Pair<Participant, User>>>(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 @Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) { private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row( Row(
@@ -62,6 +62,9 @@ import androidx.core.graphics.ColorUtils
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note 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.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
@@ -81,11 +84,19 @@ val externalLinkForNote = { note: Note ->
if (note is AddressableNote) { if (note is AddressableNote) {
if (note.event?.getReward() != null) { if (note.event?.getReward() != null) {
"https://nostrbounties.com/b/${note.address().toNAddr()}" "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 { } else {
"https://habla.news/a/${note.address().toNAddr()}" "https://habla.news/a/${note.address()?.toNAddr()}"
} }
} else { } 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) { if (popupExpanded) {
val isOwnNote = accountViewModel.isLoggedUser(note.author) val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!) val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
Popup(onDismissRequest = onDismiss) { Popup(onDismissRequest = onDismiss) {
Card( Card(
@@ -28,6 +28,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
@@ -37,7 +38,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.* import java.util.*
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -49,19 +49,45 @@ fun PollNote(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
navController: NavController navController: NavController
) { ) {
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note ?: return
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return val account = remember(accountState) { accountState?.account } ?: return
val pollViewModel = PollNoteViewModel() val pollViewModel: PollNoteViewModel = viewModel()
pollViewModel.load(account, zappedNote)
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( OptionNote(
poll_op.key, poll_op,
poll_op.value,
pollViewModel, pollViewModel,
baseNote, baseNote,
accountViewModel, accountViewModel,
@@ -74,8 +100,7 @@ fun PollNote(
@Composable @Composable
private fun OptionNote( private fun OptionNote(
optionNumber: Int, poolOption: PollOption,
optionText: String,
pollViewModel: PollNoteViewModel, pollViewModel: PollNoteViewModel,
baseNote: Note, baseNote: Note,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -88,32 +113,17 @@ private fun OptionNote(
modifier = Modifier.padding(vertical = 3.dp) modifier = Modifier.padding(vertical = 3.dp)
) { ) {
if (!pollViewModel.canZap()) { if (!pollViewModel.canZap()) {
val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) val color = if (poolOption.consensusThreadhold) {
var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) } Color.Green.copy(alpha = 0.32f)
} else {
LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) { MaterialTheme.colors.primary.copy(alpha = 0.32f)
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)
}
}
} }
ZapVote( ZapVote(
baseNote, baseNote,
poolOption,
accountViewModel, accountViewModel,
pollViewModel, pollViewModel,
optionNumber,
nonClickablePrepend = { nonClickablePrepend = {
Box( Box(
Modifier Modifier
@@ -121,14 +131,14 @@ private fun OptionNote(
.clip(shape = RoundedCornerShape(15.dp)) .clip(shape = RoundedCornerShape(15.dp))
.border( .border(
2.dp, 2.dp,
optionTally.second, color,
RoundedCornerShape(15.dp) RoundedCornerShape(15.dp)
) )
) { ) {
LinearProgressIndicator( LinearProgressIndicator(
modifier = Modifier.matchParentSize(), modifier = Modifier.matchParentSize(),
color = optionTally.second, color = color,
progress = optionTally.first.toFloat() progress = poolOption.tally.toFloat()
) )
Row( Row(
@@ -141,7 +151,7 @@ private fun OptionNote(
.width(40.dp) .width(40.dp)
) { ) {
Text( Text(
text = "${(optionTally.first.toFloat() * 100).roundToInt()}%", text = "${(poolOption.tally.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
@@ -152,7 +162,7 @@ private fun OptionNote(
.padding(15.dp) .padding(15.dp)
) { ) {
TranslatableRichTextViewer( TranslatableRichTextViewer(
optionText, poolOption.descriptor,
canPreview, canPreview,
Modifier, Modifier,
pollViewModel.pollEvent?.tags(), pollViewModel.pollEvent?.tags(),
@@ -170,9 +180,9 @@ private fun OptionNote(
} else { } else {
ZapVote( ZapVote(
baseNote, baseNote,
poolOption,
accountViewModel, accountViewModel,
pollViewModel, pollViewModel,
optionNumber,
nonClickablePrepend = {}, nonClickablePrepend = {},
clickablePrepend = { clickablePrepend = {
Box( Box(
@@ -186,7 +196,7 @@ private fun OptionNote(
) )
) { ) {
TranslatableRichTextViewer( TranslatableRichTextViewer(
optionText, poolOption.descriptor,
canPreview, canPreview,
Modifier.padding(15.dp), Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(), pollViewModel.pollEvent?.tags(),
@@ -205,9 +215,9 @@ private fun OptionNote(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
fun ZapVote( fun ZapVote(
baseNote: Note, baseNote: Note,
poolOption: PollOption,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
pollViewModel: PollNoteViewModel, pollViewModel: PollNoteViewModel,
pollOption: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
nonClickablePrepend: @Composable () -> Unit, nonClickablePrepend: @Composable () -> Unit,
clickablePrepend: @Composable () -> Unit clickablePrepend: @Composable () -> Unit
@@ -264,7 +274,7 @@ fun ZapVote(
) )
.show() .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 // only allow one vote per option when min==max, i.e. atomic vote amount specified
scope.launch { scope.launch {
Toast Toast
@@ -281,7 +291,7 @@ fun ZapVote(
accountViewModel.zap( accountViewModel.zap(
baseNote, baseNote,
account.zapAmountChoices.first() * 1000, account.zapAmountChoices.first() * 1000,
pollOption, poolOption.option,
"", "",
context, context,
onError = { onError = {
@@ -311,7 +321,7 @@ fun ZapVote(
baseNote, baseNote,
accountViewModel, accountViewModel,
pollViewModel, pollViewModel,
pollOption, poolOption.option,
onDismiss = { onDismiss = {
wantsToZap = false wantsToZap = false
zappingProgress = 0f zappingProgress = 0f
@@ -335,17 +345,7 @@ fun ZapVote(
clickablePrepend() clickablePrepend()
var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) } if (poolOption.zappedByLoggedIn) {
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!optionWasZappedByLoggedInUser) {
optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())
}
}
}
if (optionWasZappedByLoggedInUser) {
zappingProgress = 1f zappingProgress = 1f
Icon( Icon(
imageVector = Icons.Default.Bolt, 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 // only show tallies after a user has zapped note
if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) { if (baseNote.author == accountViewModel.userProfile() || pollViewModel.wasZappedByLoggedInAccount) {
Text( Text(
showAmount(pollViewModel.zappedPollOptionAmount(pollOption)), showAmount(poolOption.zappedValue),
fontSize = 14.sp, fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = modifier modifier = modifier
@@ -1,5 +1,9 @@
package com.vitorpamplona.amethyst.ui.note 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.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
@@ -8,19 +12,30 @@ import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import java.util.* 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 var account: Account? = null
private var pollNote: Note? = null private var pollNote: Note? = null
var pollEvent: PollNoteEvent? = null var pollEvent: PollNoteEvent? = null
private var pollOptions: Map<Int, String>? = null var pollOptions: Map<Int, String>? = null
var valueMaximum: Int? = null var valueMaximum: Int? = null
var valueMinimum: Int? = null var valueMinimum: Int? = null
private var closedAt: Int? = null private var closedAt: Int? = null
var consensusThreshold: BigDecimal? = null var consensusThreshold: BigDecimal? = null
var totalZapped: BigDecimal = BigDecimal.ZERO var totalZapped: BigDecimal = BigDecimal.ZERO
var wasZappedByAuthor: Boolean = false var wasZappedByLoggedInAccount: Boolean = false
var tallies by mutableStateOf<List<PollOption>>(emptyList())
fun load(acc: Account, note: Note?) { fun load(acc: Account, note: Note?) {
account = acc account = acc
@@ -32,15 +47,35 @@ class PollNoteViewModel {
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal() consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
closedAt = pollEvent?.getTagInt(CLOSED_AT) closedAt = pollEvent?.getTagInt(CLOSED_AT)
refreshTallies()
}
fun refreshTallies() {
totalZapped = totalZapped() 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 { fun canZap(): Boolean {
val account = account ?: return false val account = account ?: return false
val user = account.userProfile() ?: return false val user = account.userProfile() ?: return false
val note = pollNote ?: 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 fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
@@ -88,14 +123,6 @@ class PollNoteViewModel {
return false 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 { fun isPollOptionZappedBy(option: Int, user: User): Boolean {
return pollNote!!.zaps return pollNote!!.zaps
.any { .any {
@@ -105,7 +132,7 @@ class PollNoteViewModel {
} }
} }
fun zappedPollOptionAmount(option: Int): BigDecimal { private fun zappedPollOptionAmount(option: Int): BigDecimal {
return pollNote?.zaps?.values?.sumOf { return pollNote?.zaps?.values?.sumOf {
val event = it?.event as? LnZapEvent val event = it?.event as? LnZapEvent
if (event?.zappedPollOption() == option) { if (event?.zappedPollOption() == option) {
@@ -116,7 +143,7 @@ class PollNoteViewModel {
} ?: BigDecimal(0) } ?: BigDecimal(0)
} }
fun totalZapped(): BigDecimal { private fun totalZapped(): BigDecimal {
return pollNote?.zaps?.values?.sumOf { return pollNote?.zaps?.values?.sumOf {
val zapEvent = (it?.event as? LnZapEvent) val zapEvent = (it?.event as? LnZapEvent)
@@ -53,6 +53,7 @@ import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note 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.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
@@ -352,6 +353,8 @@ fun NoteMaster(
Column() { Column() {
if (noteEvent is PeopleListEvent) { if (noteEvent is PeopleListEvent) {
DisplayPeopleList(noteState, MaterialTheme.colors.background, accountViewModel, navController) DisplayPeopleList(noteState, MaterialTheme.colors.background, accountViewModel, navController)
} else if (noteEvent is AudioTrackEvent) {
AudioTrackHeader(noteEvent, note, account.userProfile(), navController)
} else if (noteEvent is HighlightEvent) { } else if (noteEvent is HighlightEvent) {
DisplayHighlight( DisplayHighlight(
noteEvent.quote(), noteEvent.quote(),
@@ -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<String> { fun findHashtags(content: String): List<String> {
val matcher = hashtagSearch.matcher(content) val matcher = hashtagSearch.matcher(content)
@@ -14,6 +14,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -48,7 +49,7 @@ fun TranslatableRichTextViewer(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
navController: NavController navController: NavController
) { ) {
val translatedTextState = remember { var translatedTextState by remember {
mutableStateOf(ResultOrError(content, null, null, null)) mutableStateOf(ResultOrError(content, null, null, null))
} }
@@ -56,7 +57,7 @@ fun TranslatableRichTextViewer(
var langSettingsPopupExpanded by remember { mutableStateOf(false) } var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = accountState?.account ?: return val account = remember(accountState) { accountState?.account } ?: return
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -72,13 +73,17 @@ fun TranslatableRichTextViewer(
val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!) val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!)
showOriginal = preference == task.result.sourceLang 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() { Column() {
ExpandableRichTextViewer( ExpandableRichTextViewer(
@@ -91,8 +96,8 @@ fun TranslatableRichTextViewer(
navController navController
) )
val target = translatedTextState.value.targetLang val target = translatedTextState.targetLang
val source = translatedTextState.value.sourceLang val source = translatedTextState.sourceLang
if (source != null && target != null) { if (source != null && target != null) {
if (source != target) { if (source != target) {