Merge remote-tracking branch 'upstream/main' into hashtags_release

This commit is contained in:
Believethehype
2023-04-22 20:04:04 +02:00
84 changed files with 1971 additions and 814 deletions
+2 -2
View File
@@ -14,10 +14,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up JDK 11 - name: Set up JDK 17
uses: actions/setup-java@v1 uses: actions/setup-java@v1
with: with:
java-version: 11 java-version: 17
- name: Cache gradle - name: Cache gradle
uses: actions/cache@v1 uses: actions/cache@v1
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up JDK 11 - name: Set up JDK 17
uses: actions/setup-java@v1 uses: actions/setup-java@v1
with: with:
java-version: 11 java-version: 17
- name: Cache gradle - name: Cache gradle
uses: actions/cache@v1 uses: actions/cache@v1
+1 -1
View File
@@ -85,7 +85,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro
## Setup ## Setup
Make sure to have the following pre-requisites installed: Make sure to have the following pre-requisites installed:
1. Java 11 1. Java 17
2. Android Studio 2. Android Studio
3. Android 8.0+ Phone or Emulation setup 3. Android 8.0+ Phone or Emulation setup
+13 -10
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 33 targetSdk 33
versionCode 118 versionCode 123
versionName "0.32.3" versionName "0.34.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -57,12 +57,12 @@ android {
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_11 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = '11' jvmTarget = '17'
} }
buildFeatures { buildFeatures {
@@ -78,10 +78,14 @@ android {
excludes += '/META-INF/{AL2.0,LGPL2.1}' excludes += '/META-INF/{AL2.0,LGPL2.1}'
} }
} }
lint {
disable 'MissingTranslation'
}
} }
dependencies { dependencies {
implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.activity:activity-compose:1.7.0-beta01' implementation 'androidx.activity:activity-compose:1.7.0-beta01'
implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -142,15 +146,14 @@ dependencies {
// view svgs // view svgs
implementation "io.coil-kt:coil-svg:$coil_version" implementation "io.coil-kt:coil-svg:$coil_version"
// create blurhash
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
// Rendering clickable text // Rendering clickable text
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version" implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
// Permission to upload pictures: // Permission to upload pictures:
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
// tabs for user profiles
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
// Parses URLs from Text: // Parses URLs from Text:
implementation "io.github.url-detector:url-detector:0.1.23" implementation "io.github.url-detector:url-detector:0.1.23"
+2
View File
@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
@@ -18,7 +18,7 @@ object VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory lateinit var cacheDataSourceFactory: CacheDataSource.Factory
fun get(context: Context): CacheDataSource.Factory { fun init(context: Context) {
if (!this::simpleCache.isInitialized) { if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context) exoDatabaseProvider = StandaloneDatabaseProvider(context)
@@ -35,7 +35,9 @@ object VideoCache {
) )
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
} }
}
fun get(): CacheDataSource.Factory {
return cacheDataSourceFactory return cacheDataSourceFactory
} }
} }
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources import android.content.res.Resources
import androidx.core.os.ConfigurationCompat import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants import com.vitorpamplona.amethyst.service.relays.Constants
@@ -54,8 +55,14 @@ class Account(
val liveLanguages: AccountLiveData = AccountLiveData(this) val liveLanguages: AccountLiveData = AccountLiveData(this)
val saveable: AccountLiveData = AccountLiveData(this) val saveable: AccountLiveData = AccountLiveData(this)
var userProfileCache: User? = null
fun userProfile(): User { fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) return userProfileCache ?: run {
val myUser: User = LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
userProfileCache = myUser
myUser
}
} }
fun followingChannels(): List<Channel> { fun followingChannels(): List<Channel> {
@@ -351,6 +358,25 @@ class Account(
} }
} }
fun sendHeader(headerInfo: FileHeader): Note? {
if (!isWriteable()) return null
val signedEvent = FileHeaderEvent.create(
url = headerInfo.url,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
return LocalCache.notes[signedEvent.id]
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) { fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -722,11 +748,11 @@ class Account(
fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers
fun followingKeySet(): Set<HexKey> { fun followingKeySet(): Set<HexKey> {
return userProfile().cachedFollowingKeySet() ?: emptySet() return userProfile().cachedFollowingKeySet()
} }
fun followingTagSet(): Set<HexKey> { fun followingTagSet(): Set<HexKey> {
return userProfile().cachedFollowingTagSet() ?: emptySet() return userProfile().cachedFollowingTagSet()
} }
fun isAcceptable(user: User): Boolean { fun isAcceptable(user: User): Boolean {
@@ -6,7 +6,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.* import kotlinx.coroutines.*
import nostr.postr.toNpub import nostr.postr.toNpub
@@ -189,7 +189,7 @@ object LocalCache {
it.addReply(note) it.addReply(note)
} }
refreshObservers() refreshObservers(note)
} }
fun consume(event: LongTextNoteEvent, relay: Relay?) { fun consume(event: LongTextNoteEvent, relay: Relay?) {
@@ -218,7 +218,7 @@ object LocalCache {
author.addNote(note) author.addNote(note)
refreshObservers() refreshObservers(note)
} }
} }
@@ -255,7 +255,7 @@ object LocalCache {
it.addReply(note) it.addReply(note)
} }
refreshObservers() refreshObservers(note)
} }
fun consume(event: BadgeDefinitionEvent) { fun consume(event: BadgeDefinitionEvent) {
@@ -268,7 +268,7 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) { if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList<Note>()) note.loadEvent(event, author, emptyList<Note>())
refreshObservers() refreshObservers(note)
} }
} }
@@ -286,8 +286,6 @@ object LocalCache {
note.loadEvent(event, author, replyTo) note.loadEvent(event, author, replyTo)
author.updateAcceptedBadges(note) author.updateAcceptedBadges(note)
refreshObservers()
} }
} }
@@ -309,7 +307,7 @@ object LocalCache {
it.addReply(note) it.addReply(note)
} }
refreshObservers() refreshObservers(note)
} }
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
@@ -353,7 +351,7 @@ object LocalCache {
recipient.addMessage(author, note) recipient.addMessage(author, note)
} }
refreshObservers() refreshObservers(note)
} }
fun consume(event: DeletionEvent) { fun consume(event: DeletionEvent) {
@@ -401,7 +399,7 @@ object LocalCache {
} }
if (deletedAtLeastOne) { if (deletedAtLeastOne) {
live.invalidateData() // refreshObservers()
} }
} }
@@ -427,7 +425,7 @@ object LocalCache {
it.addBoost(note) it.addBoost(note)
} }
refreshObservers() refreshObservers(note)
} }
fun consume(event: ReactionEvent) { fun consume(event: ReactionEvent) {
@@ -465,6 +463,8 @@ object LocalCache {
it.addReport(note) it.addReport(note)
} }
} }
refreshObservers(note)
} }
fun consume(event: ReportEvent, relay: Relay?) { fun consume(event: ReportEvent, relay: Relay?) {
@@ -495,6 +495,8 @@ object LocalCache {
repliesTo.forEach { repliesTo.forEach {
it.addReport(note) it.addReport(note)
} }
refreshObservers(note)
} }
fun consume(event: ChannelCreateEvent) { fun consume(event: ChannelCreateEvent) {
@@ -511,7 +513,7 @@ object LocalCache {
oldChannel.addNote(note) oldChannel.addNote(note)
note.loadEvent(event, author, emptyList()) note.loadEvent(event, author, emptyList())
refreshObservers() refreshObservers(note)
} }
} }
@@ -531,7 +533,7 @@ object LocalCache {
oldChannel.addNote(note) oldChannel.addNote(note)
note.loadEvent(event, author, emptyList()) note.loadEvent(event, author, emptyList())
refreshObservers() refreshObservers(note)
} }
} else { } else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -578,7 +580,7 @@ object LocalCache {
it.addReply(note) it.addReply(note)
} }
refreshObservers() refreshObservers(note)
} }
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
@@ -595,7 +597,7 @@ object LocalCache {
// Already processed this event. // Already processed this event.
if (note.event != null) return if (note.event != null) return
val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) } val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) }
val author = getOrCreateUser(event.pubKey) val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
@@ -620,6 +622,8 @@ object LocalCache {
mentions.forEach { mentions.forEach {
it.addZap(zapRequest, note) it.addZap(zapRequest, note)
} }
refreshObservers(note)
} }
fun consume(event: LnZapRequestEvent) { fun consume(event: LnZapRequestEvent) {
@@ -643,6 +647,21 @@ object LocalCache {
mentions.forEach { mentions.forEach {
it.addZap(note, null) it.addZap(note, null)
} }
refreshObservers(note)
}
fun consume(event: FileHeaderEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
} }
fun findUsersStartingWith(username: String): List<User> { fun findUsersStartingWith(username: String): List<User> {
@@ -694,11 +713,6 @@ object LocalCache {
notes.remove(it.idHex) notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter. // Doesn't need to clean up the replies and mentions.. Too small to matter.
// reverts the add
val mentions =
it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
?.mapNotNull { checkGetOrCreateUser(it) }
// Counts the replies // Counts the replies
it.replyTo?.forEach { _ -> it.replyTo?.forEach { _ ->
it.removeReply(it) it.removeReply(it)
@@ -749,30 +763,23 @@ object LocalCache {
} }
// Observers line up here. // Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this) val live: LocalCacheLiveData = LocalCacheLiveData()
private fun refreshObservers() { private fun refreshObservers(newNote: Note) {
live.invalidateData() live.invalidateData(newNote)
} }
} }
class LocalCacheLiveData(val cache: LocalCache) : class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
LiveData<LocalCacheState>(LocalCacheState(cache)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) { private val bundler = BundledInsert<Note>(300, Dispatchers.Main)
if (hasActiveObservers()) {
refresh() fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes ->
if (hasActiveObservers()) {
postValue(bundledNewNotes)
}
} }
} }
fun invalidateData() {
bundler.invalidate()
}
private fun refresh() {
postValue(LocalCacheState(cache))
}
} }
class LocalCacheState(val cache: LocalCache)
@@ -53,13 +53,14 @@ open class Note(val idHex: String) {
open fun idNote() = id().toNote() open fun idNote() = id().toNote()
open fun idDisplayNote() = idNote().toShortenHex() open fun idDisplayNote() = idNote().toShortenHex()
fun channel(): Channel? { fun channelHex(): HexKey? {
val channelHex = return (event as? ChannelMessageEvent)?.channel()
(event as? ChannelMessageEvent)?.channel() ?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel() ?: (event as? ChannelCreateEvent)?.id
?: (event as? ChannelCreateEvent)?.id }
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) } fun channel(): Channel? {
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
} }
open fun address(): ATag? = null open fun address(): ATag? = null
@@ -295,7 +295,7 @@ class User(val pubkeyHex: String) {
fun hasSentMessagesTo(user: User?): Boolean { fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false val messagesToUser = privateChatrooms[user] ?: return false
return messagesToUser.roomMessages.any { this == it.author } return messagesToUser.roomMessages.any { this === it.author }
} }
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean { fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
@@ -0,0 +1,203 @@
package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
import android.graphics.Color
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.withSign
object BlurHashDecoder {
// cache Math.cos() calculations to improve performance.
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
private val cacheCosinesX = HashMap<Int, DoubleArray>()
private val cacheCosinesY = HashMap<Int, DoubleArray>()
/**
* Clear calculations stored in memory cache.
* The cache is not big, but will increase when many image sizes are used,
* if the app needs memory it is recommended to clear it.
*/
fun clearCache() {
cacheCosinesX.clear()
cacheCosinesY.clear()
}
/**
* Returns width/height
*/
fun aspectRatio(blurHash: String?): Float? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
return numCompX.toFloat() / numCompY.toFloat()
}
/**
* Decode a blur hash into a new bitmap.
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations.
* By default it is true.
*/
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
val maxAcEnc = decode83(blurHash, 1, 2)
val maxAc = (maxAcEnc + 1) / 166f
val colors = Array(numCompX * numCompY) { i ->
if (i == 0) {
val colorEnc = decode83(blurHash, 2, 6)
decodeDc(colorEnc)
} else {
val from = 4 + i * 2
val colorEnc = decode83(blurHash, from, from + 2)
decodeAc(colorEnc, maxAc * punch)
}
}
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
}
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
var result = 0
for (i in from until to) {
val index = charMap[str[i]] ?: -1
if (index != -1) {
result = result * 83 + index
}
}
return result
}
private fun decodeDc(colorEnc: Int): FloatArray {
val r = colorEnc shr 16
val g = (colorEnc shr 8) and 255
val b = colorEnc and 255
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
}
private fun srgbToLinear(colorEnc: Int): Float {
val v = colorEnc / 255f
return if (v <= 0.04045f) {
(v / 12.92f)
} else {
((v + 0.055f) / 1.055f).pow(2.4f)
}
}
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
val r = value / (19 * 19)
val g = (value / 19) % 19
val b = value % 19
return floatArrayOf(
signedPow2((r - 9) / 9.0f) * maxAc,
signedPow2((g - 9) / 9.0f) * maxAc,
signedPow2((b - 9) / 9.0f) * maxAc
)
}
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
private fun composeBitmap(
width: Int,
height: Int,
numCompX: Int,
numCompY: Int,
colors: Array<FloatArray>,
useCache: Boolean
): Bitmap {
// use an array for better performance when writing pixel colors
val imageArray = IntArray(width * height)
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
for (y in 0 until height) {
for (x in 0 until width) {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until numCompY) {
for (i in 0 until numCompX) {
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
val basis = (cosX * cosY).toFloat()
val color = colors[j * numCompX + i]
r += color[0] * basis
g += color[1] * basis
b += color[2] * basis
}
}
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
}
}
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
}
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
calculate -> {
DoubleArray(height * numCompY).also {
cacheCosinesY[height * numCompY] = it
}
}
else -> {
cacheCosinesY[height * numCompY]!!
}
}
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
calculate -> {
DoubleArray(width * numCompX).also {
cacheCosinesX[width * numCompX] = it
}
}
else -> cacheCosinesX[width * numCompX]!!
}
private fun DoubleArray.getCos(
calculate: Boolean,
x: Int,
numComp: Int,
y: Int,
size: Int
): Double {
if (calculate) {
this[x + numComp * y] = cos(Math.PI * y * x / size)
}
return this[x + numComp * y]
}
private fun linearToSrgb(value: Float): Int {
val v = value.coerceIn(0f, 1f)
return if (v <= 0.0031308f) {
(v * 12.92f * 255f + 0.5f).toInt()
} else {
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
}
}
private val charMap = listOf(
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
)
.mapIndexed { i, c -> c to i }
.toMap()
}
@@ -0,0 +1,65 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.net.Uri
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.math.roundToInt
class BlurHashFetcher(
private val options: Options,
private val data: Uri
) : Fetcher {
override suspend fun fetch(): FetchResult {
val encodedHash = data.toString().removePrefix("bluehash:")
val hash = URLDecoder.decode(encodedHash, "utf-8")
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
val preferredWidth = 100
val bitmap = BlurHashDecoder.decode(
hash,
preferredWidth,
(preferredWidth * (1 / aspectRatio)).roundToInt()
)
if (bitmap == null) {
throw Exception("Unable to convert Bluehash $hash")
}
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY
)
}
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return BlurHashFetcher(options, data)
}
}
}
object BlurHashRequester {
fun imageRequest(context: Context, message: String): ImageRequest {
val encodedMessage = URLEncoder.encode(message, "utf-8")
return ImageRequest
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.crossfade(100)
.build()
}
}
@@ -0,0 +1,65 @@
package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey
import io.trbl.blurhash.BlurHash
import java.net.URL
import java.security.MessageDigest
import kotlin.math.roundToInt
class FileHeader(
val url: String,
val mimeType: String?,
val hash: String,
val size: Int,
val blurHash: String?,
val description: String? = null
) {
companion object {
fun prepare(fileUrl: String, mimeType: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData = URL(fileUrl).readBytes()
val sha256 = MessageDigest.getInstance("SHA-256")
val hash = sha256.digest(imageData).toHexKey()
val size = imageData.size
val blurHash = if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt)
val intArray = IntArray(mBitmap.width * mBitmap.height)
mBitmap.getPixels(
intArray,
0,
mBitmap.width,
0,
0,
mBitmap.width,
mBitmap.height
)
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt())
} else if (aspectRatio < 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9)
} else {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
}
} else {
null
}
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, ""))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()
}
}
}
}
@@ -75,8 +75,9 @@ abstract class NostrDataSource(val debugName: String) {
is ContactListEvent -> LocalCache.consume(event) is ContactListEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event) is DeletionEvent -> LocalCache.consume(event)
is FileHeaderEvent -> LocalCache.consume(event)
is LnZapEvent -> { is LnZapEvent -> {
event.containedPost()?.let { onEvent(it, subscriptionId, relay) } event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event) LocalCache.consume(event)
} }
is LnZapRequestEvent -> LocalCache.consume(event) is LnZapRequestEvent -> LocalCache.consume(event)
@@ -154,7 +155,7 @@ abstract class NostrDataSource(val debugName: String) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(250, Dispatchers.IO) { private val bundler = BundledUpdate(250, Dispatchers.IO) {
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines. // holding off new updates in case of heavy refresh routines.
@@ -17,7 +17,14 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
return TypedFilter( return TypedFilter(
types = FeedType.values().toSet(), types = FeedType.values().toSet(),
filter = JsonFilter( filter = JsonFilter(
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())), tags = mapOf(
"t" to listOf(
hashToLoad,
hashToLoad.lowercase(),
hashToLoad.uppercase(),
hashToLoad.capitalize()
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200 limit = 200
) )
@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter
import java.util.Date
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>() private var eventsToWatch = setOf<Note>()
@@ -20,8 +19,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return null return null
} }
val now = Date().time / 1000
return addressesToWatch.mapNotNull { return addressesToWatch.mapNotNull {
it.address()?.let { aTag -> it.address()?.let { aTag ->
TypedFilter( TypedFilter(
@@ -49,8 +46,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return null return null
} }
val now = Date().time / 1000
return addressesToWatch.mapNotNull { return addressesToWatch.mapNotNull {
it.address()?.let { aTag -> it.address()?.let { aTag ->
TypedFilter( TypedFilter(
@@ -116,9 +111,12 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
types = FeedType.values().toSet(), types = FeedType.values().toSet(),
filter = JsonFilter( filter = JsonFilter(
kinds = listOf( kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, ReactionEvent.kind, RepostEvent.kind,
PollNoteEvent.kind, PrivateDmEvent.kind LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind, FileHeaderEvent.kind
), ),
ids = interestedEvents.toList() ids = interestedEvents.toList()
) )
@@ -25,7 +25,7 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
) )
} }
val loadEventsChannel = requestNewChannel() { eoseTime, relay -> val loadEventsChannel = requestNewChannel() { _, _ ->
// Many relays operate with limits in the amount of filters. // Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data. // As information comes, the filters will be rotated to get more data.
invalidateFilters() invalidateFilters()
@@ -222,6 +222,7 @@ open class Event(
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,71 @@
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 FileHeaderEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
companion object {
const val kind = 1063
private const val URL = "url"
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
private const val BLUR_HASH = "blurhash"
fun create(
url: String,
mimeType: String? = null,
description: String? = null,
hash: String? = null,
size: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileHeaderEvent {
var tags = listOfNotNull(
listOf(URL, url),
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
)
val content = description ?: ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
data class AESGCM(val key: String, val nonce: String)
@@ -4,7 +4,6 @@ import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import java.math.BigDecimal
class LnZapEvent( class LnZapEvent(
id: HexKey, id: HexKey,
@@ -14,25 +13,37 @@ class LnZapEvent(
content: String, content: String,
sig: HexKey sig: HexKey
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
// This event is also kept in LocalCache (same object)
@Transient val zapRequest: LnZapRequestEvent?
override fun zappedPost() = tags private fun containedPost(): LnZapRequestEvent? = try {
.filter { it.firstOrNull() == "e" } description()?.ifBlank { null }?.let {
.mapNotNull { it.getOrNull(1) } fromJson(it, Client.lenient)
} as? LnZapRequestEvent
override fun zappedPollOption(): Int? = containedPost()?.tags } catch (e: Exception) {
?.filter { it.firstOrNull() == POLL_OPTION } Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
?.getOrNull(0)?.getOrNull(1)?.toInt() null
override fun zappedAuthor() = tags
.filter { it.firstOrNull() == "p" }
.mapNotNull { it.getOrNull(1) }
override fun zappedRequestAuthor(): String? = containedPost()?.pubKey()
override fun amount(): BigDecimal? {
return amount
} }
init {
zapRequest = containedPost()
}
override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
override fun zappedPollOption(): Int? = try {
zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt()
} catch (e: Exception) {
Log.e("LnZapEvent", "ZappedPollOption failed to parse", e)
null
}
override fun zappedRequestAuthor(): String? = zapRequest?.pubKey()
override fun amount() = amount
// Keeps this as a field because it's a heavier function used everywhere. // Keeps this as a field because it's a heavier function used everywhere.
val amount by lazy { val amount by lazy {
try { try {
@@ -42,29 +53,14 @@ class LnZapEvent(
null null
} }
} }
override fun message(): String { override fun message(): String {
return message return content
}
val message = content
override fun containedPost(): Event? = try {
description()?.ifBlank { null }?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
null
} }
private fun lnInvoice(): String? = tags private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
.filter { it.firstOrNull() == "bolt11" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
private fun description(): String? = tags private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
.filter { it.firstOrNull() == "description" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
companion object { companion object {
const val kind = 9735 const val kind = 9735
@@ -16,7 +16,5 @@ interface LnZapEventInterface : EventInterface {
fun amount(): BigDecimal? fun amount(): BigDecimal?
fun containedPost(): Event?
fun message(): String fun message(): String
} }
@@ -13,8 +13,10 @@ class LnZapRequestEvent(
content: String, content: String,
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
companion object { companion object {
const val kind = 9734 const val kind = 9734
@@ -20,24 +20,17 @@ class PollNoteEvent(
content: String, content: String,
sig: HexKey sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { ) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun pollOptions(): Map<Int, String> { fun pollOptions() =
val map = mutableMapOf<Int, String>() tags.filter { it.size > 2 && it[0] == POLL_OPTION }
tags.filter { it.first() == POLL_OPTION } .associate { it[1].toInt() to it[2] }
.forEach { map[it[1].toInt()] = it[2] }
return map
}
fun getTagInt(property: String): Int? { fun getTagInt(property: String): Int? {
val tagList = tags.filter { val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1)
it.firstOrNull() == property
}
val tag = tagList.getOrNull(0)
val s = tag?.getOrNull(1)
return if (s.isNullOrBlank() || s == "null") { return if (number.isNullOrBlank() || number == "null") {
null null
} else { } else {
s.toInt() number.toInt()
} }
} }
@@ -21,7 +21,7 @@ class PrivateDmEvent(
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages. * for initial messages.
*/ */
fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
/** /**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18. * To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
@@ -29,7 +29,7 @@ class PrivateDmEvent(
* Nip-18 messages should refer to other events by inline references in the content like * Nip-18 messages should refer to other events by inline references in the content like
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506). * `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
*/ */
fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? { fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
return try { return try {
@@ -14,8 +14,8 @@ class ReactionEvent(
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
companion object { companion object {
const val kind = 7 const val kind = 7
@@ -30,7 +30,7 @@ class ReportEvent(
} }
fun reportedPost() = tags fun reportedPost() = tags
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null } .filter { it.size > 1 && it[0] == "e" }
.map { .map {
ReportedKey( ReportedKey(
it[1], it[1],
@@ -39,7 +39,7 @@ class ReportEvent(
} }
fun reportedAuthor() = tags fun reportedAuthor() = tags
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null } .filter { it.size > 1 && it[0] == "p" }
.map { .map {
ReportedKey( ReportedKey(
it[1], it[1],
@@ -19,6 +19,7 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
@@ -54,6 +55,9 @@ class MainActivity : FragmentActivity() {
null null
} }
// Initializes video cache.
VideoCache.init(this.applicationContext)
Coil.setImageLoader { Coil.setImageLoader {
ImageLoader.Builder(this).components { ImageLoader.Builder(this).components {
if (SDK_INT >= 28) { if (SDK_INT >= 28) {
@@ -15,7 +15,7 @@ object ImageUploader {
fun uploadImage( fun uploadImage(
uri: Uri, uri: Uri,
contentResolver: ContentResolver, contentResolver: ContentResolver,
onSuccess: (String) -> Unit, onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit onError: (Throwable) -> Unit
) { ) {
val contentType = contentResolver.getType(uri) val contentType = contentResolver.getType(uri)
@@ -64,7 +64,7 @@ object ImageUploader {
"There must be an uploaded image URL in the response" "There must be an uploaded image URL in the response"
} }
onSuccess(url) onSuccess(url, contentType)
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -9,21 +9,21 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) { class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
open fun addUserToMentions(user: User) { fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user) mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
} }
open fun addNoteToReplyTos(note: Note) { fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) } note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note) replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
} }
open fun tagIndex(user: User): Int { fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order // Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0) return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
} }
open fun tagIndex(note: Note): Int { fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order // Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0) return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
} }
@@ -7,7 +7,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -15,15 +14,6 @@ import com.vitorpamplona.amethyst.R
@Composable @Composable
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) { fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Row { Row {
val deleteIcon: @Composable (() -> Unit) = { val deleteIcon: @Composable (() -> Unit) = {
IconButton( IconButton(
@@ -116,7 +116,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
Text(stringResource(R.string.poll_heading_required)) Text(stringResource(R.string.poll_heading_required))
// NewPollRecipientsField(pollViewModel, account) // NewPollRecipientsField(pollViewModel, account)
NewPollPrimaryDescription(pollViewModel) NewPollPrimaryDescription(pollViewModel)
pollViewModel.pollOptions.values.forEachIndexed { index, element -> pollViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(pollViewModel, index) NewPollOption(pollViewModel, index)
} }
Button( Button(
@@ -150,7 +150,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
itemsIndexed( itemsIndexed(
userSuggestions, userSuggestions,
key = { _, item -> item.pubkeyHex } key = { _, item -> item.pubkeyHex }
) { index, item -> ) { _, item ->
UserLine(item, account) { UserLine(item, account) {
pollViewModel.autocompleteWithUser(item) pollViewModel.autocompleteWithUser(item)
} }
@@ -167,7 +167,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
) )
if (postViewModel.wantsPoll) { if (postViewModel.wantsPoll) {
postViewModel.pollOptions.values.forEachIndexed { index, element -> postViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(postViewModel, index) NewPollOption(postViewModel, index)
} }
@@ -189,7 +189,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
val user = postViewModel.account?.userProfile() val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress() val lud16 = user?.info?.lnAddress()
if (lud16 != null && user != null && postViewModel.wantsInvoice) { if (lud16 != null && postViewModel.wantsInvoice) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest( InvoiceRequest(
lud16, lud16,
@@ -13,6 +13,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.components.isValidURL
@@ -72,7 +73,7 @@ open class NewPostViewModel : ViewModel() {
this.mentions = currentMentions.plus(replyUser) this.mentions = currentMentions.plus(replyUser)
} }
} }
} ?: { } ?: run {
replyTos = null replyTos = null
mentions = null mentions = null
} }
@@ -110,13 +111,39 @@ open class NewPostViewModel : ViewModel() {
ImageUploader.uploadImage( ImageUploader.uploadImage(
uri = it, uri = it,
contentResolver = context.contentResolver, contentResolver = context.contentResolver,
onSuccess = { imageUrl -> onSuccess = { imageUrl, mimeType ->
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
delay(2000) // Images don't seem to be ready immediately after upload
urlPreview = findUrlInMessage()
if (mimeType?.startsWith("image/") == true) {
delay(2000)
} else {
delay(5000)
}
FileHeader.prepare(
imageUrl,
mimeType,
onReady = {
val note = account?.sendHeader(it)
isUploadingImage = false
if (note == null) {
message = TextFieldValue(message.text + "\n\n" + imageUrl)
} else {
message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote())
}
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
} }
}, },
onError = { onError = {
@@ -171,7 +171,7 @@ class NewUserMetadataViewModel : ViewModel() {
ImageUploader.uploadImage( ImageUploader.uploadImage(
uri = it, uri = it,
contentResolver = context.contentResolver, contentResolver = context.contentResolver,
onSuccess = { imageUrl -> onSuccess = { imageUrl, mimeType ->
onUploading(false) onUploading(false)
onUploaded(imageUrl) onUploaded(imageUrl)
}, },
@@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/** /**
* This class is designed to have a waiting time between two calls of invalidate * This class is designed to have a waiting time between two calls of invalidate
@@ -44,3 +45,37 @@ class BundledUpdate(
} }
} }
} }
/**
* This class is designed to have a waiting time between two calls of invalidate
*/
class BundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
private var onlyOneInBlock = AtomicBoolean()
private var atomicSet = AtomicReference<Set<T>>(setOf<T>())
fun invalidateList(newObject: T, onUpdate: (Set<T>) -> Unit) {
atomicSet.updateAndGet() {
it + newObject
}
if (onlyOneInBlock.getAndSet(true)) {
return
}
val scope = CoroutineScope(Job() + dispatcher)
scope.launch {
try {
onUpdate(atomicSet.getAndSet(emptySet()))
delay(delay)
onUpdate(atomicSet.getAndSet(emptySet()))
} finally {
withContext(NonCancellable) {
onlyOneInBlock.set(false)
}
}
}
}
}
@@ -14,6 +14,7 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
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
@@ -32,6 +33,8 @@ import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults import com.halilibo.richtext.ui.resolveDefaults
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
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.model.User import com.vitorpamplona.amethyst.model.User
@@ -123,48 +126,43 @@ fun RichTextViewer(
) )
) )
val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
MaterialRichText( MaterialRichText(
style = myMarkDownStyle style = myMarkDownStyle
) { ) {
Markdown( Markdown(
content = content, content = markdownWithSpecialContent,
markdownParseOptions = MarkdownParseOptions.Default markdownParseOptions = MarkdownParseOptions.Default
) )
} }
} else { } else {
val imagesForPager = mutableListOf<String>() val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
content.split('\n').forEach { paragraph -> val imagesForPager = urlSet.mapNotNull { fullUrl ->
paragraph.split(' ').forEach { word: String -> val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
// sequence of images will render in a slideview if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
if (isValidURL(word)) { ZoomableImage(fullUrl)
val removedParamsFromUrl = word.split("?")[0].lowercase() } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { ZoomableVideo(fullUrl)
imagesForPager.add(word) } else {
} null
if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
imagesForPager.add(word)
}
}
} }
} }.associateBy { it.url }
val imageList = imagesForPager.values.toList()
// FlowRow doesn't work well with paragraphs. So we need to split them // FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph -> content.split('\n').forEach { paragraph ->
FlowRow() { FlowRow() {
val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ') val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ')
s.forEach { word: String -> s.forEach { word: String ->
if (canPreview) { if (canPreview) {
// Explicit URL // Explicit URL
if (isValidURL(word)) { val img = imagesForPager[word]
val removedParamsFromUrl = word.split("?")[0].lowercase() if (img != null) {
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { ZoomableContentView(img, imageList)
ZoomableImageView(word, imagesForPager) } else if (urlSet.contains(word)) {
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { UrlPreview(word, "$word ")
ZoomableImageView(word, imagesForPager)
} else {
UrlPreview(word, "$word ")
}
} else if (word.startsWith("lnbc", true)) { } else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word) MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) { } else if (word.startsWith("lnurl", true)) {
@@ -192,7 +190,7 @@ fun RichTextViewer(
navController navController
) )
} else if (hashTagsPattern.matcher(word).matches()) { } else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController) HashTag(word, navController)
} else { } else {
Text( Text(
text = "$word ", text = "$word ",
@@ -205,8 +203,15 @@ fun RichTextViewer(
val url = matcher.group(1) // url val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars val additionalChars = matcher.group(4) ?: "" // additional chars
ClickableUrl(url, "https://$url") if (url != null) {
Text("$additionalChars ") ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else { } else {
Text( Text(
text = "$word ", text = "$word ",
@@ -214,7 +219,7 @@ fun RichTextViewer(
) )
} }
} else { } else {
if (isValidURL(word)) { if (urlSet.contains(word)) {
ClickableUrl("$word ", word) ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) { } else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word) val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
@@ -249,7 +254,7 @@ fun RichTextViewer(
navController navController
) )
} else if (hashTagsPattern.matcher(word).matches()) { } else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController) HashTag(word, navController)
} else { } else {
Text( Text(
text = "$word ", text = "$word ",
@@ -262,8 +267,15 @@ fun RichTextViewer(
val url = matcher.group(1) // url val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars val additionalChars = matcher.group(4) ?: "" // additional chars
ClickableUrl(url, "https://$url") if (url != null) {
Text("$additionalChars ") ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else { } else {
Text( Text(
text = "$word ", text = "$word ",
@@ -278,6 +290,53 @@ fun RichTextViewer(
} }
} }
@Composable
private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? {
if (parsedNip19.type == Nip19.Type.USER) {
val userHex = parsedNip19.hex
val userBase = LocalCache.getOrCreateUser(userHex)
val userState by userBase.live().metadata.observeAsState()
val displayName = userState?.user?.bestDisplayName()
if (displayName !== null) {
return displayName
}
}
return null
}
@Composable
private fun returnMarkdownWithSpecialContent(content: String): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isValidURL(word)) {
returnContent += "[$word]($word) "
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
returnContent += "[$word](mailto:$word) "
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
returnContent += "[$word](tel:$word) "
} else if (isBechLink(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
returnContent += if (parsedNip19 !== null) {
val displayName = getDisplayNameFromUserNip19(parsedNip19)
if (displayName != null) {
"[@$displayName](nostr://$word) "
} else {
"$word "
}
} else {
"$word "
}
} else {
returnContent += "$word "
}
}
returnContent += "\n"
}
return returnContent
}
private fun isArabic(text: String): Boolean { private fun isArabic(text: String): Boolean {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
} }
@@ -326,9 +385,11 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
isQuotedNote = true, isQuotedNote = true,
navController = navController navController = navController
) )
Text( if (!it.second.isNullOrEmpty()) {
"${it.second} " Text(
) "${it.second} "
)
}
} ?: nip19Route?.let { } ?: nip19Route?.let {
ClickableRoute(it, navController) ClickableRoute(it, navController)
} ?: Text(text = "$word ") } ?: Text(text = "$word ")
@@ -340,7 +401,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
} }
@Composable @Composable
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) { fun HashTag(word: String, navController: NavController) {
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) } var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
LaunchedEffect(key1 = word) { LaunchedEffect(key1 = word) {
@@ -11,6 +11,7 @@ import coil.fetch.Fetcher
import coil.fetch.SourceResult import coil.fetch.SourceResult
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.request.Options import coil.request.Options
import coil.size.Size
import okio.Buffer import okio.Buffer
import java.security.MessageDigest import java.security.MessageDigest
@@ -61,6 +62,7 @@ private fun svgString(msg: String): String {
class HashImageFetcher( class HashImageFetcher(
private val context: Context, private val context: Context,
private val size: Size,
private val data: Uri private val data: Uri
) : Fetcher { ) : Fetcher {
@@ -79,16 +81,17 @@ class HashImageFetcher(
object Factory : Fetcher.Factory<Uri> { object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher { override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return HashImageFetcher(options.context, data) return HashImageFetcher(options.context, options.size, data)
} }
} }
} }
object Robohash { object Robohash {
fun imageRequest(context: Context, message: String): ImageRequest { fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest {
return ImageRequest return ImageRequest
.Builder(context) .Builder(context)
.data("robohash:$message") .data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory) .fetcherFactory(HashImageFetcher.Factory)
.size(robotSize)
.crossfade(100) .crossfade(100)
.build() .build()
} }
@@ -9,13 +9,17 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.size.Size
@Composable @Composable
fun RobohashAsyncImage( fun RobohashAsyncImage(
robot: String, robot: String,
robotSize: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentDescription: String? = null, contentDescription: String? = null,
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform, transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
@@ -26,24 +30,33 @@ fun RobohashAsyncImage(
colorFilter: ColorFilter? = null, colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) { ) {
AsyncImage( with(LocalDensity.current) {
model = Robohash.imageRequest(LocalContext.current, robot), AsyncImage(
contentDescription = contentDescription, model = Robohash.imageRequest(
modifier = modifier, LocalContext.current,
transform = transform, robot,
onState = onState, Size(robotSize.roundToPx(), robotSize.roundToPx())
alignment = alignment, ),
contentScale = contentScale, contentDescription = contentDescription,
alpha = alpha, modifier = modifier,
colorFilter = colorFilter, transform = transform,
filterQuality = filterQuality onState = onState,
) alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
} }
var imageErrors = setOf<String>()
@Composable @Composable
fun RobohashFallbackAsyncImage( fun RobohashFallbackAsyncImage(
robot: String, robot: String,
model: String?, robotSize: Dp,
model: String,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center, alignment: Alignment = Alignment.Center,
@@ -52,22 +65,47 @@ fun RobohashFallbackAsyncImage(
colorFilter: ColorFilter? = null, colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) { ) {
val context = LocalContext.current if (imageErrors.contains(model)) {
val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot)) RobohashAsyncImage(
robot = robot,
robotSize = robotSize,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
val context = LocalContext.current
val painter = with(LocalDensity.current) {
rememberAsyncImagePainter(
model = Robohash.imageRequest(
context,
robot,
Size(robotSize.roundToPx(), robotSize.roundToPx())
)
)
}
AsyncImage( AsyncImage(
model = model, model = model,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier, modifier = modifier,
placeholder = painter, placeholder = painter,
fallback = painter, fallback = painter,
error = painter, error = painter,
alignment = alignment, alignment = alignment,
contentScale = contentScale, contentScale = contentScale,
alpha = alpha, alpha = alpha,
colorFilter = colorFilter, colorFilter = colorFilter,
filterQuality = filterQuality filterQuality = filterQuality,
) onError = {
imageErrors = imageErrors + model
}
)
}
} }
@Composable @Composable
@@ -82,9 +120,11 @@ fun RobohashAsyncImageProxy(
colorFilter: ColorFilter? = null, colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) { ) {
if (model.url == null) { val proxy = model.proxyUrl()
if (proxy == null) {
RobohashAsyncImage( RobohashAsyncImage(
robot = robot, robot = robot,
robotSize = model.size,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier, modifier = modifier,
alignment = alignment, alignment = alignment,
@@ -96,7 +136,8 @@ fun RobohashAsyncImageProxy(
} else { } else {
RobohashFallbackAsyncImage( RobohashFallbackAsyncImage(
robot = robot, robot = robot,
model = model.proxyUrl(), robotSize = model.size,
model = proxy,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier, modifier = modifier,
alignment = alignment, alignment = alignment,
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
@@ -22,11 +25,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun SlidingCarousel( fun SlidingCarousel(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -39,7 +39,7 @@ fun SlidingCarousel(
Box( Box(
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
HorizontalPager(count = itemsCount, state = pagerState) { page -> HorizontalPager(pageCount = itemsCount, state = pagerState) { page ->
itemContent(page) itemContent(page)
} }
@@ -41,24 +41,24 @@ fun UrlPreviewCard(
) )
) { ) {
Column { Column {
val url = URL(previewInfo.url) val validatedUrl = URL(previewInfo.url)
// correctly treating relative images // correctly treating relative images
val imageUrl = if (previewInfo.image.startsWith("/")) { val imageUrl = if (previewInfo.image.startsWith("/")) {
URL(url, previewInfo.image).toString() URL(validatedUrl, previewInfo.image).toString()
} else { } else {
previewInfo.image previewInfo.image
} }
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = stringResource(R.string.preview_card_image_for, url), contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl),
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Text( Text(
text = url.host, text = validatedUrl.host,
style = MaterialTheme.typography.caption, style = MaterialTheme.typography.caption,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -6,9 +6,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
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
@@ -21,44 +25,54 @@ import com.vitorpamplona.amethyst.VideoCache
@Composable @Composable
fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) { fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val exoPlayer = remember { val exoPlayer = remember(videoUri) {
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_WITH_CROPPING
setMediaSource( setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri)) ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(MediaItem.fromUri(videoUri))
) )
prepare() prepare()
} }
} }
val playerView = remember { DisposableEffect(
StyledPlayerView(context).apply { AndroidView(
player = exoPlayer modifier = Modifier.fillMaxWidth(),
layoutParams = FrameLayout.LayoutParams( factory = {
ViewGroup.LayoutParams.MATCH_PARENT, StyledPlayerView(context).apply {
ViewGroup.LayoutParams.WRAP_CONTENT player = exoPlayer
) layoutParams = FrameLayout.LayoutParams(
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH ViewGroup.LayoutParams.MATCH_PARENT,
onDialog?.let { innerOnDialog -> ViewGroup.LayoutParams.WRAP_CONTENT
setFullscreenButtonClickListener { )
innerOnDialog(it) resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
innerOnDialog(it)
}
}
} }
} }
)
) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.pause()
}
else -> {}
}
} }
} val lifecycle = lifecycleOwner.value.lifecycle
lifecycle.addObserver(observer)
DisposableEffect(exoPlayer) {
onDispose { onDispose {
exoPlayer.release() exoPlayer.release()
lifecycle.removeObserver(observer)
} }
} }
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
playerView
}
)
} }
@@ -0,0 +1,408 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.imageLoader
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.theme.Nip05
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.security.MessageDigest
abstract class ZoomableContent(
val url: String,
val description: String? = null,
val hash: String? = null
)
class ZoomableImage(
url: String,
description: String? = null,
hash: String? = null,
val bluehash: String? = null
) : ZoomableContent(url, description, hash)
class ZoomableVideo(
url: String,
description: String? = null,
hash: String? = null
) : ZoomableContent(url, description, hash)
fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
return if (isImage) {
ZoomableImage(fullUrl)
} else if (isVideo) {
ZoomableVideo(fullUrl)
} else {
ZoomableImage(fullUrl)
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
val mainImageModifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
)
if (content is ZoomableImage) {
Box() {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = mainImageModifier,
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
if (content.bluehash != null) {
DisplayBlueHash(content, mainImageModifier)
} else {
DisplayUrlWithLoadingSymbol(content)
}
} else {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
} else {
VideoView(content.url) { dialogOpen = true }
}
if (dialogOpen) {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
}
}
@Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
ClickableUrl(urlText = "$content ", url = content.url)
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("")
appendInlineContent(myId, "[icon]")
}
}
val inlineContent = mapOf(
Pair(
myId,
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
LoadingAnimation()
}
)
)
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = inlineContent
)
}
@Composable
private fun DisplayBlueHash(
content: ZoomableImage,
modifier: Modifier
) {
if (content.bluehash == null) return
val context = LocalContext.current
AsyncImage(
model = BlurHashRequester.imageRequest(
context,
content.bluehash
),
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = modifier
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableContent> = listOf(imageUrl), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column() {
val pagerState: PagerState = remember { PagerState() }
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onDismiss)
SaveToGallery(url = allImages[pagerState.currentPage].url)
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
}
}
}
}
@Composable
private fun RenderImageOrVideo(content: ZoomableContent) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
if (content is ZoomableImage) {
Box() {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState()),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
} else {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url)
}
}
}
@OptIn(ExperimentalCoilApi::class)
private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? {
if (content.hash == null) return null
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
val imageFile = snapshot.data.toFile()
val bytes = imageFile.readBytes()
val sha256 = MessageDigest.getInstance("SHA-256")
val hash = sha256.digest(bytes).toHexKey()
Log.d("Image Hash Verification", "$hash == ${content.hash}")
return hash == content.hash
}
return null
}
@Composable
private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) {
if (verifiedHash == null) return
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
Box(
modifier
.width(40.dp)
.height(40.dp)
.padding(10.dp)
) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
)
if (verifiedHash == true) {
IconButton(
onClick = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.hash_verification_passed),
Toast.LENGTH_LONG
).show()
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"Hash Verified",
tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background),
modifier = Modifier.size(30.dp)
)
}
} else if (verifiedHash == false) {
IconButton(
onClick = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.hash_verification_failed),
Toast.LENGTH_LONG
).show()
}
}
) {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Hash",
modifier = Modifier.size(30.dp)
)
}
}
}
}
@@ -1,189 +0,0 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.PagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = word,
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
ClickableUrl(urlText = "$word ", url = word)
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("")
appendInlineContent(myId, "[icon]")
}
}
val inlineContent = mapOf(
Pair(
myId,
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
LoadingAnimation()
}
)
)
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = inlineContent
)
}
} else {
VideoView(word) { dialogOpen = true }
}
if (dialogOpen) {
ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(imageUrl), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column() {
var pagerState: PagerState = remember { PagerState() }
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onDismiss)
SaveToGallery(url = allImages[pagerState.currentPage])
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
}
}
}
}
@Composable
private fun RenderImageOrVideo(imageUrl: String) {
val removedParamsFromUrl = imageUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = imageUrl,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
)
} else {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(imageUrl)
}
}
}
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
object ChannelFeedFilter : FeedFilter<Note>() { object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account lateinit var account: Account
lateinit var channel: Channel lateinit var channel: Channel
@@ -22,4 +22,14 @@ object ChannelFeedFilter : FeedFilter<Note>() {
.sortedBy { it.createdAt() } .sortedBy { it.createdAt() }
.reversed() .reversed()
} }
override fun applyFilter(collection: Set<Note>): Set<Note> {
return collection
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
} }
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
object ChatroomFeedFilter : FeedFilter<Note>() { object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
var account: Account? = null var account: Account? = null
var withUser: User? = null var withUser: User? = null
@@ -30,4 +30,23 @@ object ChatroomFeedFilter : FeedFilter<Note>() {
.sortedBy { it.createdAt() } .sortedBy { it.createdAt() }
.reversed() .reversed()
} }
override fun applyFilter(collection: Set<Note>): Set<Note> {
val myAccount = account
val myUser = withUser
if (myAccount == null || myUser == null) return emptySet()
val messages = myAccount
.userProfile()
.privateChatrooms[myUser] ?: return emptySet()
return collection
.filter { it in messages.roomMessages && account?.isAcceptable(it) == true }
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
} }
@@ -10,7 +10,7 @@ object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> { override fun feed(): List<Note> {
val me = account.userProfile() val me = account.userProfile()
val privateChatrooms = account.userProfile().privateChatrooms val privateChatrooms = me.privateChatrooms
val messagingWith = privateChatrooms.keys.filter { val messagingWith = privateChatrooms.keys.filter {
me.hasSentMessagesTo(it) && account.isAcceptable(it) me.hasSentMessagesTo(it) && account.isAcceptable(it)
} }
@@ -4,7 +4,7 @@ import android.util.Log
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
abstract class FeedFilter<T>() { abstract class FeedFilter<T> {
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
fun loadTop(): List<T> { fun loadTop(): List<T> {
val (feed, elapsed) = measureTimedValue { val (feed, elapsed) = measureTimedValue {
@@ -17,3 +17,24 @@ abstract class FeedFilter<T>() {
abstract fun feed(): List<T> abstract fun feed(): List<T>
} }
abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun applyFilter(collection: Set<T>): Set<T>
abstract fun sort(collection: Set<T>): List<T>
@OptIn(ExperimentalTime::class)
fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
val (feed, elapsed) = measureTimedValue {
val newItemsToBeAdded = applyFilter(newItems)
if (newItemsToBeAdded.isNotEmpty()) {
val newList = oldList.toSet() + newItemsToBeAdded
sort(newList).take(1000)
} else {
oldList
}
}
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
return feed
}
}
@@ -5,21 +5,32 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
object GlobalFeedFilter : FeedFilter<Note>() { object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account lateinit var account: Account
override fun feed(): List<Note> { override fun feed(): List<Note> {
val followChannels = account.followingChannels() val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
return sort(notes + longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val followChannels = account.followingChannels
val followUsers = account.followingKeySet() val followUsers = account.followingKeySet()
val now = System.currentTimeMillis() / 1000 val now = System.currentTimeMillis() / 1000
val notes = LocalCache.notes.values return collection
.asSequence() .asSequence()
.filter { .filter {
it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty() it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty()
} }
.filter { .filter {
val channel = it.channel() val channel = it.channelHex()
// does not show events already in the public chat list // does not show events already in the public chat list
(channel == null || channel !in followChannels) && (channel == null || channel !in followChannels) &&
// does not show people the user already follows // does not show people the user already follows
@@ -30,29 +41,10 @@ object GlobalFeedFilter : FeedFilter<Note>() {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating. // Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= now it.createdAt()!! <= now
} }
.toList() .toSet()
}
val longFormNotes = LocalCache.addressables.values override fun sort(collection: Set<Note>): List<Note> {
.asSequence() return collection.sortedBy { it.createdAt() }.reversed()
.filter {
it.event is LongTextNoteEvent && it.replyTo.isNullOrEmpty()
}
.filter {
val channel = it.channel()
// does not show events already in the public chat list
(channel == null || channel !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= now
}
.toList()
return (notes + longFormNotes)
.sortedBy { it.createdAt() }
.reversed()
} }
} }
@@ -8,14 +8,27 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HashtagFeedFilter : FeedFilter<Note>() { object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account lateinit var account: Account
var tag: String? = null var tag: String? = null
override fun feed(): List<Note> { fun loadHashtag(account: Account, tag: String?) {
val myTag = tag ?: return emptyList() this.account = account
this.tag = tag
}
return LocalCache.notes.values override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myTag = tag ?: return emptySet()
return collection
.asSequence() .asSequence()
.filter { .filter {
( (
@@ -27,13 +40,10 @@ object HashtagFeedFilter : FeedFilter<Note>() {
it.event?.isTaggedHash(myTag) == true it.event?.isTaggedHash(myTag) == true
} }
.filter { account.isAcceptable(it) } .filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() } .toSet()
.toList()
.reversed()
} }
fun loadHashtag(account: Account, tag: String?) { override fun sort(collection: Set<Note>): List<Note> {
this.account = account return collection.sortedBy { it.createdAt() }.reversed()
this.tag = tag
} }
} }
@@ -6,15 +6,24 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeConversationsFeedFilter : FeedFilter<Note>() { object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account lateinit var account: Account
override fun feed(): List<Note> { override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val user = account.userProfile() val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet() val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet() val followingTagSet = user.cachedFollowingTagSet()
return LocalCache.notes.values return collection
.asSequence()
.filter { .filter {
(it.event is TextNoteEvent || it.event is PollNoteEvent) && (it.event is TextNoteEvent || it.event is PollNoteEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
@@ -22,7 +31,10 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
it.author?.let { !account.isHidden(it) } ?: true && it.author?.let { !account.isHidden(it) } ?: true &&
!it.isNewThread() !it.isNewThread()
} }
.sortedBy { it.createdAt() } .toSet()
.reversed() }
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
} }
} }
@@ -8,34 +8,38 @@ import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeNewThreadFeedFilter : FeedFilter<Note>() { object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account lateinit var account: Account
override fun feed(): List<Note> { override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
return sort(notes + longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val user = account.userProfile() val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet() val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet() val followingTagSet = user.cachedFollowingTagSet()
val notes = LocalCache.notes.values return collection
.asSequence()
.filter { it -> .filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is PollNoteEvent) && (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) &&
(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 &&
it.isNewThread() it.isNewThread()
} }
.toSet()
}
val longFormNotes = LocalCache.addressables.values override fun sort(collection: Set<Note>): List<Note> {
.filter { it -> return collection.sortedBy { it.createdAt() }.reversed()
(it.event is LongTextNoteEvent) &&
(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 &&
it.isNewThread()
}
return (notes + longFormNotes)
.sortedBy { it.createdAt() }
.reversed()
} }
} }
@@ -6,14 +6,22 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
object NotificationFeedFilter : FeedFilter<Note>() { object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account lateinit var account: Account
override fun feed(): List<Note> { override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val loggedInUser = account.userProfile() val loggedInUser = account.userProfile()
val loggedInUserHex = loggedInUser.pubkeyHex val loggedInUserHex = loggedInUser.pubkeyHex
return LocalCache.notes.values.filter { return collection.filter {
it.event !is ChannelCreateEvent && it.event !is ChannelCreateEvent &&
it.event !is ChannelMetadataEvent && it.event !is ChannelMetadataEvent &&
it.event !is LnZapRequestEvent && it.event !is LnZapRequestEvent &&
@@ -23,9 +31,11 @@ object NotificationFeedFilter : FeedFilter<Note>() {
it.event?.isTaggedUser(loggedInUserHex) ?: false && it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && (it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
tagsAnEventByUser(it, loggedInUser) tagsAnEventByUser(it, loggedInUser)
} }.toSet()
.sortedBy { it.createdAt() } }
.reversed()
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
} }
fun tagsAnEventByUser(note: Note, author: User): Boolean { fun tagsAnEventByUser(note: Note, author: User): Boolean {
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
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 kotlin.time.ExperimentalTime
val bottomNavigationItems = listOf( val bottomNavigationItems = listOf(
Route.Home, Route.Home,
@@ -135,6 +136,7 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
} }
} }
@OptIn(ExperimentalTime::class)
@Composable @Composable
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) { private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
@@ -159,13 +161,13 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
LaunchedEffect(key1 = notif) { LaunchedEffect(key1 = notif) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache) hasNewItems = route.hasNewItems(account, notif.cache, emptySet())
} }
} }
LaunchedEffect(key1 = db) { LaunchedEffect(key1 = db) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache) hasNewItems = route.hasNewItems(account, notif.cache, db)
} }
} }
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -11,8 +13,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
@@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun AppNavigation( fun AppNavigation(
navController: NavHostController, navController: NavHostController,
@@ -10,6 +10,8 @@ import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
@@ -17,7 +19,7 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
sealed class Route( sealed class Route(
val route: String, val route: String,
val icon: Int, val icon: Int,
val hasNewItems: (Account, NotificationCache) -> Boolean = { _, _ -> false }, val hasNewItems: (Account, NotificationCache, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _, _ -> false },
val arguments: List<NamedNavArgument> = emptyList() val arguments: List<NamedNavArgument> = emptyList()
) { ) {
val base: String val base: String
@@ -30,7 +32,7 @@ sealed class Route(
navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }, navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false },
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null } navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
), ),
hasNewItems = { accountViewModel, cache -> homeHasNewItems(accountViewModel, cache) } hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
) )
object Search : Route( object Search : Route(
@@ -43,13 +45,13 @@ sealed class Route(
route = "Notification?scrollToTop={scrollToTop}", route = "Notification?scrollToTop={scrollToTop}",
icon = R.drawable.ic_notifications, icon = R.drawable.ic_notifications,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }), arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }),
hasNewItems = { accountViewModel, cache -> notificationHasNewItems(accountViewModel, cache) } hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
) )
object Message : Route( object Message : Route(
route = "Message", route = "Message",
icon = R.drawable.ic_dm, icon = R.drawable.ic_dm,
hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) } hasNewItems = { accountViewModel, cache, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
) )
object BlockedUsers : Route( object BlockedUsers : Route(
@@ -108,36 +110,72 @@ fun currentRoute(navController: NavHostController): String? {
return navBackStackEntry?.destination?.route return navBackStackEntry?.destination?.route
} }
private fun homeHasNewItems(account: Account, cache: NotificationCache): Boolean { open class LatestItem {
val lastTime = cache.load("HomeFollows") var newestItemPerAccount: Map<String, Note?> = mapOf()
HomeNewThreadFeedFilter.account = account fun updateNewestItem(newNotes: Set<Note>, account: Account, filter: AdditiveFeedFilter<Note>): Note? {
val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex]
return ( if (newestItem == null) {
HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() newestItemPerAccount = newestItemPerAccount + Pair(
?: 0 account.userProfile().pubkeyHex,
) > lastTime filter.feed().firstOrNull { it.createdAt() != null }
)
} else {
newestItemPerAccount = newestItemPerAccount + Pair(
account.userProfile().pubkeyHex,
filter.sort(filter.applyFilter(newNotes) + newestItem).first()
)
}
return newestItemPerAccount[account.userProfile().pubkeyHex]
}
} }
private fun notificationHasNewItems(account: Account, cache: NotificationCache): Boolean { object HomeLatestItem : LatestItem() {
val lastTime = cache.load("Notification") fun hasNewItems(
account: Account,
cache: NotificationCache,
newNotes: Set<Note>
): Boolean {
val lastTime = cache.load("HomeFollows")
HomeNewThreadFeedFilter.account = account
NotificationFeedFilter.account = account val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter)
return ( return (newestItem?.createdAt() ?: 0) > lastTime
NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() }
?: 0
) > lastTime
} }
private fun messagesHasNewItems(account: Account, cache: NotificationCache): Boolean { object NotificationLatestItem : LatestItem() {
ChatroomListKnownFeedFilter.account = account fun hasNewItems(
account: Account,
cache: NotificationCache,
newNotes: Set<Note>
): Boolean {
val lastTime = cache.load("Notification")
NotificationFeedFilter.account = account
val note = ChatroomListKnownFeedFilter.feed().firstOrNull { val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter)
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
} ?: return false
val lastTime = cache.load("Room/${note.author?.pubkeyHex}") return (newestItem?.createdAt() ?: 0) > lastTime
}
return (note.createdAt() ?: 0) > lastTime }
object MessagesLatestItem {
fun hasNewItems(
account: Account,
cache: NotificationCache,
newNotes: Set<Note>
): Boolean {
ChatroomListKnownFeedFilter.account = account
val note = ChatroomListKnownFeedFilter.loadTop().firstOrNull {
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
} ?: return false
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
return (note.createdAt() ?: 0) > lastTime
}
} }
@@ -246,30 +246,32 @@ fun ChatroomMessageCompose(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
val event = note.event val event = note.event
if (event is ChannelCreateEvent) { if (event is ChannelCreateEvent) {
val channelInfo = event.channelInfo()
Text( Text(
text = note.author?.toBestDisplayName() text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.created)} " + ( .toString() + " ${stringResource(R.string.created)} " + (
event.channelInfo().name channelInfo.name
?: "" ?: ""
) + " ${stringResource(R.string.with_description_of)} '" + ( ) + " ${stringResource(R.string.with_description_of)} '" + (
event.channelInfo().about channelInfo.about
?: "" ?: ""
) + "', ${stringResource(R.string.and_picture)} '" + ( ) + "', ${stringResource(R.string.and_picture)} '" + (
event.channelInfo().picture channelInfo.picture
?: "" ?: ""
) + "'" ) + "'"
) )
} else if (event is ChannelMetadataEvent) { } else if (event is ChannelMetadataEvent) {
val channelInfo = event.channelInfo()
Text( Text(
text = note.author?.toBestDisplayName() text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
event.channelInfo().name channelInfo.name
?: "" ?: ""
) + "$', {stringResource(R.string.description_to)} '" + ( ) + "$', {stringResource(R.string.description_to)} '" + (
event.channelInfo().about channelInfo.about
?: "" ?: ""
) + "', ${stringResource(R.string.and_picture_to)} '" + ( ) + "', ${stringResource(R.string.and_picture_to)} '" + (
event.channelInfo().picture channelInfo.picture
?: "" ?: ""
) + "'" ) + "'"
) )
@@ -371,6 +373,7 @@ private fun RelayBadges(baseNote: Note) {
) { ) {
RobohashFallbackAsyncImage( RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico", robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico", model = "https://$url/favicon.ico",
contentDescription = stringResource(id = R.string.relay_icon), contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
@@ -26,7 +26,9 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -46,7 +48,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
} else { } else {
var isNew by remember { mutableStateOf<Boolean>(false) } var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = messageSetCard) { LaunchedEffect(key1 = messageSetCard.createdAt()) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
isNew = isNew =
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead) messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
@@ -64,14 +66,29 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
Column( Column(
modifier = Modifier.background(backgroundColor).combinedClickable( modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = { onClick = {
if (noteEvent !is ChannelMessageEvent) { if (noteEvent is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
note.channel()?.let { note.channel()?.let {
navController.navigate("Channel/${it.idHex}") navController.navigate("Channel/${it.idHex}")
} }
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
} else {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} }
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
@@ -61,7 +61,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
} else { } else {
var isNew by remember { mutableStateOf<Boolean>(false) } var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = multiSetCard) { LaunchedEffect(key1 = multiSetCard.createdAt()) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead) isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
@@ -244,7 +244,7 @@ fun FastNoteAuthorPicture(
val userState by author.live().metadata.observeAsState() val userState by author.live().metadata.observeAsState()
val user = userState?.user ?: return val user = userState?.user ?: return
val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
UserPicture( UserPicture(
userHex = user.pubkeyHex, userHex = user.pubkeyHex,
@@ -96,7 +96,6 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
user.nip05()?.let { nip05 -> user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) { if (nip05.split("@").size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Column(modifier = columnModifier) { Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_") { if (nip05.split("@")[0] != "_") {
@@ -108,6 +107,7 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
) )
} }
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
if (nip05Verified == null) { if (nip05Verified == null) {
Icon( Icon(
tint = Color.Yellow, tint = Color.Yellow,
@@ -5,8 +5,6 @@ import android.graphics.Bitmap
import android.util.Log import android.util.Log
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -61,7 +59,6 @@ import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.math.BigDecimal import java.math.BigDecimal
import kotlin.math.ceil
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
@@ -96,7 +93,7 @@ fun NoteCompose(
) )
} }
Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}") Log.d("Time", "Note Compose in $elapsed for ${baseNote.idHex} ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@@ -170,6 +167,8 @@ fun NoteComposeInner(
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController) ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
} else if (noteEvent is BadgeDefinitionEvent) { } else if (noteEvent is BadgeDefinitionEvent) {
BadgeDisplay(baseNote = note) BadgeDisplay(baseNote = note)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note)
} else { } else {
var isNew by remember { mutableStateOf<Boolean>(false) } var isNew by remember { mutableStateOf<Boolean>(false) }
@@ -299,7 +298,7 @@ fun NoteComposeInner(
} }
if (noteEvent is RepostEvent) { if (noteEvent is RepostEvent) {
note.replyTo?.lastOrNull()?.let { baseNote.replyTo?.lastOrNull()?.let {
RelayBadges(it) RelayBadges(it)
} }
} else { } else {
@@ -496,13 +495,13 @@ fun NoteComposeInner(
noteEvent.recipientPubKey() != loggedIn.pubkeyHex && noteEvent.recipientPubKey() != loggedIn.pubkeyHex &&
note.author !== loggedIn note.author !== loggedIn
) { ) {
val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) } val recipient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
TranslatableRichTextViewer( TranslatableRichTextViewer(
stringResource( stringResource(
id = R.string.private_conversation_notification, id = R.string.private_conversation_notification,
"@${note.author?.pubkeyNpub()}", "@${note.author?.pubkeyNpub()}",
"@${recepient?.pubkeyNpub()}" "@${recipient?.pubkeyNpub()}"
), ),
canPreview = !makeItShort, canPreview = !makeItShort,
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
@@ -582,7 +581,9 @@ fun DisplayFollowingHashtagsInPost(
var firstTag by remember { mutableStateOf<String?>(null) } var firstTag by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = noteEvent) { LaunchedEffect(key1 = noteEvent) {
firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet()) withContext(Dispatchers.IO) {
firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
}
} }
Column() { Column() {
@@ -777,6 +778,30 @@ fun BadgeDisplay(baseNote: Note) {
} }
} }
@Composable
fun FileHeaderDisplay(note: Note) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
val blurHash = event.blurhash()
val hash = event.hash()
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
if (isImage || isVideo) {
val content = if (isImage) {
ZoomableImage(fullUrl, description, hash, blurHash)
} else {
ZoomableVideo(fullUrl, description, hash)
}
ZoomableContentView(content = content, listOf(content))
} else {
UrlPreview(fullUrl, "$fullUrl ")
}
}
@Composable @Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) { private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row( Row(
@@ -869,67 +894,102 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: U
@Composable @Composable
private fun RelayBadges(baseNote: Note) { private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState() val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet() val noteRelays = noteRelaysState?.note ?: return
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var showShowMore by remember { mutableStateOf(false) }
var lazyRelayList by remember { mutableStateOf(emptyList<String>()) }
val relaysToDisplay = (if (expanded) noteRelays else noteRelays.take(3)).toList() LaunchedEffect(key1 = noteRelaysState, key2 = expanded) {
val height = (ceil(relaysToDisplay.size / 3.0f) * 17).dp withContext(Dispatchers.IO) {
val relayList = noteRelays.relays.map {
it.removePrefix("wss://").removePrefix("ws://")
}
val uri = LocalUriHandler.current val relaysToDisplay = if (expanded) relayList else relayList.take(3)
val shouldListChange = lazyRelayList.size < 3 || lazyRelayList.size != relayList.size
Spacer(Modifier.height(10.dp)) if (shouldListChange) {
lazyRelayList = relaysToDisplay
}
LazyVerticalGrid( val nextShowMore = relayList.size > 3 && !expanded
columns = GridCells.Fixed(3), if (nextShowMore != showShowMore) {
contentPadding = PaddingValues(start = 4.dp, end = 4.dp), // only triggers recomposition when actually different
modifier = Modifier.height(height), showShowMore = nextShowMore
userScrollEnabled = false
) {
items(relaysToDisplay.size) {
val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://")
Box(
Modifier
.padding(1.dp)
.size(15.dp)
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
model = "https://$url/favicon.ico",
contentDescription = stringResource(R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.width(13.dp)
.height(13.dp)
.clip(shape = CircleShape)
// .border(1.dp, Color.Red)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
} }
} }
} }
if (noteRelays.size > 3 && !expanded) { Spacer(Modifier.height(10.dp))
Row(
Modifier VerticalRelayPanelWithFlow(lazyRelayList)
.fillMaxWidth()
.height(25.dp), if (showShowMore) {
horizontalArrangement = Arrangement.Center, ShowMoreRelaysButton {
verticalAlignment = Alignment.Top expanded = true
}
}
}
@Composable
@Stable
private fun VerticalRelayPanelWithFlow(
relays: List<String>
) {
// FlowRow Seems to be a lot faster than LazyVerticalGrid
FlowRow() {
relays.forEach { url ->
RelayIconCompose(url)
}
}
}
@Composable
@Stable
private fun RelayIconCompose(url: String) {
val uri = LocalUriHandler.current
Box(
Modifier
.padding(1.dp)
.size(15.dp)
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.width(13.dp)
.height(13.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
}
}
@Composable
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.height(25.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.Top
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = onClick
) { ) {
IconButton( Icon(
modifier = Modifier.then(Modifier.size(24.dp)), imageVector = Icons.Default.ExpandMore,
onClick = { expanded = true } null,
) { modifier = Modifier.size(15.dp),
Icon( tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
imageVector = Icons.Default.ExpandMore, )
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
} }
} }
} }
@@ -968,6 +1028,7 @@ fun NoteAuthorPicture(
if (author == null) { if (author == null) {
RobohashAsyncImage( RobohashAsyncImage(
robot = "authornotfound", robot = "authornotfound",
robotSize = size,
contentDescription = stringResource(R.string.unknown_author), contentDescription = stringResource(R.string.unknown_author),
modifier = modifier modifier = modifier
.width(size) .width(size)
@@ -1108,12 +1169,14 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
} }
LaunchedEffect(key1 = note) { LaunchedEffect(key1 = note) {
state = DropDownParams( withContext(Dispatchers.IO) {
accountViewModel.isFollowing(note.author), state = DropDownParams(
accountViewModel.isInPrivateBookmarks(note), accountViewModel.isFollowing(note.author),
accountViewModel.isInPublicBookmarks(note), accountViewModel.isInPrivateBookmarks(note),
accountViewModel.isLoggedUser(note.author) accountViewModel.isInPublicBookmarks(note),
) accountViewModel.isLoggedUser(note.author)
)
}
} }
DropdownMenu( DropdownMenu(
@@ -48,9 +48,6 @@ fun PollNote(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
navController: NavController navController: NavController
) { ) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val zapsState by baseNote.live().zaps.observeAsState() val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note ?: return val zappedNote = zapsState?.note ?: return
@@ -72,7 +69,7 @@ fun PollNote(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp) modifier = Modifier.padding(vertical = 3.dp)
) { ) {
if (zappedNote.author == account.userProfile() || zappedNote.isZappedBy(account.userProfile())) { if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) {
ZapVote( ZapVote(
baseNote, baseNote,
accountViewModel, accountViewModel,
@@ -90,7 +87,7 @@ fun PollNote(
LinearProgressIndicator( LinearProgressIndicator(
modifier = Modifier.matchParentSize(), modifier = Modifier.matchParentSize(),
color = color, color = color,
progress = optionTally progress = optionTally.toFloat()
) )
Row( Row(
@@ -101,7 +98,7 @@ fun PollNote(
modifier = Modifier.padding(horizontal = 10.dp).width(40.dp) modifier = Modifier.padding(horizontal = 10.dp).width(40.dp)
) { ) {
Text( Text(
text = "${(optionTally * 100).roundToInt()}%", text = "${(optionTally.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
@@ -210,7 +207,7 @@ fun ZapVote(
) )
.show() .show()
} }
} else if (zappedNote?.author == account.userProfile()) { } else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
scope.launch { scope.launch {
Toast Toast
.makeText( .makeText(
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode
import java.util.* import java.util.*
class PollNoteViewModel { class PollNoteViewModel {
@@ -16,7 +17,9 @@ class PollNoteViewModel {
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: Float? = null var consensusThreshold: BigDecimal? = null
var totalZapped: BigDecimal = BigDecimal.ZERO
fun load(note: Note?) { fun load(note: Note?) {
pollNote = note pollNote = note
@@ -24,8 +27,10 @@ class PollNoteViewModel {
pollOptions = pollEvent?.pollOptions() pollOptions = pollEvent?.pollOptions()
valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM) valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM)
valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM) valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM)
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100) consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
closedAt = pollEvent?.getTagInt(CLOSED_AT) closedAt = pollEvent?.getTagInt(CLOSED_AT)
totalZapped = totalZapped()
} }
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
@@ -73,47 +78,45 @@ class PollNoteViewModel {
return false return false
} }
fun optionVoteTally(op: Int): Float { fun optionVoteTally(op: Int): BigDecimal {
val tally = zappedPollOptionAmount(op).toFloat().div(zappedVoteTotal()) return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
return if (tally.isNaN()) { // catch div by 0 zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP)
0f } else {
} else { tally } BigDecimal.ZERO
}
private fun zappedVoteTotal(): Float {
var total = 0f
pollOptions?.keys?.forEach {
total += zappedPollOptionAmount(it).toFloat()
} }
return total
} }
fun isPollOptionZappedBy(option: Int, user: User): Boolean { fun isPollOptionZappedBy(option: Int, user: User): Boolean {
if (pollNote?.zaps?.any { it.key.author == user } == true) { if (pollNote?.zaps?.any { it.key.author === user } == true) {
pollNote!!.zaps.mapNotNull { it.value?.event } pollNote!!.zaps
.filterIsInstance<LnZapEvent>() .any {
.map { val event = it.value?.event as? LnZapEvent
val zappedOption = it.zappedPollOption() event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex
if (zappedOption == option && it.zappedRequestAuthor() == user.pubkeyHex) {
return true
}
} }
} }
return false return false
} }
fun zappedPollOptionAmount(option: Int): BigDecimal { fun zappedPollOptionAmount(option: Int): BigDecimal {
return if (pollNote != null) { return pollNote?.zaps?.values?.sumOf {
pollNote!!.zaps.mapNotNull { it.value?.event } val event = it?.event as? LnZapEvent
.filterIsInstance<LnZapEvent>() if (event?.zappedPollOption() == option) {
.mapNotNull { event.amount ?: BigDecimal(0)
val zappedOption = it.zappedPollOption() } else {
if (zappedOption == option) { BigDecimal(0)
it.amount }
} else { null } } ?: BigDecimal(0)
}.sumOf { it } }
} else {
BigDecimal(0) fun totalZapped(): BigDecimal {
} return pollNote?.zaps?.values?.sumOf {
val zapEvent = (it?.event as? LnZapEvent)
if (zapEvent?.zappedPollOption() != null) {
zapEvent.amount ?: BigDecimal(0)
} else {
BigDecimal(0)
}
} ?: BigDecimal(0)
} }
} }
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -87,26 +86,28 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account) NewPostView({ wantsToQuote = null }, null, wantsToQuote, account)
} }
Row( Spacer(modifier = Modifier.height(8.dp))
modifier = Modifier
.padding(top = 8.dp) Row(verticalAlignment = CenterVertically) {
.fillMaxWidth(), Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
verticalAlignment = Alignment.CenterVertically, ReplyReaction(baseNote, accountViewModel) {
horizontalArrangement = Arrangement.SpaceBetween wantsToReplyTo = baseNote
) { }
ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
wantsToReplyTo = baseNote
} }
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) { BoostReaction(baseNote, accountViewModel) {
wantsToQuote = baseNote wantsToQuote = baseNote
}
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
LikeReaction(baseNote, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ZapReaction(baseNote, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ViewCountReaction(baseNote.idHex)
} }
LikeReaction(baseNote, accountViewModel, Modifier.weight(1f))
ZapReaction(baseNote, accountViewModel, Modifier.weight(1f))
ViewCountReaction(baseNote, Modifier.weight(1f))
} }
} }
@@ -114,14 +115,12 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
fun ReplyReaction( fun ReplyReaction(
baseNote: Note, baseNote: Note,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
showCounter: Boolean = true, showCounter: Boolean = true,
onPress: () -> Unit onPress: () -> Unit
) { ) {
val repliesState by baseNote.live().replies.observeAsState() val repliesState by baseNote.live().replies.observeAsState()
val replies = repliesState?.note?.replies ?: emptySet() val replies = repliesState?.note?.replies ?: emptySet()
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -141,29 +140,32 @@ fun ReplyReaction(
} }
} }
) { ) {
Icon( ReplyIcon()
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
tint = grayTint
)
} }
if (showCounter) { if (showCounter) {
Text( Text(
" ${showCount(replies.size)}", " ${showCount(replies.size)}",
fontSize = 14.sp, fontSize = 14.sp,
color = grayTint, color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
modifier = textModifier
) )
} }
} }
@Composable
private fun ReplyIcon() {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@Composable @Composable
private fun BoostReaction( private fun BoostReaction(
baseNote: Note, baseNote: Note,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
onQuotePress: () -> Unit onQuotePress: () -> Unit
) { ) {
val boostsState by baseNote.live().boosts.observeAsState() val boostsState by baseNote.live().boosts.observeAsState()
@@ -229,16 +231,14 @@ private fun BoostReaction(
Text( Text(
" ${showCount(boostedNote?.boosts?.size)}", " ${showCount(boostedNote?.boosts?.size)}",
fontSize = 14.sp, fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
modifier = textModifier
) )
} }
@Composable @Composable
fun LikeReaction( fun LikeReaction(
baseNote: Note, baseNote: Note,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel
textModifier: Modifier = Modifier
) { ) {
val reactionsState by baseNote.live().reactions.observeAsState() val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note ?: return val reactedNote = reactionsState?.note ?: return
@@ -287,8 +287,7 @@ fun LikeReaction(
Text( Text(
" ${showCount(reactedNote.reactions.size)}", " ${showCount(reactedNote.reactions.size)}",
fontSize = 14.sp, fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
modifier = textModifier
) )
} }
@@ -457,13 +456,13 @@ fun ZapReaction(
} }
@Composable @Composable
private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) { private fun ViewCountReaction(idHex: String) {
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton( IconButton(
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") } onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") }
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.BarChart, imageVector = Icons.Outlined.BarChart,
@@ -473,10 +472,10 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier)
) )
} }
Row(modifier = textModifier) { Row() {
AsyncImage( AsyncImage(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(LocalContext.current)
.data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000") .data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000")
.diskCachePolicy(CachePolicy.DISABLED) .diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED)
.build(), .build(),
@@ -20,11 +20,9 @@ import com.vitorpamplona.amethyst.model.*
@Composable @Composable
fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) { fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) {
val sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowingCached(it) }
ReplyInformation(replyTo, sortedMentions, account) { ReplyInformation(replyTo, dupMentions, account) {
navController.navigate("User/${it.pubkeyHex}") navController.navigate("User/${it.pubkeyHex}")
} }
} }
@@ -43,7 +43,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
var isNew by remember { mutableStateOf<Boolean>(false) } var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = zapSetCard) { LaunchedEffect(key1 = zapSetCard.createdAt()) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead) isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
@@ -5,7 +5,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
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.LocalCacheState
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
@@ -15,7 +14,9 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -30,7 +31,7 @@ import kotlin.time.measureTimedValue
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter) class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter)
open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() { open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading) private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
val feedContent = _feedContent.asStateFlow() val feedContent = _feedContent.asStateFlow()
@@ -46,28 +47,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
@Synchronized @Synchronized
private fun refreshSuspended() { private fun refreshSuspended() {
val notes = dataSource.loadTop() val notes = localFilter.loadTop()
val thisAccount = (dataSource as? NotificationFeedFilter)?.account val thisAccount = (localFilter as? NotificationFeedFilter)?.account
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
val oldNotesState = feedContent.value val oldNotesState = _feedContent.value
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) { if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
val newCards = convertToCard(notes.minus(lastNotesCopy)) val newCards = convertToCard(notes.minus(lastNotesCopy))
if (newCards.isNotEmpty()) { if (newCards.isNotEmpty()) {
lastNotes = notes lastNotes = notes
lastAccount = (dataSource as? NotificationFeedFilter)?.account lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed()) updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
} }
} else { } else {
val cards = convertToCard(notes) val cards = convertToCard(notes)
lastNotes = notes lastNotes = notes
lastAccount = (dataSource as? NotificationFeedFilter)?.account lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed(cards) updateFeed(cards)
} }
} }
private fun convertToCard(notes: List<Note>): List<Card> { private fun convertToCard(notes: Collection<Note>): List<Card> {
val reactionsPerEvent = mutableMapOf<Note, MutableList<Note>>() val reactionsPerEvent = mutableMapOf<Note, MutableList<Note>>()
notes notes
.filter { it.event is ReactionEvent } .filter { it.event is ReactionEvent }
@@ -159,7 +160,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
private fun updateFeed(notes: List<Card>) { private fun updateFeed(notes: List<Card>) {
val scope = CoroutineScope(Job() + Dispatchers.Main) val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch { scope.launch {
val currentState = feedContent.value val currentState = _feedContent.value
if (notes.isEmpty()) { if (notes.isEmpty()) {
_feedContent.update { CardFeedState.Empty } _feedContent.update { CardFeedState.Empty }
@@ -172,6 +173,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
} }
} }
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) {
val filteredNewList = localFilter.applyFilter(newItems)
val actuallyNew = filteredNewList.minus(lastNotesCopy)
val newCards = convertToCard(actuallyNew)
if (newCards.isNotEmpty()) {
lastNotes = lastNotesCopy + newItems
lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
}
} else {
// Refresh Everything
refreshSuspended()
}
}
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
private val bundler = BundledUpdate(250, Dispatchers.IO) { private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
@@ -181,13 +204,29 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
} }
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
} }
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateData() { fun invalidateData() {
bundler.invalidate() bundler.invalidate()
} }
private val cacheListener: (LocalCacheState) -> Unit = { @OptIn(ExperimentalTime::class)
invalidateData() fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) {
val (value, elapsed) = measureTimedValue {
refreshFromOldState(it.flatten().toSet())
}
Log.d("Time", "${this.javaClass.simpleName} Card additive update $elapsed")
}
}
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
} }
init { init {
@@ -3,9 +3,10 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
@@ -65,7 +66,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
fun refreshSuspended() { fun refreshSuspended() {
val notes = newListFromDataSource() val notes = newListFromDataSource()
val oldNotesState = feedContent.value val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) { if (oldNotesState is FeedState.Loaded) {
// Using size as a proxy for has changed. // Using size as a proxy for has changed.
if (notes != oldNotesState.feed.value) { if (notes != oldNotesState.feed.value) {
@@ -79,7 +80,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private fun updateFeed(notes: List<Note>) { private fun updateFeed(notes: List<Note>) {
val scope = CoroutineScope(Job() + Dispatchers.Main) val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch { scope.launch {
val currentState = feedContent.value val currentState = _feedContent.value
if (notes.isEmpty()) { if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty } _feedContent.update { FeedState.Empty }
} else if (currentState is FeedState.Loaded) { } else if (currentState is FeedState.Loaded) {
@@ -91,18 +92,41 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
} }
} }
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) {
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet())
updateFeed(newList)
} else {
// Refresh Everything
refreshSuspended()
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO) { private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines. // holding off new updates in case of heavy refresh routines.
refreshSuspended() refreshSuspended()
} }
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateData() { fun invalidateData() {
bundler.invalidate() bundler.invalidate()
} }
private val cacheListener: (LocalCacheState) -> Unit = { fun invalidateInsertData(newItems: Set<Note>) {
invalidateData() bundlerInsert.invalidateList(newItems) {
refreshFromOldState(it.flatten().toSet())
}
}
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
} }
init { init {
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
@@ -32,7 +31,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
private fun refreshSuspended() { private fun refreshSuspended() {
val notes = dataSource.loadTop() val notes = dataSource.loadTop()
val oldNotesState = feedContent.value val oldNotesState = _feedContent.value
if (oldNotesState is LnZapFeedState.Loaded) { if (oldNotesState is LnZapFeedState.Loaded) {
// Using size as a proxy for has changed. // Using size as a proxy for has changed.
if (notes != oldNotesState.feed.value) { if (notes != oldNotesState.feed.value) {
@@ -46,7 +45,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
private fun updateFeed(notes: List<Pair<Note, Note>>) { private fun updateFeed(notes: List<Pair<Note, Note>>) {
val scope = CoroutineScope(Job() + Dispatchers.Main) val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch { scope.launch {
val currentState = feedContent.value val currentState = _feedContent.value
if (notes.isEmpty()) { if (notes.isEmpty()) {
_feedContent.update { LnZapFeedState.Empty } _feedContent.update { LnZapFeedState.Empty }
} else if (currentState is LnZapFeedState.Loaded) { } else if (currentState is LnZapFeedState.Loaded) {
@@ -68,7 +67,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
bundler.invalidate() bundler.invalidate()
} }
private val cacheListener: (LocalCacheState) -> Unit = { private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
invalidateData() invalidateData()
} }
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
@@ -36,7 +36,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
private fun refreshSuspended() { private fun refreshSuspended() {
val notes = dataSource.loadTop() val notes = dataSource.loadTop()
val oldNotesState = feedContent.value val oldNotesState = _feedContent.value
if (oldNotesState is UserFeedState.Loaded) { if (oldNotesState is UserFeedState.Loaded) {
// Using size as a proxy for has changed. // Using size as a proxy for has changed.
if (notes != oldNotesState.feed.value) { if (notes != oldNotesState.feed.value) {
@@ -50,7 +50,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
private fun updateFeed(notes: List<User>) { private fun updateFeed(notes: List<User>) {
val scope = CoroutineScope(Job() + Dispatchers.Main) val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch { scope.launch {
val currentState = feedContent.value val currentState = _feedContent.value
if (notes.isEmpty()) { if (notes.isEmpty()) {
_feedContent.update { UserFeedState.Empty } _feedContent.update { UserFeedState.Empty }
} else if (currentState is UserFeedState.Loaded) { } else if (currentState is UserFeedState.Loaded) {
@@ -72,7 +72,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
bundler.invalidate() bundler.invalidate()
} }
private val cacheListener: (LocalCacheState) -> Unit = { private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
invalidateData() invalidateData()
} }
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab import androidx.compose.material.Tab
import androidx.compose.material.TabRow import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -18,10 +20,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
@@ -30,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavController) { fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -57,13 +55,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon
TabRow( TabRow(
backgroundColor = MaterialTheme.colors.background, backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
}
) { ) {
Tab( Tab(
selected = pagerState.currentPage == 0, selected = pagerState.currentPage == 0,
@@ -80,7 +72,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon
} }
) )
} }
HorizontalPager(count = 2, state = pagerState) { HorizontalPager(pageCount = 2, state = pagerState) {
when (pagerState.currentPage) { when (pagerState.currentPage) {
0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null) 0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null)
1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null) 1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null)
@@ -89,6 +89,8 @@ fun ChannelScreen(
val context = LocalContext.current val context = LocalContext.current
val channelScreenModel: NewPostViewModel = viewModel() val channelScreenModel: NewPostViewModel = viewModel()
channelScreenModel.account = account
if (account != null && channelId != null) { if (account != null && channelId != null) {
val replyTo = remember { mutableStateOf<Note?>(null) } val replyTo = remember { mutableStateOf<Note?>(null) }
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.ExperimentalFoundationApi
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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -7,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon import androidx.compose.material.Icon
@@ -14,7 +17,6 @@ import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab import androidx.compose.material.Tab
import androidx.compose.material.TabRow import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
@@ -37,10 +39,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
@@ -50,7 +48,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) { fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) {
val pagerState = rememberPagerState() val pagerState = rememberPagerState()
@@ -68,13 +66,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
Box(Modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth()) {
TabRow( TabRow(
backgroundColor = MaterialTheme.colors.background, backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
}
) { ) {
Tab( Tab(
selected = pagerState.currentPage == 0, selected = pagerState.currentPage == 0,
@@ -115,7 +107,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
} }
} }
HorizontalPager(count = 2, state = pagerState) { HorizontalPager(pageCount = 2, state = pagerState) {
when (pagerState.currentPage) { when (pagerState.currentPage) {
0 -> TabKnown(accountViewModel, navController, markKnownAsRead) 0 -> TabKnown(accountViewModel, navController, markKnownAsRead)
1 -> TabNew(accountViewModel, navController, markNewAsRead) 1 -> TabNew(accountViewModel, navController, markNewAsRead)
@@ -67,6 +67,8 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
val context = LocalContext.current val context = LocalContext.current
val chatRoomScreenModel: NewPostViewModel = viewModel() val chatRoomScreenModel: NewPostViewModel = viewModel()
chatRoomScreenModel.account = account
if (account != null && userId != null) { if (account != null && userId != null) {
val replyTo = remember { mutableStateOf<Note?>(null) } val replyTo = remember { mutableStateOf<Note?>(null) }
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab import androidx.compose.material.Tab
import androidx.compose.material.TabRow import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -17,17 +19,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedView import com.vitorpamplona.amethyst.ui.screen.UserFeedView
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavController) { fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -45,13 +43,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont
TabRow( TabRow(
backgroundColor = MaterialTheme.colors.background, backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
}
) { ) {
Tab( Tab(
selected = pagerState.currentPage == 0, selected = pagerState.currentPage == 0,
@@ -61,7 +53,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont
} }
) )
} }
HorizontalPager(count = 1, state = pagerState) { HorizontalPager(pageCount = 1, state = pagerState) {
when (pagerState.currentPage) { when (pagerState.currentPage) {
0 -> UserFeedView(feedViewModel, accountViewModel, navController) 0 -> UserFeedView(feedViewModel, accountViewModel, navController)
} }
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab import androidx.compose.material.Tab
import androidx.compose.material.TabRow import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -23,10 +25,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NavController import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
@@ -39,7 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen(
homeFeedViewModel: NostrHomeFeedViewModel, homeFeedViewModel: NostrHomeFeedViewModel,
@@ -90,13 +88,7 @@ fun HomeScreen(
) { ) {
TabRow( TabRow(
backgroundColor = MaterialTheme.colors.background, backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
}
) { ) {
Tab( Tab(
selected = pagerState.currentPage == 0, selected = pagerState.currentPage == 0,
@@ -114,7 +106,7 @@ fun HomeScreen(
} }
) )
} }
HorizontalPager(count = 2, state = pagerState) { HorizontalPager(pageCount = 2, state = pagerState) {
when (pagerState.currentPage) { when (pagerState.currentPage) {
0 -> FeedView(homeFeedViewModel, accountViewModel, navController, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS, scrollToTop) 0 -> FeedView(homeFeedViewModel, accountViewModel, navController, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS, scrollToTop)
1 -> FeedView(repliesFeedViewModel, accountViewModel, navController, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES, scrollToTop) 1 -> FeedView(repliesFeedViewModel, accountViewModel, navController, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES, scrollToTop)
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.activity.compose.BackHandler
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -19,6 +20,7 @@ import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
@@ -34,10 +36,12 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.currentRoute import com.vitorpamplona.amethyst.ui.navigation.currentRoute
import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) { fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) {
val coroutineScope = rememberCoroutineScope()
val navController = rememberNavController() val navController = rememberNavController()
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
val sheetState = rememberModalBottomSheetState( val sheetState = rememberModalBottomSheetState(
@@ -64,6 +68,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
}, },
drawerContent = { drawerContent = {
DrawerContent(navController, scaffoldState, sheetState, accountViewModel) DrawerContent(navController, scaffoldState, sheetState, accountViewModel)
BackHandler(enabled = scaffoldState.drawerState.isOpen) {
coroutineScope.launch { scaffoldState.drawerState.close() }
}
}, },
floatingActionButton = { floatingActionButton = {
FloatingButtons(navController, accountStateViewModel) FloatingButtons(navController, accountStateViewModel)
@@ -5,6 +5,8 @@ import android.net.Uri
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
@@ -48,10 +50,6 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
@@ -70,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
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.ZoomableImageDialog import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
@@ -98,7 +97,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.math.BigDecimal import java.math.BigDecimal
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) { fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -187,12 +186,6 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
ScrollableTabRow( ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background, backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
},
edgePadding = 8.dp, edgePadding = 8.dp,
modifier = Modifier.onSizeChanged { modifier = Modifier.onSizeChanged {
tabsSize = it tabsSize = it
@@ -268,7 +261,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
} }
} }
HorizontalPager( HorizontalPager(
count = 8, pageCount = 8,
state = pagerState, state = pagerState,
modifier = with(LocalDensity.current) { modifier = with(LocalDensity.current) {
Modifier.height((columnSize.height - tabsSize.height).toDp()) Modifier.height((columnSize.height - tabsSize.height).toDp())
@@ -409,8 +402,9 @@ private fun ProfileHeader(
} }
} }
if (zoomImageDialogOpen) { val profilePic = baseUser.profilePicture()
ZoomableImageDialog(baseUser.profilePicture()!!, onDismiss = { zoomImageDialogOpen = false }) if (zoomImageDialogOpen && profilePic != null) {
ZoomableImageDialog(figureOutMimeType(profilePic), onDismiss = { zoomImageDialogOpen = false })
} }
} }
@@ -498,7 +492,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
userBadge.acceptedBadges?.let { note -> userBadge.acceptedBadges?.let { note ->
(note.event as? BadgeProfilesEvent)?.let { event -> (note.event as? BadgeProfilesEvent)?.let { event ->
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { FlowRow(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp)) {
event.badgeAwardEvents().forEach { badgeAwardEvent -> event.badgeAwardEvents().forEach { badgeAwardEvent ->
val baseNote = LocalCache.notes[badgeAwardEvent] val baseNote = LocalCache.notes[badgeAwardEvent]
if (baseNote != null) { if (baseNote != null) {
@@ -655,6 +649,7 @@ fun BadgeThumb(
if (image == null) { if (image == null) {
RobohashAsyncImage( RobohashAsyncImage(
robot = "authornotfound", robot = "authornotfound",
robotSize = size,
contentDescription = stringResource(R.string.unknown_author), contentDescription = stringResource(R.string.unknown_author),
modifier = pictureModifier modifier = pictureModifier
.width(size) .width(size)
@@ -664,6 +659,7 @@ fun BadgeThumb(
} else { } else {
RobohashFallbackAsyncImage( RobohashFallbackAsyncImage(
robot = note.idHex, robot = note.idHex,
robotSize = size,
model = image, model = image,
contentDescription = stringResource(id = R.string.profile_image), contentDescription = stringResource(id = R.string.profile_image),
modifier = pictureModifier modifier = pictureModifier
@@ -712,7 +708,7 @@ private fun DrawBanner(baseUser: User) {
) )
if (zoomImageDialogOpen) { if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = banner, onDismiss = { zoomImageDialogOpen = false }) ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false })
} }
} else { } else {
Image( Image(
+21
View File
@@ -263,9 +263,30 @@
<string name="wallet_connect_service_secret_placeholder">nsec / hex privát kulcs</string> <string name="wallet_connect_service_secret_placeholder">nsec / hex privát kulcs</string>
<string name="pledge_amount_in_sats">Hozzájárulás összege sats-ban</string> <string name="pledge_amount_in_sats">Hozzájárulás összege sats-ban</string>
<string name="post_poll">Szavazás Létrehozása</string>
<string name="poll_heading_required">Szükséges mezők:</string>
<string name="poll_zap_recipients">Zap-et kapják</string>
<string name="poll_primary_description">Szavazás elsődleges leírása…</string>
<string name="poll_option_index">Szavazás %s megoszlása</string>
<string name="poll_option_description">Szavazás opcióinak leírása</string>
<string name="poll_heading_optional">Kiegészítő mezők:</string>
<string name="poll_zap_value_min">Zap minimum</string>
<string name="poll_zap_value_max">Zap maximum</string>
<string name="poll_consensus_threshold">Konszenzus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Szavazás lezárása</string>
<string name="poll_closing_time_days">napok</string>
<string name="poll_is_closed">A szavazásra nem lehet már új szavazatot leadni</string>
<string name="poll_zap_amount">Zap összege</string>
<string name="one_vote_per_user_on_atomic_votes">Az ilyen típusú szavazásokon felhasználónként csak egy szavazat engedélyezett</string>
<string name="looking_for_event">"%1$s esemény keresése"</string> <string name="looking_for_event">"%1$s esemény keresése"</string>
<string name="custom_zaps_add_a_message">Nyilvános üzenet hozzáadása</string> <string name="custom_zaps_add_a_message">Nyilvános üzenet hozzáadása</string>
<string name="custom_zaps_add_a_message_example">Köszönöm a kemény munkát!</string> <string name="custom_zaps_add_a_message_example">Köszönöm a kemény munkát!</string>
<string name="lightning_create_and_add_invoice">Létrehoz és Hozzáad</string>
<string name="poll_author_no_vote">A szavazás létrehozója sajátjára nem szavazhat.</string>
<string name="poll_hashtag" translatable="false">#zappoll</string>
</resources> </resources>
+59
View File
@@ -226,4 +226,63 @@
<string name="account_switch_has_private_key">Heeft privésleutel</string> <string name="account_switch_has_private_key">Heeft privésleutel</string>
<string name="account_switch_pubkey_only">Alleen lezen, geen privésleutel</string> <string name="account_switch_pubkey_only">Alleen lezen, geen privésleutel</string>
<string name="back">Terug</string> <string name="back">Terug</string>
<string name="report_dialog_spam">Spam of oplichting</string>
<string name="report_dialog_profanity">Godslastering of haatdragend gedrag</string>
<string name="report_dialog_impersonation">Kwaadwillige imitatie</string>
<string name="report_dialog_nudity">Naaktheid of grafische inhoud</string>
<string name="report_dialog_illegal">Illegaal gedrag</string>
<string name="report_dialog_blocking_a_user">Als u een gebruiker blokkeert, wordt zijn inhoud in uw app verborgen. Uw notes zijn nog steeds publiekelijk zichtbaar, ook voor mensen die u blokkeert. Geblokkeerde gebruikers worden vermeld in het scherm Beveiligingsfilters.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blokkeer & verberg gebruiker]]></string>
<string name="report_dialog_report_btn">Misbruik melden</string>
<string name="report_dialog_reminder_public">Alle geplaatste rapporteringen zijn openbaar.</string>
<string name="report_dialog_additional_reason_placeholder">Geef optioneel extra context over uw rapportering...</string>
<string name="report_dialog_additional_reason_label">Extra context</string>
<string name="report_dialog_select_reason_label">Reden</string>
<string name="report_dialog_select_reason_placeholder">Selecteer een reden…</string>
<string name="report_dialog_post_report_btn">Rapporteer</string>
<string name="report_dialog_title">Blokkeer en rapporteer</string>
<string name="block_only">Blokkeren</string>
<string name="bookmarks">Bladwijzers</string>
<string name="private_bookmarks">Privé bladwijzers</string>
<string name="public_bookmarks">PPublieke Bladwijzers</string>
<string name="add_to_private_bookmarks">Toevoegen aan privé bladwijzers</string>
<string name="add_to_public_bookmarks">Toevoegen aan publieke bladwijzers</string>
<string name="remove_from_private_bookmarks">Verwijderen uit privé bladwijzers</string>
<string name="remove_from_public_bookmarks">Verwijderen uit publieke bladwijzers</string>
<string name="wallet_connect_service">Wallet Connect Service</string>
<string name="wallet_connect_service_explainer">Machtigt een Nostr Secret om zaps te betalen zonder de app te verlaten. Houd het geheim veilig en gebruik indien mogelijk een privé relays.</string>
<string name="wallet_connect_service_pubkey">Wallet Connect publieke sleutel</string>
<string name="wallet_connect_service_relay">Wallet Connect Relay</string>
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
<string name="wallet_connect_service_show_secret">Toon secret sleutel</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex privésleutel</string>
<string name="pledge_amount_in_sats">Bedrag in sats</string>
<string name="post_poll">Verzend poll</string>
<string name="poll_heading_required">Verplichte velden:</string>
<string name="poll_zap_recipients">Zap ontvangers</string>
<string name="poll_primary_description">Primaire poll beschrijving…</string>
<string name="poll_option_index">Optie %s</string>
<string name="poll_option_description">Poll optie beschrijvingen</string>
<string name="poll_heading_optional">Optionele velden:</string>
<string name="poll_zap_value_min">Zap minimum</string>
<string name="poll_zap_value_max">Zap maximum</string>
<string name="poll_consensus_threshold">Consensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Sluit na</string>
<string name="poll_closing_time_days">dagen</string>
<string name="poll_is_closed">Poll is gesloten voor nieuwe stemmen</string>
<string name="poll_zap_amount">Zap bedrag</string>
<string name="one_vote_per_user_on_atomic_votes">Slechts één stem per gebruiker is toegestaan bij dit type peiling.</string>
<string name="looking_for_event">"Zoeken naar event %1$s"</string>
<string name="custom_zaps_add_a_message">Een openbaar bericht toevoegen</string>
<string name="custom_zaps_add_a_message_example">Bedankt voor al jullie werk!</string>
<string name="lightning_create_and_add_invoice">Maken en toevoegen</string>
<string name="poll_author_no_vote">Poll auteurs kunnen niet stemmen in hun eigen poll.</string>
<string name="poll_hashtag" translatable="false">#zappoll</string>
</resources> </resources>
+5
View File
@@ -292,4 +292,9 @@
<string name="lightning_create_and_add_invoice">Create and Add</string> <string name="lightning_create_and_add_invoice">Create and Add</string>
<string name="poll_author_no_vote">Poll authors can\'t vote in their own polls.</string> <string name="poll_author_no_vote">Poll authors can\'t vote in their own polls.</string>
<string name="poll_hashtag" translatable="false">#zappoll</string> <string name="poll_hashtag" translatable="false">#zappoll</string>
<string name="hash_verification_passed">Image is the same since the post</string>
<string name="hash_verification_failed">Image has changed. The author might not have seen the change</string>
</resources> </resources>
+3 -3
View File
@@ -2,7 +2,7 @@ buildscript {
ext { ext {
fragment_version = "1.5.6" fragment_version = "1.5.6"
lifecycle_version = '2.6.1' lifecycle_version = '2.6.1'
compose_ui_version = '1.5.0-alpha01' compose_ui_version = '1.5.0-alpha02'
nav_version = "2.5.3" nav_version = "2.5.3"
room_version = "2.4.3" room_version = "2.4.3"
accompanist_version = '0.30.0' accompanist_version = '0.30.0'
@@ -10,8 +10,8 @@ buildscript {
} }
}// Top-level build file where you can add configuration options common to all sub-projects/modules. }// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id 'com.android.application' version '7.4.2' apply false id 'com.android.application' version '8.0.0' apply false
id 'com.android.library' version '7.4.2' apply false id 'com.android.library' version '8.0.0' apply false
id 'org.jetbrains.kotlin.android' version '1.8.10' apply false id 'org.jetbrains.kotlin.android' version '1.8.10' apply false
id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false
} }
+3 -1
View File
@@ -21,4 +21,6 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.enableR8.fullMode=true android.enableR8.fullMode=true
android.defaults.buildfeatures.buildconfig=true
android.nonFinalResIds=false
+1 -1
View File
@@ -1,6 +1,6 @@
#Wed Jan 04 09:23:50 EST 2023 #Wed Jan 04 09:23:50 EST 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME