This commit is contained in:
Believethehype
2023-03-24 21:35:11 +01:00
41 changed files with 981 additions and 203 deletions
+4 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 33 targetSdk 33
versionCode 103 versionCode 105
versionName "0.28.1" versionName "0.29.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -104,6 +104,8 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
implementation "net.engawapg.lib:zoomable:1.4.0"
// Biometrics // Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
@@ -77,4 +77,13 @@ class TranslationsTest {
"pt" "pt"
) )
} }
@Test
fun testNostrEvents() {
assertTranslateContains(
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"en"
)
}
} }
@@ -357,10 +357,10 @@ class Account(
} }
} }
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?) { fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
if (!isWriteable()) return if (!isWriteable()) return
val repliesToHex = replyTo?.map { it.idHex } val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex } val mentionsHex = mentions?.map { it.pubkeyHex }
val addresses = replyTo?.mapNotNull { it.address() } val addresses = replyTo?.mapNotNull { it.address() }
@@ -369,8 +369,10 @@ class Account(
replyTos = repliesToHex, replyTos = repliesToHex,
mentions = mentionsHex, mentions = mentionsHex,
addresses = addresses, addresses = addresses,
extraTags = tags,
privateKey = loggedIn.privKey!! privateKey = loggedIn.privKey!!
) )
Client.send(signedEvent) Client.send(signedEvent)
LocalCache.consume(signedEvent) LocalCache.consume(signedEvent)
} }
@@ -67,7 +67,7 @@ open class Note(val idHex: String) {
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) } return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
} }
open fun address() = (event as? LongTextNoteEvent)?.address() open fun address(): ATag? = null
open fun createdAt() = event?.createdAt() open fun createdAt() = event?.createdAt()
@@ -235,6 +235,35 @@ open class Note(val idHex: String) {
}.sumOf { it } }.sumOf { it }
} }
fun hasPledgeBy(user: User): Boolean {
return replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.any {
val pledgeValue = try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
null
// do nothing if it can't convert to bigdecimal
}
pledgeValue != null && it.author == user
}
}
fun pledgedAmountByOthers(): BigDecimal {
return replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.mapNotNull {
try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
null
// do nothing if it can't convert to bigdecimal
}
}
.sumOf { it }
}
fun hasAnyReports(): Boolean { fun hasAnyReports(): Boolean {
val dayAgo = Date().time / 1000 - 24 * 60 * 60 val dayAgo = Date().time / 1000 - 24 * 60 * 60
return reports.isNotEmpty() || return reports.isNotEmpty() ||
@@ -7,8 +7,8 @@ data class RelaySetupInfo(
val read: Boolean, val read: Boolean,
val write: Boolean, val write: Boolean,
val errorCount: Int = 0, val errorCount: Int = 0,
val downloadCount: Int = 0, val downloadCountInBytes: Int = 0,
val uploadCount: Int = 0, val uploadCountInBytes: Int = 0,
val spamCount: Int = 0, val spamCount: Int = 0,
val feedTypes: Set<FeedType> val feedTypes: Set<FeedType>
) )
@@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
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
@@ -42,7 +43,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter( TypedFilter(
types = FeedType.values().toSet(), types = FeedType.values().toSet(),
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind), kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind),
authors = listOf(it.pubkeyHex), authors = listOf(it.pubkeyHex),
limit = 200 limit = 200
) )
@@ -0,0 +1,29 @@
package com.vitorpamplona.amethyst.service.lnurl
import java.util.regex.Pattern
object LnWithdrawalUtil {
private val withdrawalPattern = Pattern.compile(
"lnurl.*",
Pattern.CASE_INSENSITIVE
)
/**
* Finds LN withdrawal in the provided input string and returns it.
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
* It will only return the first withdrawal found in the input.
*
* @return the invoice if it was found. null for null input or if no invoice is found
*/
fun findWithdrawal(input: String?): String? {
if (input == null) {
return null
}
val matcher = withdrawalPattern.matcher(input)
return if (matcher.find()) {
matcher.group()
} else {
null
}
}
}
@@ -9,6 +9,7 @@ import fr.acinq.secp256k1.Secp256k1
import nostr.postr.Utils import nostr.postr.Utils
import nostr.postr.toHex import nostr.postr.toHex
import java.lang.reflect.Type import java.lang.reflect.Type
import java.math.BigDecimal
import java.security.MessageDigest import java.security.MessageDigest
import java.util.* import java.util.*
@@ -47,7 +48,7 @@ open class Event(
if (aTagValue != null) ATag.parse(aTagValue, relay) else null if (aTagValue != null) ATag.parse(aTagValue, relay) else null
} }
fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } override fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex } override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
@@ -55,6 +56,14 @@ open class Event(
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags } override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1) override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
override fun getReward(): BigDecimal? {
return try {
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull()
} catch (e: Exception) {
null
}
}
/** /**
* Checks if the ID is correct and then if the pubKey's secret key signed the event. * Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/ */
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.HexKey
import java.math.BigDecimal
interface EventInterface { interface EventInterface {
fun id(): HexKey fun id(): HexKey
@@ -28,4 +29,7 @@ interface EventInterface {
fun isTaggedHash(hashtag: String): Boolean fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtag: Set<String>): Boolean fun isTaggedHashes(hashtag: Set<String>): Boolean
fun firstIsTaggedHashes(hashtag: Set<String>): String? fun firstIsTaggedHashes(hashtag: Set<String>): String?
fun hashtags(): List<String>
fun getReward(): BigDecimal?
} }
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.service.model package com.vitorpamplona.amethyst.service.model
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
@@ -18,7 +20,15 @@ class TextNoteEvent(
companion object { companion object {
const val kind = 1 const val kind = 1
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, addresses: List<ATag>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent { fun create(
msg: String,
replyTos: List<String>?,
mentions: List<String>?,
addresses: List<ATag>?,
extraTags: List<String>?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): TextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>() val tags = mutableListOf<List<String>>()
replyTos?.forEach { replyTos?.forEach {
@@ -33,9 +43,19 @@ class TextNoteEvent(
findHashtags(msg).forEach { findHashtags(msg).forEach {
tags.add(listOf("t", it)) tags.add(listOf("t", it))
} }
extraTags?.forEach {
tags.add(listOf("t", it))
}
findURLs(msg).forEach {
tags.add(listOf("r", it))
}
val id = generateId(pubKey, createdAt, kind, tags, msg) val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey) val sig = Utils.sign(id, privateKey)
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
} }
} }
} }
fun findURLs(text: String): List<String> {
return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl }
}
@@ -7,7 +7,7 @@ import java.util.regex.Pattern
object Nip19 { object Nip19 {
enum class Type { enum class Type {
USER, NOTE, RELAY, ADDRESS USER, NOTE, EVENT, RELAY, ADDRESS
} }
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE) val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
@@ -76,7 +76,7 @@ object Nip19 {
?.get(0) ?.get(0)
?.toString(Charsets.UTF_8) ?.toString(Charsets.UTF_8)
return Return(Type.USER, hex, relay) return Return(Type.EVENT, hex, relay)
} }
private fun nrelay(bytes: ByteArray): Return? { private fun nrelay(bytes: ByteArray): Return? {
@@ -31,9 +31,10 @@ class Relay(
private var socket: WebSocket? = null private var socket: WebSocket? = null
private var isReady: Boolean = false private var isReady: Boolean = false
var eventDownloadCounter = 0 var eventDownloadCounterInBytes = 0
var eventUploadCounterInBytes = 0
var spamCounter = 0 var spamCounter = 0
var eventUploadCounter = 0
var errorCounter = 0 var errorCounter = 0
var ping: Long? = null var ping: Long? = null
@@ -89,7 +90,7 @@ class Relay(
when (type) { when (type) {
"EVENT" -> { "EVENT" -> {
// Log.w("Relay", "Relay onEVENT $url, $channel") // Log.w("Relay", "Relay onEVENT $url, $channel")
eventDownloadCounter++ eventDownloadCounterInBytes += text.bytesUsedInMemory()
val event = Event.fromJson(msg[2], Client.lenient) val event = Event.fromJson(msg[2], Client.lenient)
listeners.forEach { it.onEvent(this@Relay, channel, event) } listeners.forEach { it.onEvent(this@Relay, channel, event) }
} }
@@ -185,6 +186,7 @@ class Relay(
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]""" """["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]"""
// println("FILTERSSENT ${url} ${request}") // println("FILTERSSENT ${url} ${request}")
socket?.send(request) socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
} }
} }
} else { } else {
@@ -209,8 +211,9 @@ class Relay(
fun send(signedEvent: EventInterface) { fun send(signedEvent: EventInterface) {
if (write) { if (write) {
socket?.send("""["EVENT",${signedEvent.toJson()}]""") val event = """["EVENT",${signedEvent.toJson()}]"""
eventUploadCounter++ socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
} }
} }
@@ -257,3 +260,7 @@ class Relay(
fun onRelayStateChange(relay: Relay, type: Type, channel: String?) fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
} }
} }
fun String.bytesUsedInMemory(): Int {
return (8 * ((((this.length) * 2) + 45) / 8))
}
@@ -1,8 +1,11 @@
package com.vitorpamplona.amethyst.ui package com.vitorpamplona.amethyst.ui
import android.content.Context
import android.content.Intent
import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION.SDK_INT
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
@@ -34,6 +37,8 @@ class MainActivity : FragmentActivity() {
val startingPage = when (nip19?.type) { val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}" Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}" Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> "Event/${nip19.hex}"
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null else -> null
} }
@@ -94,3 +99,13 @@ class MainActivity : FragmentActivity() {
ServiceManager.cleanUp() ServiceManager.cleanUp()
} }
} }
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
return super.createIntent(context, input).apply {
// Force only images and videos to be selectable
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
}
}
}
@@ -19,13 +19,16 @@ object ImageUploader {
onError: (Throwable) -> Unit onError: (Throwable) -> Unit
) { ) {
val contentType = contentResolver.getType(uri) val contentType = contentResolver.getType(uri)
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
val url = if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
val client = OkHttpClient.Builder().build() val client = OkHttpClient.Builder().build()
val requestBody: RequestBody = MultipartBody.Builder() val requestBody: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.addFormDataPart( .addFormDataPart(
"image", category,
"${UUID.randomUUID()}", "${UUID.randomUUID()}",
object : RequestBody() { object : RequestBody() {
override fun contentType(): MediaType? = override fun contentType(): MediaType? =
@@ -46,7 +49,7 @@ object ImageUploader {
val request: Request = Request.Builder() val request: Request = Request.Builder()
.header("Authorization", "Client-ID e6aea87296f3f96") .header("Authorization", "Client-ID e6aea87296f3f96")
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url("https://api.imgur.com/3/image") .url(url)
.post(requestBody) .post(requestBody)
.build() .build()
@@ -14,6 +14,8 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -85,6 +87,8 @@ class NewPostViewModel : ViewModel() {
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex)) addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) { } else if (results?.key?.type == Nip19.Type.NOTE) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex)) addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.EVENT) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.ADDRESS) { } else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex) val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) { if (note != null) {
@@ -105,6 +109,10 @@ class NewPostViewModel : ViewModel() {
} else if (results?.key?.type == Nip19.Type.NOTE) { } else if (results?.key?.type == Nip19.Type.NOTE) {
val note = LocalCache.getOrCreateNote(results.key.hex) val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}" "#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) { } else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex) val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
@@ -140,12 +148,16 @@ class NewPostViewModel : ViewModel() {
onSuccess = { imageUrl -> onSuccess = { imageUrl ->
isUploadingImage = false isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl) message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
viewModelScope.launch(Dispatchers.IO) {
delay(2000)
urlPreview = findUrlInMessage()
}
}, },
onError = { onError = {
isUploadingImage = false isUploadingImage = false
viewModelScope.launch { viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image") imageUploadingError.emit("Failed to upload the image / video")
} }
} }
) )
@@ -150,27 +150,27 @@ fun ServerConfigHeader() {
Column(Modifier.weight(1.4f)) { Column(Modifier.weight(1.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(25.dp)) Spacer(modifier = Modifier.size(30.dp))
Text( Text(
text = stringResource(R.string.posts), text = stringResource(R.string.bytes),
maxLines = 1, maxLines = 1,
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
) )
Spacer(modifier = Modifier.size(10.dp)) Spacer(modifier = Modifier.size(5.dp))
Text( Text(
text = stringResource(id = R.string.posts), text = stringResource(id = R.string.bytes),
maxLines = 1, maxLines = 1,
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
) )
Spacer(modifier = Modifier.size(10.dp)) Spacer(modifier = Modifier.size(5.dp))
Text( Text(
text = stringResource(R.string.errors), text = stringResource(R.string.errors),
@@ -371,10 +371,10 @@ fun ServerConfig(
} }
Text( Text(
text = "${countToHumanReadable(item.downloadCount)}", text = "${countToHumanReadable(item.downloadCountInBytes)}",
maxLines = 1, maxLines = 1,
fontSize = 14.sp, fontSize = 12.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
) )
@@ -399,10 +399,10 @@ fun ServerConfig(
} }
Text( Text(
text = "${countToHumanReadable(item.uploadCount)}", text = "${countToHumanReadable(item.uploadCountInBytes)}",
maxLines = 1, maxLines = 1,
fontSize = 14.sp, fontSize = 12.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
) )
@@ -418,7 +418,7 @@ fun ServerConfig(
Text( Text(
text = "${countToHumanReadable(item.errorCount)}", text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1, maxLines = 1,
fontSize = 14.sp, fontSize = 12.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
) )
@@ -433,7 +433,7 @@ fun ServerConfig(
Text( Text(
text = "${countToHumanReadable(item.spamCount)}", text = "${countToHumanReadable(item.spamCount)}",
maxLines = 1, maxLines = 1,
fontSize = 14.sp, fontSize = 12.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
) )
@@ -49,23 +49,23 @@ class NewRelayListViewModel : ViewModel() {
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet() val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
val errorCounter = liveRelay?.errorCounter ?: 0 val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0 val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0 val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0 val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes) RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed() }.sortedBy { it.downloadCountInBytes }.reversed()
} else { } else {
account.localRelays.map { account.localRelays.map {
val liveRelay = RelayPool.getRelay(it.url) val liveRelay = RelayPool.getRelay(it.url)
val errorCounter = liveRelay?.errorCounter ?: 0 val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0 val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0 val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0 val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes) RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed() }.sortedBy { it.downloadCountInBytes }.reversed()
} }
} }
} }
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.actions
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
@@ -28,6 +27,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@@ -150,7 +150,7 @@ fun GallerySelect(
) { ) {
var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) } var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(), contract = GetMediaActivityResultContract(),
onResult = { uri: Uri? -> onResult = { uri: Uri? ->
onImageUri(uri) onImageUri(uri)
hasLaunched.set(false) hasLaunched.set(false)
@@ -161,7 +161,7 @@ fun GallerySelect(
fun LaunchGallery() { fun LaunchGallery() {
SideEffect { SideEffect {
if (!hasLaunched.getAndSet(true)) { if (!hasLaunched.getAndSet(true)) {
launcher.launch("image/*") launcher.launch("*/*")
} }
} }
} }
@@ -12,6 +12,7 @@ import androidx.compose.ui.text.withStyle
import androidx.navigation.NavController import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
@Composable @Composable
@@ -47,11 +48,28 @@ fun ClickableRoute(
if (note.event is ChannelCreateEvent) { if (note.event is ChannelCreateEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController) CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
} else if (channel != null) { } else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController) CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
} else { } else {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController) CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
} }
} else if (nip19.type == Nip19.Type.EVENT) {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
} else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
} else {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Event/${nip19.hex}", navController)
}
} else { } else {
Text( Text(
"@${nip19.hex}${nip19.additionalChars} " "@${nip19.hex}${nip19.additionalChars} "
@@ -0,0 +1,27 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.core.content.ContextCompat
@Composable
fun ClickableWithdrawal(withdrawalString: String) {
val context = LocalContext.current
ClickableText(
text = AnnotatedString("$withdrawalString "),
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
ContextCompat.startActivity(context, intent, null)
}
},
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
@@ -37,6 +37,7 @@ import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -68,6 +69,8 @@ fun isValidURL(url: String?): Boolean {
} }
} }
val richTextDefaults = RichTextStyle().resolveDefaults()
@Composable @Composable
fun RichTextViewer( fun RichTextViewer(
content: String, content: String,
@@ -78,8 +81,8 @@ fun RichTextViewer(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
navController: NavController navController: NavController
) { ) {
val myMarkDownStyle = RichTextStyle().resolveDefaults().copy( val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = RichTextStyle().resolveDefaults().codeBlockStyle?.copy( codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle( textStyle = TextStyle(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 14.sp fontSize = 14.sp
@@ -99,7 +102,7 @@ fun RichTextViewer(
.compositeOver(backgroundColor) .compositeOver(backgroundColor)
) )
), ),
stringStyle = RichTextStyle().resolveDefaults().stringStyle?.copy( stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle( linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline, textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary color = MaterialTheme.colors.primary
@@ -128,6 +131,20 @@ fun RichTextViewer(
) )
} }
} else { } else {
val imagesForPager = mutableListOf<String>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
// sequence of images will render in a slideview
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
imagesForPager.add(word)
}
}
}
}
// 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() {
@@ -136,17 +153,21 @@ fun RichTextViewer(
if (canPreview) { if (canPreview) {
// Explicit URL // Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word) val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) { val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) { if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase() val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) { if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word) ZoomableImageView(word, imagesForPager)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) { } else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word) VideoView(word)
} else { } else {
UrlPreview(word, word) UrlPreview(word, "$word ")
} }
} else if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word) ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
@@ -0,0 +1,104 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.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)
@Composable
fun SlidingCarousel(
modifier: Modifier = Modifier,
pagerState: PagerState = remember { PagerState() },
itemsCount: Int,
itemContent: @Composable (index: Int) -> Unit
) {
val isDragged by pagerState.interactionSource.collectIsDraggedAsState()
Box(
modifier = modifier.fillMaxWidth()
) {
HorizontalPager(count = itemsCount, state = pagerState) { page ->
itemContent(page)
}
// you can remove the surface in case you don't want
// the transparant bacground
Surface(
modifier = Modifier
.padding(bottom = 8.dp)
.align(Alignment.BottomCenter),
shape = CircleShape,
color = Color.Black.copy(alpha = 0.5f)
) {
DotsIndicator(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
totalDots = itemsCount,
selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage,
dotSize = 8.dp
)
}
}
}
@Composable
fun DotsIndicator(
modifier: Modifier = Modifier,
totalDots: Int,
selectedIndex: Int,
selectedColor: Color = MaterialTheme.colors.primary /* Color.Yellow */,
unSelectedColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) /* Color.Gray */,
dotSize: Dp
) {
LazyRow(
modifier = modifier
.wrapContentWidth()
.wrapContentHeight()
) {
items(totalDots) { index ->
IndicatorDot(
color = if (index == selectedIndex) selectedColor else unSelectedColor,
size = dotSize
)
if (index != totalDots - 1) {
Spacer(modifier = Modifier.padding(horizontal = 2.dp))
}
}
}
}
@Composable
fun IndicatorDot(
modifier: Modifier = Modifier,
size: Dp,
color: Color
) {
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(color)
)
}
@@ -1,61 +0,0 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
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.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
@Composable
fun ZoomableAsyncImage(imageUrl: String) {
var scale by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown()
do {
val event = awaitPointerEvent()
scale *= event.calculateZoom()
val offset = event.calculatePan()
offsetX += offset.x
offsetY += offset.y
} while (event.changes.any { it.pressed })
}
}
) {
AsyncImage(
model = imageUrl,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
)
)
}
}
@@ -18,22 +18,27 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
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.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage import coil.compose.AsyncImage
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.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@Composable @Composable
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String) { fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state // store the dialog open or close state
@@ -49,7 +54,11 @@ fun ZoomableImageView(word: String) {
.padding(top = 4.dp) .padding(top = 4.dp)
.fillMaxWidth() .fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp)) .clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) .border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.combinedClickable( .combinedClickable(
onClick = { dialogOpen = true }, onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(word)) } onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
@@ -57,12 +66,13 @@ fun ZoomableImageView(word: String) {
) )
if (dialogOpen) { if (dialogOpen) {
ZoomableImageDialog(word, onDismiss = { dialogOpen = false }) ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
} }
} }
@OptIn(ExperimentalPagerApi::class)
@Composable @Composable
fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) { fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(imageUrl), onDismiss: () -> Unit) {
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -71,6 +81,8 @@ fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
Column( Column(
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) { ) {
var pagerState: PagerState = remember { PagerState() }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
@@ -79,10 +91,34 @@ fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
) { ) {
CloseButton(onCancel = onDismiss) CloseButton(onCancel = onDismiss)
SaveToGallery(url = imageUrl) SaveToGallery(url = allImages[pagerState.currentPage])
} }
ZoomableAsyncImage(imageUrl) if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
AsyncImage(
model = allImages[index],
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
)
}
)
} else {
AsyncImage(
model = imageUrl,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
)
}
} }
} }
} }
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeConversationsFeedFilter : FeedFilter<Note>() { object HomeConversationsFeedFilter : FeedFilter<Note>() {
@@ -16,7 +15,7 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
return LocalCache.notes.values return LocalCache.notes.values
.filter { .filter {
(it.event is TextNoteEvent || it.event is RepostEvent) && (it.event is TextNoteEvent) &&
(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) } ?: true && it.author?.let { !account.isHidden(it) } ?: true &&
@@ -17,7 +17,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val notes = LocalCache.notes.values val notes = LocalCache.notes.values
.filter { it -> .filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) && (it.event is TextNoteEvent || it.event is RepostEvent) &&
(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) } ?: true && it.author?.let { !account.isHidden(it) } ?: true &&
@@ -26,7 +26,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val longFormNotes = LocalCache.addressables.values val longFormNotes = LocalCache.addressables.values
.filter { it -> .filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) && (it.event is LongTextNoteEvent) &&
(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) } ?: true && it.author?.let { !account.isHidden(it) } ?: true &&
@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
@@ -126,6 +127,16 @@ fun AppNavigation(
) )
}) })
} }
Route.Event.let { route ->
composable(route.route, route.arguments, content = {
LoadRedirectScreen(
eventId = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
)
})
}
} }
if (nextPage != null) { if (nextPage != null) {
@@ -87,6 +87,12 @@ sealed class Route(
icon = R.drawable.ic_moments, icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }) arguments = listOf(navArgument("id") { type = NavType.StringType })
) )
object Event : Route(
route = "Event/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
)
} }
// ** // **
@@ -0,0 +1,144 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.PostButton
class AddBountyAmountViewModel : ViewModel() {
private var account: Account? = null
private var bounty: Note? = null
var nextAmount by mutableStateOf(TextFieldValue(""))
fun load(account: Account, bounty: Note?) {
this.account = account
this.bounty = bounty
}
fun sendPost() {
val newValue = nextAmount.text.trim().toLongOrNull()
if (newValue != null) {
account?.sendPost(
newValue.toString(),
listOfNotNull(bounty),
listOfNotNull(bounty?.author),
listOf("bounty-added-reward")
)
nextAmount = TextFieldValue("")
}
}
fun cancel() {
nextAmount = TextFieldValue("")
}
fun hasChanged(): Boolean {
return nextAmount.text.trim().toLongOrNull() != null
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AddBountyAmountDialog(bounty: Note, account: Account, onClose: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: AddBountyAmountViewModel = viewModel()
LaunchedEffect(account) {
postViewModel.load(account, bounty)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp).width(IntrinsicSize.Min)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.hasChanged()
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.pledge_amount_in_sats)) },
value = postViewModel.nextAmount,
onValueChange = {
postViewModel.nextAmount = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
text = "10000, 50000, 5000000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}
}
}
@@ -365,7 +365,7 @@ private fun RelayBadges(baseNote: Note) {
FlowRow(Modifier.padding(start = 10.dp)) { FlowRow(Modifier.padding(start = 10.dp)) {
relaysToDisplay.forEach { relaysToDisplay.forEach {
val url = it.removePrefix("wss://") val url = it.removePrefix("wss://").removePrefix("ws://")
Box( Box(
Modifier Modifier
.size(15.dp) .size(15.dp)
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -76,18 +77,18 @@ fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State<Bool
} }
@Composable @Composable
fun ObserveDisplayNip05Status(baseNote: Note) { fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifier) {
val noteState by baseNote.live().metadata.observeAsState() val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return val note = noteState?.note ?: return
val author = note.author val author = note.author
if (author != null) { if (author != null) {
ObserveDisplayNip05Status(author) ObserveDisplayNip05Status(author, columnModifier)
} }
} }
@Composable @Composable
fun ObserveDisplayNip05Status(baseUser: User) { fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState() val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return val user = userState?.user ?: return
@@ -96,52 +97,54 @@ fun ObserveDisplayNip05Status(baseUser: User) {
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) val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = columnModifier) {
if (nip05.split("@")[0] != "_") { Row(verticalAlignment = Alignment.CenterVertically) {
Text( if (nip05.split("@")[0] != "_") {
text = AnnotatedString(nip05.split("@")[0]), Text(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), text = AnnotatedString(nip05.split("@")[0]),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
}
ClickableText(
text = AnnotatedString(nip05.split("@")[1]),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Visible
) )
} }
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
}
ClickableText(
text = AnnotatedString(nip05.split("@")[1]),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1,
overflow = TextOverflow.Visible
)
} }
} }
} }
@@ -10,8 +10,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -42,6 +44,7 @@ import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
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.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
@@ -50,6 +53,7 @@ import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent 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.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent
@@ -65,9 +69,11 @@ import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Following 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
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -273,15 +279,8 @@ fun NoteCompose(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
) )
} } else {
DisplayFollowingHashtagsInPost(noteEvent, account, navController)
val firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
if (firstTag != null) {
ClickableText(
text = AnnotatedString(" #$firstTag"),
onClick = { navController.navigate("Hashtag/$firstTag") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
)
} }
Text( Text(
@@ -306,7 +305,14 @@ fun NoteCompose(
} }
if (note.author != null && !makeItShort && !isQuotedNote) { if (note.author != null && !makeItShort && !isQuotedNote) {
ObserveDisplayNip05Status(note.author!!) Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
val baseReward = noteEvent.getReward()
if (baseReward != null) {
DisplayReward(baseReward, baseNote, account, navController)
}
}
} }
Spacer(modifier = Modifier.height(3.dp)) Spacer(modifier = Modifier.height(3.dp))
@@ -394,7 +400,7 @@ fun NoteCompose(
thickness = 0.25.dp thickness = 0.25.dp
) )
} else if (noteEvent is LongTextNoteEvent) { } else if (noteEvent is LongTextNoteEvent) {
LongFormHeader(noteEvent) LongFormHeader(noteEvent, note, account.userProfile())
ReactionsRow(note, accountViewModel) ReactionsRow(note, accountViewModel)
@@ -473,15 +479,26 @@ fun NoteCompose(
!noteForReports.hasAnyReports() !noteForReports.hasAnyReports()
if (eventContent != null) { if (eventContent != null) {
TranslateableRichTextViewer( if (makeItShort && note.author == account.userProfile()) {
eventContent, Text(
canPreview = canPreview && !makeItShort, text = eventContent,
Modifier.fillMaxWidth(), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
noteEvent.tags(), maxLines = 2,
backgroundColor, overflow = TextOverflow.Ellipsis
accountViewModel, )
navController } else {
) TranslateableRichTextViewer(
eventContent,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags(),
backgroundColor,
accountViewModel,
navController
)
DisplayUncitedHashtags(noteEvent, eventContent, navController)
}
} }
if (!makeItShort) { if (!makeItShort) {
@@ -501,6 +518,130 @@ fun NoteCompose(
} }
} }
@Composable
fun DisplayFollowingHashtagsInPost(
noteEvent: EventInterface,
account: Account,
navController: NavController
) {
Column() {
Row(verticalAlignment = Alignment.CenterVertically) {
val firstTag =
noteEvent.firstIsTaggedHashes(account.followingTagSet())
if (firstTag != null) {
ClickableText(
text = AnnotatedString(" #$firstTag"),
onClick = { navController.navigate("Hashtag/$firstTag") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
)
}
}
}
}
@Composable
fun DisplayUncitedHashtags(
noteEvent: EventInterface,
eventContent: String,
navController: NavController
) {
val hashtags = noteEvent.hashtags()
if (hashtags.isNotEmpty()) {
FlowRow(
modifier = Modifier.padding(top = 5.dp)
) {
hashtags.forEach { hashtag ->
if (!eventContent.contains(hashtag, true)) {
ClickableText(
text = AnnotatedString("#$hashtag "),
onClick = { navController.navigate("Hashtag/$hashtag") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
)
}
}
}
}
}
@Composable
fun DisplayReward(
baseReward: BigDecimal,
baseNote: Note,
account: Account,
navController: NavController
) {
var popupExpanded by remember { mutableStateOf(false) }
Column() {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { popupExpanded = true }
) {
ClickableText(
text = AnnotatedString("#bounty"),
onClick = { navController.navigate("Hashtag/bounty") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
)
val repliesState by baseNote.live().replies.observeAsState()
val replyNote = repliesState?.note
if (replyNote?.hasPledgeBy(account.userProfile()) == true) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
var rewardAmount by remember {
mutableStateOf<BigDecimal?>(
baseReward
)
}
LaunchedEffect(key1 = repliesState) {
withContext(Dispatchers.IO) {
replyNote?.pledgedAmountByOthers()?.let {
rewardAmount = baseReward.add(it)
}
}
}
Text(
showAmount(rewardAmount),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
if (popupExpanded) {
AddBountyAmountDialog(baseNote, account) {
popupExpanded = false
}
}
}
}
@Composable @Composable
fun BadgeDisplay(baseNote: Note) { fun BadgeDisplay(baseNote: Note) {
val background = MaterialTheme.colors.background val background = MaterialTheme.colors.background
@@ -565,7 +706,7 @@ fun BadgeDisplay(baseNote: Note) {
} }
@Composable @Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent) { private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row( Row(
modifier = Modifier modifier = Modifier
.clip(shape = RoundedCornerShape(15.dp)) .clip(shape = RoundedCornerShape(15.dp))
@@ -586,6 +727,35 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} ?: Box() {
note.author?.info?.banner?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
} ?: Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringResource(R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
Box(
Modifier
.width(75.dp)
.height(75.dp)
.padding(10.dp)
.align(Alignment.BottomStart)
) {
NoteAuthorPicture(baseNote = note, baseUserAccount = loggedIn, size = 55.dp)
}
} }
noteEvent.title()?.let { noteEvent.title()?.let {
@@ -610,6 +780,16 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
?: Text(
text = noteEvent.content.take(200),
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
} }
} }
} }
@@ -79,7 +79,11 @@ private fun lightenColor(color: Color, amount: Float): Color {
val externalLinkForNote = { note: Note -> val externalLinkForNote = { note: Note ->
if (note is AddressableNote) { if (note is AddressableNote) {
"https://habla.news/a/${note.address().toNAddr()}" if (note.event?.getReward() != null) {
"https://nostrbounties.com/b/${note.address().toNAddr()}"
} else {
"https://habla.news/a/${note.address().toNAddr()}"
}
} else { } else {
"https://snort.social/e/${note.idNote()}" "https://snort.social/e/${note.idNote()}"
} }
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.qrcode package com.vitorpamplona.amethyst.ui.qrcode
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -19,6 +20,8 @@ fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
val startingPage = when (nip19?.type) { val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}" Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}" Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> "Event/${nip19.hex}"
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null else -> null
} }
@@ -28,6 +31,7 @@ fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
onScan(null) onScan(null)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e("NIP19 Scanner", "Error parsing $it", e)
// QR can be anything, do not throw errors. // QR can be anything, do not throw errors.
onScan(null) onScan(null)
} }
@@ -57,6 +57,9 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.DisplayFollowingHashtagsInPost
import com.vitorpamplona.amethyst.ui.note.DisplayReward
import com.vitorpamplona.amethyst.ui.note.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.HiddenNote import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -250,6 +253,8 @@ fun NoteMaster(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(baseNote, Modifier.weight(1f)) NoteUsernameDisplay(baseNote, Modifier.weight(1f))
DisplayFollowingHashtagsInPost(noteEvent, account, navController)
Text( Text(
timeAgo(note.createdAt(), context = context), timeAgo(note.createdAt(), context = context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -271,7 +276,14 @@ fun NoteMaster(
} }
} }
ObserveDisplayNip05Status(baseNote) Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(baseNote, Modifier.weight(1f))
val baseReward = noteEvent.getReward()
if (baseReward != null) {
DisplayReward(baseReward, baseNote, account, navController)
}
}
} }
} }
@@ -341,6 +353,8 @@ fun NoteMaster(
accountViewModel, accountViewModel,
navController navController
) )
DisplayUncitedHashtags(noteEvent, eventContent, navController)
} }
ReactionsRow(note, accountViewModel) ReactionsRow(note, accountViewModel)
@@ -0,0 +1,63 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
@Composable
fun LoadRedirectScreen(eventId: String?, accountViewModel: AccountViewModel, navController: NavController) {
if (eventId == null) return
val baseNote = LocalCache.checkGetOrCreateNote(eventId) ?: return
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
LaunchedEffect(key1 = noteState) {
val event = note?.event
val channel = note?.channel()
if (event == null) {
// stay here, loading
} else if (event is ChannelCreateEvent) {
navController.backQueue.removeLast()
navController.navigate("Channel/${note.idHex}")
} else if (event is PrivateDmEvent) {
navController.backQueue.removeLast()
navController.navigate("Room/${note.author?.pubkeyHex}")
} else if (channel != null) {
navController.backQueue.removeLast()
navController.navigate("Channel/${channel.idHex}")
} else {
navController.backQueue.removeLast()
navController.navigate("Note/${note.idHex}")
}
}
Column(
Modifier
.fillMaxHeight()
.fillMaxWidth()
.padding(horizontal = 50.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(stringResource(R.string.looking_for_event, eventId))
}
}
@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage 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.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
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
@@ -393,7 +394,7 @@ private fun ProfileHeader(
} }
} }
DrawAdditionalInfo(baseUser, account, navController) DrawAdditionalInfo(baseUser, account, accountViewModel, navController)
Divider(modifier = Modifier.padding(top = 6.dp)) Divider(modifier = Modifier.padding(top = 6.dp))
} }
@@ -406,7 +407,7 @@ private fun ProfileHeader(
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun DrawAdditionalInfo(baseUser: User, account: Account, navController: NavController) { private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
val userState by baseUser.live().metadata.observeAsState() val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return val user = userState?.user ?: return
@@ -554,11 +555,18 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, navController:
} }
user.info?.about?.let { user.info?.about?.let {
Text( Row(
it,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp) modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
) ) {
TranslateableRichTextViewer(
content = it,
canPreview = false,
tags = null,
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
navController = navController
)
}
} }
} }
@@ -22,6 +22,7 @@
<string name="copy_user_pubkey">Copiar PubKey do usuário</string> <string name="copy_user_pubkey">Copiar PubKey do usuário</string>
<string name="copy_note_id">Copiar ID da Nota</string> <string name="copy_note_id">Copiar ID da Nota</string>
<string name="broadcast">Transmitir</string> <string name="broadcast">Transmitir</string>
<string name="request_deletion">Pedir para excluir</string>
<string name="block_hide_user">Bloquear e ocultar usuário</string> <string name="block_hide_user">Bloquear e ocultar usuário</string>
<string name="report_spam_scam">Denunciar spam/fraude</string> <string name="report_spam_scam">Denunciar spam/fraude</string>
<string name="report_impersonation">Denunciar representação</string> <string name="report_impersonation">Denunciar representação</string>
@@ -72,6 +73,7 @@
<string name="failed_to_upload_the_image">Falha ao enviar imagem</string> <string name="failed_to_upload_the_image">Falha ao enviar imagem</string>
<string name="relay_address">Endereço do Relay</string> <string name="relay_address">Endereço do Relay</string>
<string name="posts">Posts</string> <string name="posts">Posts</string>
<string name="bytes">Bytes</string>
<string name="errors">Erros</string> <string name="errors">Erros</string>
<string name="home_feed">Feed principal</string> <string name="home_feed">Feed principal</string>
<string name="private_message_feed">Feed de mensagens privadas</string> <string name="private_message_feed">Feed de mensagens privadas</string>
@@ -191,6 +193,14 @@
<string name="copied_user_id_to_clipboard">Copiado @npub do autor</string> <string name="copied_user_id_to_clipboard">Copiado @npub do autor</string>
<string name="copied_note_id_to_clipboard">Copiado ID da nota (@note1)</string> <string name="copied_note_id_to_clipboard">Copiado ID da nota (@note1)</string>
<string name="select_text_dialog_top">Selecionar texto</string> <string name="select_text_dialog_top">Selecionar texto</string>
<string name="account_switch_add_account_dialog_title">Adicionar Nova Conta</string>
<string name="drawer_accounts">Contas</string>
<string name="account_switch_select_account">Selecionar Conta</string>
<string name="account_switch_add_account_btn">Adicionar Nova Conta</string>
<string name="account_switch_active_account">Ativar conta</string>
<string name="account_switch_has_private_key">Tem chave privada</string>
<string name="account_switch_pubkey_only">Somente leitura, sem chave privada</string>
<string name="back">Voltar</string>
<string name="quick_action_select">Selecionar</string> <string name="quick_action_select">Selecionar</string>
<string name="quick_action_share_browser_link">Compartilhar link do navegador</string> <string name="quick_action_share_browser_link">Compartilhar link do navegador</string>
<string name="quick_action_share">Compartilhar</string> <string name="quick_action_share">Compartilhar</string>
@@ -204,6 +214,38 @@
<string name="quick_action_request_deletion_alert_body">Amethyst solicitará que sua nota seja excluída dos relays aos quais você está conectado no momento. Não há garantia de que sua nota será excluída permanentemente desses relays ou de outros relays onde possa estar armazenada.</string> <string name="quick_action_request_deletion_alert_body">Amethyst solicitará que sua nota seja excluída dos relays aos quais você está conectado no momento. Não há garantia de que sua nota será excluída permanentemente desses relays ou de outros relays onde possa estar armazenada.</string>
<string name="backup_keys">Copia de segurança das chaves</string> <string name="backup_keys">Copia de segurança das chaves</string>
<string name="private_conversation_notification">&lt;Não foi possível descriptografar a mensagem privada&gt;\\n\\nVocê foi citado em uma conversa privada/criptografada entre %1$s and %2$s.</string> <string name="private_conversation_notification">&lt;Não foi possível descriptografar a mensagem privada&gt;\\n\\nVocê foi citado em uma conversa privada/criptografada entre %1$s and %2$s.</string>
<string name="quick_action_block_dialog_btn">Bloquear</string>
<string name="quick_action_delete_dialog_btn">Excluir</string>
<string name="quick_action_block">Bloquear</string>
<string name="quick_action_report">Denunciar</string>
<string name="quick_action_delete_button">Excluir</string> <string name="quick_action_delete_button">Excluir</string>
<string name="quick_action_dont_show_again_button">Não mostar novamente</string> <string name="quick_action_dont_show_again_button">Não mostar novamente</string>
<string name="report_dialog_spam">Spam ou golpes</string>
<string name="report_dialog_profanity">Palavrões ou conduta odiosa</string>
<string name="report_dialog_impersonation">Personificação maliciosa</string>
<string name="report_dialog_nudity">Nudez ou conteúdo gráfico</string>
<string name="report_dialog_illegal">Comportamento ilegal</string>
<string name="report_dialog_blocking_a_user">Bloquear um usuário ocultará seu conteúdo em seu aplicativo. Suas anotações ainda podem ser visualizadas publicamente, inclusive para as pessoas que você bloquear. Os usuários bloqueados são listados na tela Filtros de segurança.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Bloquear e ocultar usuário]]></string>
<string name="report_dialog_report_btn">Denunciar abuso</string>
<string name="report_dialog_reminder_public">Todos as denuncias postadas serão visíveis publicamente.</string>
<string name="report_dialog_additional_reason_placeholder">Opcionalmente, forneça contexto adicional sobre a sua denuncia…</string>
<string name="report_dialog_additional_reason_label">Contexto Adicional</string>
<string name="report_dialog_select_reason_label">Motivo</string>
<string name="report_dialog_select_reason_placeholder">Selecione um motivo…</string>
<string name="report_dialog_post_report_btn">Enviar Denuncia</string>
<string name="report_dialog_title">Bloquear e Denunciar</string>
<string name="block_only">Bloquear</string>
<string name="bookmarks">Itens Salvos</string>
<string name="private_bookmarks">Itens Salvos Privados</string>
<string name="public_bookmarks">Itens Salvos Públicos</string>
<string name="add_to_private_bookmarks">Adicionar aos Itens Salvos Privados</string>
<string name="add_to_public_bookmarks">Adicionar aos Itens Salvos Públicos</string>
<string name="remove_from_private_bookmarks">Remover dos Itens Salvos Privados</string>
<string name="remove_from_public_bookmarks">Remover dos Itens Salvos Públicos</string>
<string name="wallet_connect_service">Serviço Wallet Connect</string>
<string name="wallet_connect_service_explainer">Use sua chave privada para pagar zaps sem sair do app. Qualquer pessoa com acesso à sua chave privada Nostr poderá gastar o saldo da sua carteira. Guarde apenas os fundos que você pode perder e use um retransmissor privado, se possível. O operador de Relay pode ver seus metadados de pagamentos.</string>
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
<string name="wallet_connect_service_relay">Wallet Connect Relay</string>
<string name="pledge_amount_in_sats">Valor em Sats</string>
</resources> </resources>
+5
View File
@@ -74,6 +74,7 @@
<string name="failed_to_upload_the_image">Failed to upload the image</string> <string name="failed_to_upload_the_image">Failed to upload the image</string>
<string name="relay_address">Relay Address</string> <string name="relay_address">Relay Address</string>
<string name="posts">Posts</string> <string name="posts">Posts</string>
<string name="bytes">Bytes</string>
<string name="errors">Errors</string> <string name="errors">Errors</string>
<string name="home_feed">Home Feed</string> <string name="home_feed">Home Feed</string>
<string name="private_message_feed">Private Message Feed</string> <string name="private_message_feed">Private Message Feed</string>
@@ -260,4 +261,8 @@
<string name="wallet_connect_service_explainer">Uses your private key to pay zaps without leaving the app. Anyone with access to your Nostr private key will be able to spend your wallet\'s balance. Only keep funds you are ok to lose and use a private relay if possible. The relay operator can see your payments metadata.</string> <string name="wallet_connect_service_explainer">Uses your private key to pay zaps without leaving the app. Anyone with access to your Nostr private key will be able to spend your wallet\'s balance. Only keep funds you are ok to lose and use a private relay if possible. The relay operator can see your payments metadata.</string>
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string> <string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
<string name="wallet_connect_service_relay">Wallet Connect Relay</string> <string name="wallet_connect_service_relay">Wallet Connect Relay</string>
<string name="pledge_amount_in_sats">Pledge Amount in Sats</string>
<string name="looking_for_event">"Looking for Event %1$s"</string>
</resources> </resources>
@@ -102,7 +102,7 @@ object LanguageTranslatorService {
while (matcher.find()) { while (matcher.find()) {
try { try {
val tag = matcher.group() val tag = matcher.group()
val short = "Amethysttagindexer$counter" val short = "Amethystmindexer$counter"
returningList.put(short, tag) returningList.put(short, tag)
} catch (e: Exception) { } catch (e: Exception) {
} }
@@ -69,4 +69,12 @@ class NIP19ParserTest {
assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result?.hex) assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result?.hex)
assertEquals(null, result?.relay) assertEquals(null, result?.relay)
} }
@Test
fun nEventParserTest() {
val result = Nip19.uriToRoute("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy")
assertEquals(Nip19.Type.EVENT, result?.type)
assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result?.hex)
assertEquals(null, result?.relay)
}
} }