Believethehype 2023-03-24 21:35:11 +01:00
commit 757f6982bd
41 zmienionych plików z 981 dodań i 203 usunięć

Wyświetl plik

@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 103
versionName "0.28.1"
versionCode 105
versionName "0.29.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -104,6 +104,8 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
implementation "net.engawapg.lib:zoomable:1.4.0"
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"

Wyświetl plik

@ -77,4 +77,13 @@ class TranslationsTest {
"pt"
)
}
@Test
fun testNostrEvents() {
assertTranslateContains(
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"en"
)
}
}

Wyświetl plik

@ -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
val repliesToHex = replyTo?.map { it.idHex }
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val addresses = replyTo?.mapNotNull { it.address() }
@ -369,8 +369,10 @@ class Account(
replyTos = repliesToHex,
mentions = mentionsHex,
addresses = addresses,
extraTags = tags,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}

Wyświetl plik

@ -67,7 +67,7 @@ open class Note(val idHex: String) {
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
}
open fun address() = (event as? LongTextNoteEvent)?.address()
open fun address(): ATag? = null
open fun createdAt() = event?.createdAt()
@ -235,6 +235,35 @@ open class Note(val idHex: String) {
}.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 {
val dayAgo = Date().time / 1000 - 24 * 60 * 60
return reports.isNotEmpty() ||

Wyświetl plik

@ -7,8 +7,8 @@ data class RelaySetupInfo(
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val feedTypes: Set<FeedType>
)

Wyświetl plik

@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
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.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@ -42,7 +43,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)

Wyświetl plik

@ -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
}
}
}

Wyświetl plik

@ -9,6 +9,7 @@ import fr.acinq.secp256k1.Secp256k1
import nostr.postr.Utils
import nostr.postr.toHex
import java.lang.reflect.Type
import java.math.BigDecimal
import java.security.MessageDigest
import java.util.*
@ -47,7 +48,7 @@ open class Event(
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 }
@ -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 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.
*/

Wyświetl plik

@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import java.math.BigDecimal
interface EventInterface {
fun id(): HexKey
@ -28,4 +29,7 @@ interface EventInterface {
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtag: Set<String>): Boolean
fun firstIsTaggedHashes(hashtag: Set<String>): String?
fun hashtags(): List<String>
fun getReward(): BigDecimal?
}

Wyświetl plik

