diff --git a/app/build.gradle b/app/build.gradle index 8c7f72606..7159e0cdd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt b/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt index 3cf582051..60acac6a9 100644 --- a/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt +++ b/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt @@ -77,4 +77,13 @@ class TranslationsTest { "pt" ) } + + @Test + fun testNostrEvents() { + assertTranslateContains( + "nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", + "sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", + "en" + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index dc19cebca..6980884ca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -357,10 +357,10 @@ class Account( } } - fun sendPost(message: String, replyTo: List?, mentions: List?) { + fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = 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) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 150880f76..747424e67 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -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() || diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt index 1f11bff54..d5e21d43b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt @@ -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 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index d302dddfb..7ce4626ea 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -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 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnWithdrawalUtil.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnWithdrawalUtil.kt new file mode 100644 index 000000000..bc5d60083 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnWithdrawalUtil.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 37a973073..d5594a068 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -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) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags } override fun firstIsTaggedHashes(hashtags: Set) = 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. */ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt index b598066a8..f91027df6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt @@ -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): Boolean fun firstIsTaggedHashes(hashtag: Set): String? + fun hashtags(): List + + fun getReward(): BigDecimal? } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt index fa0755ac8..f64582ba3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt @@ -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?, mentions: List?, addresses: List?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent { + fun create( + msg: String, + replyTos: List?, + mentions: List?, + addresses: List?, + extraTags: List?, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): TextNoteEvent { val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val tags = mutableListOf>() 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 { + return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt index eef4955ce..b4bdda203 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt @@ -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? { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 111bbc9bb..39e22d39f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -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)) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 0b8a19a57..3afecdcb7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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/*")) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt index 7518d15a3..574fef2b1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt @@ -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() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 225f6f8f7..881947e14 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -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") } } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt index ba64aa835..fb79ef074 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt @@ -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) ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt index cafe4d200..70ce25a38 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt @@ -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() } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt index d8797f355..652fb0738 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt @@ -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("*/*") } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 0e02d1e9f..f9117a13e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -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} " diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt new file mode 100644 index 000000000..418e25b45 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -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) + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index b306533d2..08e906e78 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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() + + 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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt new file mode 100644 index 000000000..03636135b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt @@ -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) + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt deleted file mode 100644 index a55282ce0..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt +++ /dev/null @@ -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 - ) - ) - } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt index b2196de82..9e968f27e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt @@ -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 = 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 = 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()) + ) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index 8fa9f5ff4..50385acfd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -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() { @@ -16,7 +15,7 @@ object HomeConversationsFeedFilter : FeedFilter() { 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 && diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index 1b4089bf2..ca88aaa96 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -17,7 +17,7 @@ object HomeNewThreadFeedFilter : FeedFilter() { 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() { 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 && diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index a4565e4bc..f22e0ddda 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 2062c2005..99ea241af 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -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 }) + ) } // ** diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/AddBountyAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/AddBountyAmountDialog.kt new file mode 100644 index 000000000..4791d00b4 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/AddBountyAmountDialog.kt @@ -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 + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 3b127688e..36e6423c5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index e5e538c98..53c570214 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -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 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 - ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index eb04870a4..7d4172bcf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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( + 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 + ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 1a6819e42..b6a8a5496 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -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()}" } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt index 3fe48a5df..c1f31f99c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt @@ -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) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index d7173f819..3d5f67be7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt new file mode 100644 index 000000000..1cac914e9 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 1e1d59578..5908a4003 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -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 + ) + } } } diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index b1bccd424..2db0e85dc 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -22,6 +22,7 @@ Copiar PubKey do usuário Copiar ID da Nota Transmitir + Pedir para excluir Bloquear e ocultar usuário Denunciar spam/fraude Denunciar representação @@ -72,6 +73,7 @@ Falha ao enviar imagem Endereço do Relay Posts + Bytes Erros Feed principal Feed de mensagens privadas @@ -191,6 +193,14 @@ Copiado @npub do autor Copiado ID da nota (@note1) Selecionar texto + Adicionar Nova Conta + Contas + Selecionar Conta + Adicionar Nova Conta + Ativar conta + Tem chave privada + Somente leitura, sem chave privada + Voltar Selecionar Compartilhar link do navegador Compartilhar @@ -204,6 +214,38 @@ 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. Copia de segurança das chaves <Não foi possível descriptografar a mensagem privada>\\n\\nVocê foi citado em uma conversa privada/criptografada entre %1$s and %2$s. + Bloquear + Excluir + Bloquear + Denunciar Excluir Não mostar novamente + Spam ou golpes + Palavrões ou conduta odiosa + Personificação maliciosa + Nudez ou conteúdo gráfico + Comportamento ilegal + 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. + + Denunciar abuso + Todos as denuncias postadas serão visíveis publicamente. + Opcionalmente, forneça contexto adicional sobre a sua denuncia… + Contexto Adicional + Motivo + Selecione um motivo… + Enviar Denuncia + Bloquear e Denunciar + Bloquear + Itens Salvos + Itens Salvos Privados + Itens Salvos Públicos + Adicionar aos Itens Salvos Privados + Adicionar aos Itens Salvos Públicos + Remover dos Itens Salvos Privados + Remover dos Itens Salvos Públicos + Serviço Wallet Connect + 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. + Wallet Connect Pubkey + Wallet Connect Relay + Valor em Sats diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b11f58cd..4e0428a04 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,6 +74,7 @@ Failed to upload the image Relay Address Posts + Bytes Errors Home Feed Private Message Feed @@ -260,4 +261,8 @@ 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. Wallet Connect Pubkey Wallet Connect Relay + + Pledge Amount in Sats + + "Looking for Event %1$s" diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index 8cfb1b47b..89c1e38de 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -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) { } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt index d7ee9db44..488cb27d2 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt @@ -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) + } }