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
@@ -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)
@@ -411,9 +411,9 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(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() {
@@ -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
)
)
@@ -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
)
)
@@ -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())
@@ -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()
)
@@ -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
)
@@ -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>>,
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)
@@ -9,7 +9,7 @@ class BadgeProfilesEvent(
tags: List<List<String>>,
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
@@ -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
}
@@ -14,9 +14,9 @@ abstract class GeneralListEvent(
tags: List<List<String>>,
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()
@@ -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) {
@@ -12,9 +12,9 @@ class LongTextNoteEvent(
tags: List<List<String>>,
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()
@@ -15,9 +15,9 @@ class MuteListEvent(
tags: List<List<String>>,
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 {
@@ -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()))
}
@@ -63,7 +63,7 @@ class ReportEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = listOf(reportPostTag, reportAuthorTag)
if (reportedPost is LongTextNoteEvent) {
if (reportedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", reportedPost.address().toTag()))
}
@@ -36,7 +36,7 @@ class RepostEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
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()))
}
@@ -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)
}
}
}
@@ -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())
}
/**
@@ -71,9 +71,9 @@ class BundledInsert<T>(
delay(delay)
mySet.clear()
queue.drainTo(mySet)
onUpdate(mySet)
val mySet2 = mutableSetOf<T>()
queue.drainTo(mySet2)
onUpdate(mySet2)
} finally {
withContext(NonCancellable) {
onlyOneInBlock.set(false)
@@ -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 {
@@ -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<ZoomableUrlContent>
)
@OptIn(ExperimentalTime::class)
@Composable
private fun RenderRegular(
content: String,
@@ -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(
@@ -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<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 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
}
}
}
@@ -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<AsyncImagePainter.State?>(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,
@@ -27,7 +27,7 @@ object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
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()
@@ -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<Note>() {
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 &&
@@ -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
@@ -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<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
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row(
@@ -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(
@@ -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
@@ -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<Int, String>? = null
var pollOptions: Map<Int, String>? = 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<List<PollOption>>(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)
@@ -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(),
@@ -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> {
val matcher = hashtagSearch.matcher(content)