@ -1,5 +1,7 @@
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.toHexKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
@ -18,7 +20,15 @@ class TextNoteEvent(
companion object {
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 tags = mutableListOf<List<String>>()
replyTos?.forEach {
@ -33,9 +43,19 @@ class TextNoteEvent(
findHashtags(msg).forEach {
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 sig = Utils.sign(id, privateKey)
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 }
}

Wyświetl plik

@ -7,7 +7,7 @@ import java.util.regex.Pattern
object Nip19 {
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)
@ -76,7 +76,7 @@ object Nip19 {
?.get(0)
?.toString(Charsets.UTF_8)
return Return(Type.USER, hex, relay)
return Return(Type.EVENT, hex, relay)
}
private fun nrelay(bytes: ByteArray): Return? {

Wyświetl plik

@ -31,9 +31,10 @@ class Relay(
private var socket: WebSocket? = null
private var isReady: Boolean = false
var eventDownloadCounter = 0
var eventDownloadCounterInBytes = 0
var eventUploadCounterInBytes = 0
var spamCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
var ping: Long? = null
@ -89,7 +90,7 @@ class Relay(
when (type) {
"EVENT" -> {
// Log.w("Relay", "Relay onEVENT $url, $channel")
eventDownloadCounter++
eventDownloadCounterInBytes += text.bytesUsedInMemory()
val event = Event.fromJson(msg[2], Client.lenient)
listeners.forEach { it.onEvent(this@Relay, channel, event) }
}
@ -185,6 +186,7 @@ class Relay(
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]"""
// println("FILTERSSENT ${url} ${request}")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
}
}
} else {
@ -209,8 +211,9 @@ class Relay(
fun send(signedEvent: EventInterface) {
if (write) {
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
eventUploadCounter++
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
}
@ -257,3 +260,7 @@ class Relay(
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
}
}
fun String.bytesUsedInMemory(): Int {
return (8 * ((((this.length) * 2) + 45) / 8))
}

Wyświetl plik

@ -1,8 +1,11 @@
package com.vitorpamplona.amethyst.ui
import android.content.Context
import android.content.Intent
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
@ -34,6 +37,8 @@ class MainActivity : FragmentActivity() {
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> "Event/${nip19.hex}"
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
}
@ -94,3 +99,13 @@ class MainActivity : FragmentActivity() {
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/*"))
}
}
}

Wyświetl plik

@ -19,13 +19,16 @@ object ImageUploader {
onError: (Throwable) -> Unit
) {
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 requestBody: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"image",
category,
"${UUID.randomUUID()}",
object : RequestBody() {
override fun contentType(): MediaType? =
@ -46,7 +49,7 @@ object ImageUploader {
val request: Request = Request.Builder()
.header("Authorization", "Client-ID e6aea87296f3f96")
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url("https://api.imgur.com/3/image")
.url(url)
.post(requestBody)
.build()

Wyświetl plik

@ -14,6 +14,8 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@ -85,6 +87,8 @@ class NewPostViewModel : ViewModel() {
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) {
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) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
@ -105,6 +109,10 @@ class NewPostViewModel : ViewModel() {
} else if (results?.key?.type == Nip19.Type.NOTE) {
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}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
@ -140,12 +148,16 @@ class NewPostViewModel : ViewModel() {
onSuccess = { imageUrl ->
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
viewModelScope.launch(Dispatchers.IO) {
delay(2000)
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image")
imageUploadingError.emit("Failed to upload the image / video")
}
}
)

Wyświetl plik

@ -150,27 +150,27 @@ fun ServerConfigHeader() {
Column(Modifier.weight(1.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(25.dp))
Spacer(modifier = Modifier.size(30.dp))
Text(
text = stringResource(R.string.posts),
text = stringResource(R.string.bytes),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Spacer(modifier = Modifier.size(10.dp))
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(id = R.string.posts),
text = stringResource(id = R.string.bytes),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Spacer(modifier = Modifier.size(10.dp))
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(R.string.errors),
@ -371,10 +371,10 @@ fun ServerConfig(
}
Text(
text = "${countToHumanReadable(item.downloadCount)}",
text = "${countToHumanReadable(item.downloadCountInBytes)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
fontSize = 12.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@ -399,10 +399,10 @@ fun ServerConfig(
}
Text(
text = "${countToHumanReadable(item.uploadCount)}",
text = "${countToHumanReadable(item.uploadCountInBytes)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
fontSize = 12.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@ -418,7 +418,7 @@ fun ServerConfig(
Text(
text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1,
fontSize = 14.sp,
fontSize = 12.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@ -433,7 +433,7 @@ fun ServerConfig(
Text(
text = "${countToHumanReadable(item.spamCount)}",
maxLines = 1,
fontSize = 14.sp,
fontSize = 12.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)

Wyświetl plik

@ -49,23 +49,23 @@ class NewRelayListViewModel : ViewModel() {
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed()
}.sortedBy { it.downloadCountInBytes }.reversed()
} else {
account.localRelays.map {
val liveRelay = RelayPool.getRelay(it.url)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed()
}.sortedBy { it.downloadCountInBytes }.reversed()
}
}
}

Wyświetl plik

@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.actions
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
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.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalPermissionsApi::class)
@ -150,7 +150,7 @@ fun GallerySelect(
) {
var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
contract = GetMediaActivityResultContract(),
onResult = { uri: Uri? ->
onImageUri(uri)
hasLaunched.set(false)
@ -161,7 +161,7 @@ fun GallerySelect(
fun LaunchGallery() {
SideEffect {
if (!hasLaunched.getAndSet(true)) {
launcher.launch("image/*")
launcher.launch("*/*")
}
}
}

Wyświetl plik

@ -12,6 +12,7 @@ import androidx.compose.ui.text.withStyle
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
@Composable
@ -47,11 +48,28 @@ fun ClickableRoute(
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, "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 {
Text(
"@${nip19.hex}${nip19.additionalChars} "

Wyświetl plik

@ -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)
)
}

Wyświetl plik

@ -37,6 +37,7 @@ import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
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.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@ -68,6 +69,8 @@ fun isValidURL(url: String?): Boolean {
}
}
val richTextDefaults = RichTextStyle().resolveDefaults()
@Composable
fun RichTextViewer(
content: String,
@ -78,8 +81,8 @@ fun RichTextViewer(
accountViewModel: AccountViewModel,
navController: NavController
) {
val myMarkDownStyle = RichTextStyle().resolveDefaults().copy(
codeBlockStyle = RichTextStyle().resolveDefaults().codeBlockStyle?.copy(
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
@ -99,7 +102,7 @@ fun RichTextViewer(
.compositeOver(backgroundColor)
)
),
stringStyle = RichTextStyle().resolveDefaults().stringStyle?.copy(
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
@ -128,6 +131,20 @@ fun RichTextViewer(
)
}
} 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
content.split('\n').forEach { paragraph ->
FlowRow() {
@ -136,17 +153,21 @@ fun RichTextViewer(
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
ZoomableImageView(word, imagesForPager)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} 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()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {

Wyświetl plik

@ -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)
)
}

Wyświetl plik

@ -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
)
)
}
}

Wyświetl plik

@ -18,22 +18,27 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
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.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
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.SaveToGallery
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@Composable
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String) {
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
@ -49,7 +54,11 @@ fun ZoomableImageView(word: String) {
.padding(top = 4.dp)
.fillMaxWidth()
.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(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
@ -57,12 +66,13 @@ fun ZoomableImageView(word: String) {
)
if (dialogOpen) {
ZoomableImageDialog(word, onDismiss = { dialogOpen = false })
ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(imageUrl), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
@ -71,6 +81,8 @@ fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
Column(
modifier = Modifier.padding(10.dp)
) {
var pagerState: PagerState = remember { PagerState() }
Row(
modifier = Modifier
.fillMaxWidth(),
@ -79,10 +91,34 @@ fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
) {
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())
)
}
}
}
}

Wyświetl plik

@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeConversationsFeedFilter : FeedFilter<Note>() {
@ -16,7 +15,7 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
return LocalCache.notes.values
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent) &&
(it.event is TextNoteEvent) &&
(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) } ?: true &&

Wyświetl plik

@ -17,7 +17,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val notes = LocalCache.notes.values
.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)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
@ -26,7 +26,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val longFormNotes = LocalCache.addressables.values
.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)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&

Wyświetl plik

@ -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.HiddenUsersScreen
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.ProfileScreen
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) {

Wyświetl plik

@ -87,6 +87,12 @@ sealed class Route(
icon = R.drawable.ic_moments,
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 })
)
}
// **

Wyświetl plik

@ -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
)
}
}
}
}
}

Wyświetl plik

@ -365,7 +365,7 @@ private fun RelayBadges(baseNote: Note) {
FlowRow(Modifier.padding(start = 10.dp)) {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://")
val url = it.removePrefix("wss://").removePrefix("ws://")
Box(
Modifier
.size(15.dp)

Wyświetl plik

@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -76,18 +77,18 @@ fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State<Bool
}
@Composable
fun ObserveDisplayNip05Status(baseNote: Note) {
fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifier) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val author = note.author
if (author != null) {
ObserveDisplayNip05Status(author)
ObserveDisplayNip05Status(author, columnModifier)
}
}
@Composable
fun ObserveDisplayNip05Status(baseUser: User) {
fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
@ -96,52 +97,54 @@ fun ObserveDisplayNip05Status(baseUser: User) {
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_") {
Text(
text = AnnotatedString(nip05.split("@")[0]),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_") {
Text(
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,
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
)
}
}
}

Wyświetl plik

@ -10,8 +10,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.*
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.MoreVert
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
@ -42,6 +44,7 @@ import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
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.ChannelMessageEvent
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.PrivateDmEvent
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.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
@OptIn(ExperimentalFoundationApi::class)
@Composable
@ -273,15 +279,8 @@ fun NoteCompose(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
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))
)
} else {
DisplayFollowingHashtagsInPost(noteEvent, account, navController)
}
Text(
@ -306,7 +305,14 @@ fun NoteCompose(
}
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))
@ -394,7 +400,7 @@ fun NoteCompose(
thickness = 0.25.dp
)
} else if (noteEvent is LongTextNoteEvent) {
LongFormHeader(noteEvent)
LongFormHeader(noteEvent, note, account.userProfile())
ReactionsRow(note, accountViewModel)
@ -473,15 +479,26 @@ fun NoteCompose(
!noteForReports.hasAnyReports()
if (eventContent != null) {
TranslateableRichTextViewer(
eventContent,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags(),
backgroundColor,
accountViewModel,
navController
)
if (makeItShort && note.author == account.userProfile()) {
Text(
text = eventContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
} else {
TranslateableRichTextViewer(
eventContent,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags(),
backgroundColor,
accountViewModel,
navController
)
DisplayUncitedHashtags(noteEvent, eventContent, navController)
}
}
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
fun BadgeDisplay(baseNote: Note) {
val background = MaterialTheme.colors.background
@ -565,7 +706,7 @@ fun BadgeDisplay(baseNote: Note) {
}
@Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row(
modifier = Modifier
.clip(shape = RoundedCornerShape(15.dp))
@ -586,6 +727,35 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
contentScale = ContentScale.FillWidth,
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 {
@ -610,6 +780,16 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
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
)
}
}
}

Wyświetl plik

@ -79,7 +79,11 @@ private fun lightenColor(color: Color, amount: Float): Color {
val externalLinkForNote = { note: Note ->
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 {
"https://snort.social/e/${note.idNote()}"
}

Wyświetl plik

@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.qrcode
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@ -19,6 +20,8 @@ fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> "Event/${nip19.hex}"
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
}
@ -28,6 +31,7 @@ fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
onScan(null)
}
} catch (e: Throwable) {
Log.e("NIP19 Scanner", "Error parsing $it", e)
// QR can be anything, do not throw errors.
onScan(null)
}

Wyświetl plik

@ -57,6 +57,9 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
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.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@ -250,6 +253,8 @@ fun NoteMaster(
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(baseNote, Modifier.weight(1f))
DisplayFollowingHashtagsInPost(noteEvent, account, navController)
Text(
timeAgo(note.createdAt(), context = context),
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,
navController
)
DisplayUncitedHashtags(noteEvent, eventContent, navController)
}
ReactionsRow(note, accountViewModel)

Wyświetl plik

@ -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))
}
}

Wyświetl plik

@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
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.dal.UserProfileBookmarksFeedFilter
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))
}
@ -406,7 +407,7 @@ private fun ProfileHeader(
@OptIn(ExperimentalLayoutApi::class)
@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 user = userState?.user ?: return
@ -554,11 +555,18 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, navController:
}
user.info?.about?.let {
Text(
it,
color = MaterialTheme.colors.onSurface,
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
)
) {
TranslateableRichTextViewer(
content = it,
canPreview = false,
tags = null,
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}

Wyświetl plik

@ -22,6 +22,7 @@
<string name="copy_user_pubkey">Copiar PubKey do usuário</string>
<string name="copy_note_id">Copiar ID da Nota</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="report_spam_scam">Denunciar spam/fraude</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="relay_address">Endereço do Relay</string>
<string name="posts">Posts</string>
<string name="bytes">Bytes</string>
<string name="errors">Erros</string>
<string name="home_feed">Feed principal</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_note_id_to_clipboard">Copiado ID da nota (@note1)</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_share_browser_link">Compartilhar link do navegador</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="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="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_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>

Wyświetl plik

@ -74,6 +74,7 @@
<string name="failed_to_upload_the_image">Failed to upload the image</string>
<string name="relay_address">Relay Address</string>
<string name="posts">Posts</string>
<string name="bytes">Bytes</string>
<string name="errors">Errors</string>
<string name="home_feed">Home 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_pubkey">Wallet Connect Pubkey</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>

Wyświetl plik

@ -102,7 +102,7 @@ object LanguageTranslatorService {
while (matcher.find()) {
try {
val tag = matcher.group()
val short = "Amethysttagindexer$counter"
val short = "Amethystmindexer$counter"
returningList.put(short, tag)
} catch (e: Exception) {
}

Wyświetl plik

@ -69,4 +69,12 @@ class NIP19ParserTest {
assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result?.hex)
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)
}
}