Compare commits

...

35 Commits

Author SHA1 Message Date
Vitor Pamplona
486d43dc1b 0.40.3 2023-05-01 20:44:37 -04:00
Vitor Pamplona
6731ca59cd BugFix for mixing NIP94 and NIP95 selections 2023-05-01 20:43:31 -04:00
Vitor Pamplona
7d620cd89a BugFix for now showing the error message when the phone can't open the file 2023-05-01 20:43:14 -04:00
Vitor Pamplona
82a2586eab Adds language to warn that NIP94 and 95 are new and other clients might not see it. 2023-05-01 20:42:48 -04:00
Vitor Pamplona
5a3f912b73 Merge pull request #389 from vivganes/translation-ta
Add tamil translations
2023-05-01 20:06:56 -04:00
Vitor Pamplona
bc54134e65 0.40.2 2023-05-01 18:54:51 -04:00
Vitor Pamplona
0f080219b2 Starts on mute 2023-05-01 18:54:06 -04:00
Vitor Pamplona
ff54629601 Autoplay controls 2023-05-01 18:53:07 -04:00
Vitor Pamplona
8f257a7e2d - Displays Video Uploading Progress
- Keeps position in the Video feed between screen
- Animates to the top when posting
- Adds NIP-95 in-disk caching
- Fixes the Crossfade for BlurHash on NIP94 and NIP95 events
2023-05-01 16:42:58 -04:00
Vitor Pamplona
9015089593 Correctly loading NIP95 events from relays. 2023-05-01 16:39:07 -04:00
Vitor Pamplona
2b87930283 Removing the need to prune NIP95 events. 2023-05-01 16:38:35 -04:00
vivganes
15d94ca9c4 Add tamil translations 2023-05-01 21:15:55 +05:30
Vitor Pamplona
44af7f958c Merge pull request #388 from vitorpamplona/zap_payments
Zap payments without zap services on the receiving end.
2023-05-01 11:22:01 -04:00
Vitor Pamplona
2b2028853e Finishes the payment database 2023-05-01 11:12:46 -04:00
Vitor Pamplona
66dcc03351 Merge remote-tracking branch 'origin/HEAD' into zap_payments 2023-05-01 08:55:52 -04:00
Vitor Pamplona
22ba150c4e v0.40.1 2023-04-30 21:35:53 -04:00
Vitor Pamplona
63cc00976f Fixes https://github.com/vitorpamplona/amethyst/issues/387 2023-04-30 21:34:19 -04:00
Vitor Pamplona
cc9d4c237a Fixes Bug with the Nostr's Report use of double asterisk 2023-04-30 21:31:37 -04:00
Vitor Pamplona
446135ea2a Adding Zap Payments to the calculation of amounts and zapped by 2023-04-30 21:30:30 -04:00
Vitor Pamplona
a38bbd7168 Adding hex index for the Video Feed 2023-04-30 17:00:47 -04:00
Vitor Pamplona
635ed1afc1 v0.40.0 2023-04-29 19:45:27 -04:00
Vitor Pamplona
130ad40a3a Merge pull request #385 from vitorpamplona/tiktok
Tiktok
2023-04-29 19:44:36 -04:00
Vitor Pamplona
be5e58111e Merge remote-tracking branch 'origin/HEAD' 2023-04-29 19:31:12 -04:00
Vitor Pamplona
8b012884b1 0.39.0 2023-04-29 19:31:00 -04:00
Vitor Pamplona
4949a5c459 Merge pull request #384 from ZsZolee/main
Upload server Hungarian translation
2023-04-29 19:30:06 -04:00
Vitor Pamplona
ab330fea96 v0.39.0 2023-04-29 19:29:42 -04:00
Vitor Pamplona
8209c45303 New Video Feed based on NIP-94 and 95 2023-04-29 19:28:46 -04:00
Zoltan
206935289e Upload server Hungarian translation 2023-04-29 10:45:16 +02:00
Vitor Pamplona
571ff0e72b v0.38.0 2023-04-28 18:42:44 -04:00
Vitor Pamplona
940914adf5 Merge remote-tracking branch 'origin/HEAD' 2023-04-28 18:40:32 -04:00
Vitor Pamplona
1496f012a5 Adds new Highlight event kind from https://highlighter.com/ 2023-04-28 18:40:12 -04:00
Vitor Pamplona
2a552bea4d Update PRIVACY.md
Clarifying that the PRIVACY policy covers the distribution usage and not the source code usage.
2023-04-27 19:30:20 -04:00
Vitor Pamplona
d1dc06a467 v0.37.4 2023-04-27 18:29:04 -04:00
Vitor Pamplona
f173f3194b Adjusts size of the Zap Forward text. 2023-04-27 18:27:56 -04:00
Vitor Pamplona
8ca63f32f4 Avoids flickering in the Global Feed 2023-04-27 18:20:04 -04:00
50 changed files with 2106 additions and 202 deletions

View File

@@ -1,12 +1,12 @@
# Amethyst Privacy Policy & Terms of Use
Effective as of Jan 20, 2023.
Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.

View File

@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 130
versionName "0.37.3"
versionCode 137
versionName "0.40.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@@ -72,7 +72,6 @@ object ServiceManager {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(it)
LocalCache.pruneFileStorageEvents(it)
// LocalCache.pruneNonFollows(it)
}
}

View File

@@ -20,6 +20,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.Persona
import nostr.postr.Utils
import java.math.BigDecimal
import java.util.Locale
val DefaultChannels = setOf(
@@ -170,7 +172,32 @@ class Account(
return zapPaymentRequest != null
}
fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) {
fun isNIP47Author(pubkeyHex: String?): Boolean {
val privKey = zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!
val pubKey = Utils.pubkeyCreate(privKey).toHexKey()
return (pubKey == pubkeyHex)
}
fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? {
val myNip47 = zapPaymentRequest ?: return null
return zapResponseEvent.response(
myNip47.secret?.toByteArray() ?: loggedIn.privKey!!,
myNip47.pubKeyHex.toByteArray()
)
}
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
return zappedNote.isZappedBy(userProfile(), this) == true
}
fun calculateZappedAmount(zappedNote: Note?): BigDecimal {
return zappedNote?.zappedAmount(
zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!,
zapPaymentRequest?.pubKeyHex?.toByteArray()
) ?: BigDecimal.ZERO
}
fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) {
if (!isWriteable()) return
zapPaymentRequest?.let { nip47 ->
@@ -179,11 +206,11 @@ class Account(
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
wcListener.start()
LocalCache.consume(event) {
LocalCache.consume(event, zappedNote) {
// After the response is received.
val privKey = nip47.secret?.toByteArray()
if (privKey != null) {
onResponse(it.response(privKey, event.pubKey.toByteArray()))
onResponse(it.response(privKey, nip47.pubKeyHex.toByteArray()))
}
}
@@ -376,7 +403,7 @@ class Account(
}
}
fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? {
fun createNip95(data: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
if (!isWriteable()) return null
val data = FileStorageEvent.create(
@@ -395,11 +422,17 @@ class Account(
privateKey = loggedIn.privKey!!
)
return Pair(data, signedEvent)
}
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
if (!isWriteable()) return null
Client.send(data)
LocalCache.consume(data)
LocalCache.consume(data, null)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
}
@@ -418,7 +451,7 @@ class Account(
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
}

View File

@@ -1,10 +1,11 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -12,6 +13,9 @@ import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
import nostr.postr.toNpub
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -31,7 +35,8 @@ object LocalCache {
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, (LnZapPaymentResponseEvent) -> Unit>(10)
val awaitingPaymentRequests =
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
@@ -379,6 +384,7 @@ object LocalCache {
masterNote.removeBoost(deleteNote)
masterNote.removeReaction(deleteNote)
masterNote.removeZap(deleteNote)
masterNote.removeZapPayment(deleteNote)
masterNote.removeReport(deleteNote)
}
@@ -653,41 +659,87 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: FileHeaderEvent) {
fun consume(event: FileHeaderEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: FileStorageHeaderEvent) {
fun consume(event: FileStorageHeaderEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: FileStorageEvent) {
fun consume(event: HighlightEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
// Adds to user profile
author.addNote(note)
refreshObservers(note)
}
fun consume(event: FileStorageEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
note.loadEvent(event, author, emptyList())
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
cachePath.mkdirs()
val stream = FileOutputStream(File(cachePath, event.id))
stream.write(event.decode())
stream.close()
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
} catch (e: IOException) {
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
}
// this is an invalid event. But we don't need to keep the data in memory.
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
note.loadEvent(eventNoData, author, emptyList())
refreshObservers(note)
}
@@ -696,12 +748,41 @@ object LocalCache {
// Does nothing without a response callback.
}
fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
awaitingPaymentRequests.put(event.id, onResponse)
fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
zappedNote?.addZapPayment(note, null)
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
refreshObservers(note)
}
fun consume(event: LnZapPaymentResponseEvent) {
val responseCallback = awaitingPaymentRequests[event.requestId()]
val requestId = event.requestId()
val pair = awaitingPaymentRequests[requestId] ?: return
val (zappedNote, responseCallback) = pair
val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
requestNote?.let { request ->
zappedNote?.addZapPayment(request, note)
}
if (responseCallback != null) {
responseCallback(event)
@@ -749,14 +830,6 @@ object LocalCache {
}
}
fun pruneFileStorageEvents(account: Account) {
val toBeRemoved = notes.filter { it.value.event is FileStorageEvent }
toBeRemoved.forEach {
notes.remove(it.key)
}
}
fun pruneOldAndHiddenMessages(account: Account) {
channels.forEach { it ->
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.EOSETime
@@ -45,6 +46,8 @@ open class Note(val idHex: String) {
private set
var zaps = mapOf<Note, Note?>()
private set
var zapPayments = mapOf<Note, Note?>()
private set
var relays = setOf<String>()
private set
@@ -156,6 +159,17 @@ open class Note(val idHex: String) {
}
}
fun removeZapPayment(note: Note) {
if (zapPayments[note] != null) {
zapPayments = zapPayments.minus(note)
liveSet?.zaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
val toRemove = zapPayments.filterValues { it == note }
zapPayments = zapPayments.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
@@ -174,6 +188,17 @@ open class Note(val idHex: String) {
}
}
@Synchronized
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
if (zapPaymentRequest !in zapPayments.keys) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
}
}
fun addReaction(note: Note) {
if (note !in reactions) {
reactions = reactions + note
@@ -204,6 +229,14 @@ open class Note(val idHex: String) {
// Zaps who the requester was the user
return zaps.any {
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
} || zapPayments.any {
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
val response = if (zapResponseEvent != null) {
account.decryptZapPaymentResponseEvent(zapResponseEvent)
} else {
null
}
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
}
}
@@ -233,12 +266,44 @@ open class Note(val idHex: String) {
}.flatten()
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
// Regular Zap Receipts
val completedZaps = zaps.asSequence()
.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
it.amount
}.sumOf { it }
.filter { it.amount != null }
.associate {
it.lnInvoice() to it.amount
}
.toMap()
val completedPayments = if (privKey != null && walletServicePubkey != null) {
// Payments confirmed by the User's Wallet
zapPayments
.asSequence()
.filter {
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
response is PayInvoiceSuccessResponse
}
.associate {
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey)
val amount = try {
if (lnInvoice == null) {
null
} else {
LnInvoiceUtil.getAmountInSats(lnInvoice)
}
} catch (e: java.lang.Exception) {
null
}
lnInvoice to amount
}
.toMap()
} else {
emptyMap()
}
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
}
fun hasPledgeBy(user: User): Boolean {

View File

@@ -19,12 +19,24 @@ class FileHeader(
) {
companion object {
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
val imageData = URL(fileUrl).readBytes()
try {
val imageData = URL(fileUrl).readBytes()
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError()
}
}
fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
fun prepare(
data: ByteArray,
fileUrl: String,
mimeType: String?,
description: String?,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
) {
try {
val sha256 = MessageDigest.getInstance("SHA-256")

View File

@@ -76,9 +76,10 @@ abstract class NostrDataSource(val debugName: String) {
is ContactListEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is FileHeaderEvent -> LocalCache.consume(event)
is FileStorageEvent -> LocalCache.consume(event)
is FileStorageHeaderEvent -> LocalCache.consume(event)
is FileHeaderEvent -> LocalCache.consume(event, relay)
is FileStorageEvent -> LocalCache.consume(event, relay)
is FileStorageHeaderEvent -> LocalCache.consume(event, relay)
is HighlightEvent -> LocalCache.consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -12,7 +13,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
limit = 200
)
)

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -55,7 +56,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.relayList
@@ -71,7 +72,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())

View File

@@ -60,7 +60,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind, HighlightEvent.kind),
search = mySearchString,
limit = 20
)

View File

@@ -79,7 +79,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind,
PollNoteEvent.kind
PollNoteEvent.kind,
HighlightEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -117,7 +118,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind
),
ids = interestedEvents.toList()
)

View File

@@ -35,7 +35,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)

View File

@@ -0,0 +1,23 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200
)
)
val videoFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
}
}

View File

@@ -86,10 +86,6 @@ open class BaseTextNoteEvent(
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) {
if (content.contains("Testing event")) {
println("AAAA $key")
}
try {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }

View File

@@ -41,6 +41,8 @@ open class Event(
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
@@ -205,6 +207,7 @@ open class Event(
.registerTypeAdapter(Event::class.java, EventDeserializer())
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
.create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
@@ -227,6 +230,7 @@ open class Event(
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)

View File

@@ -0,0 +1,36 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class HighlightEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun inUrl() = taggedUrls().firstOrNull()
fun author() = taggedUsers().firstOrNull()
fun quote() = content
companion object {
const val kind = 9802
fun create(
msg: String,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}

View File

@@ -57,7 +57,7 @@ class LnZapEvent(
return content
}
private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)

View File

@@ -1,9 +1,14 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import nostr.postr.Utils
import java.lang.reflect.Type
class LnZapPaymentResponseEvent(
id: HexKey,
@@ -36,7 +41,8 @@ class LnZapPaymentResponseEvent(
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
if (content.isNotEmpty()) {
gson.fromJson(decrypt(privKey, pubKey), Response::class.java)
val decrypted = decrypt(privKey, pubKey)
gson.fromJson(decrypted, Response::class.java)
} else {
null
}
@@ -58,7 +64,7 @@ abstract class Response(
// PayInvoice Call
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) :
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) :
Response("pay_invoice") {
class PayInvoiceResultParams(val preimage: String)
}
@@ -86,3 +92,39 @@ class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
OTHER // Other error.
}
}
class ResponseDeserializer :
JsonDeserializer<Response?> {
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): Response? {
val jsonObject = json.asJsonObject
val resultType = jsonObject.get("result_type")?.asString
if (resultType == "pay_invoice") {
val result = jsonObject.get("result")?.asJsonObject
val error = jsonObject.get("error")?.asJsonObject
if (result != null) {
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (error != null) {
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
}
} else {
// tries to guess
if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
}
}
return null
}
companion object {
}
}

View File

@@ -22,6 +22,7 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.muted
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
@@ -92,6 +93,8 @@ class MainActivity : FragmentActivity() {
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
super.onResume()
// starts muted every time
muted.value = true
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {

View File

@@ -79,7 +79,7 @@ object ImageSaver {
}
fun saveImage(
byteArray: ByteArray,
localFile: File,
mimeType: String?,
context: Context,
onSuccess: () -> Any?,
@@ -87,7 +87,7 @@ object ImageSaver {
) {
try {
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val buffer = byteArray.inputStream().source().buffer()
val buffer = localFile.inputStream().source().buffer()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveContentQ(

View File

@@ -0,0 +1,212 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import java.net.URL
open class NewMediaModel : ViewModel() {
var account: Account? = null
var isUploadingImage by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>()
var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServersAvailable?>(null)
var description by mutableStateOf("")
// Images and Videos
var galleryUri by mutableStateOf<Uri?>(null)
var uploadingPercentage = mutableStateOf(0.0f)
var uploadingDescription = mutableStateOf<String?>(null)
var onceUploaded: () -> Unit = {}
open fun load(account: Account, uri: Uri, contentType: String?) {
this.account = account
this.galleryUri = uri
this.mediaType = contentType
this.selectedServer = defaultServer()
if (selectedServer == ServersAvailable.IMGUR) {
selectedServer = ServersAvailable.IMGUR_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRIMG) {
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
}
}
fun upload(context: Context) {
isUploadingImage = true
val contentResolver = context.contentResolver
val uri = galleryUri ?: return
val serverToUse = selectedServer ?: return
if (selectedServer == ServersAvailable.NIP95) {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Loading"
val contentType = contentResolver.getType(uri)
contentResolver.openInputStream(uri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
?: run {
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
}
}
} else {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Uploading"
ImageUploader.uploadImage(
uri = uri,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
open fun cancel() {
galleryUri = null
isUploadingImage = false
mediaType = null
uploadingDescription.value = null
uploadingPercentage.value = 0.0f
description = ""
selectedServer = account?.defaultFileServer
}
fun canPost(): Boolean {
return !isUploadingImage && galleryUri != null && selectedServer != null
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) {
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) {
delay(2000)
} else {
delay(15000)
}
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
try {
val imageData = URL(imageUrl).readBytes()
uploadingPercentage.value = 0.80f
uploadingDescription.value = "Hashing"
FileHeader.prepare(
imageData,
imageUrl,
mimeType,
description,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
val note = account?.sendHeader(it)
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
},
onError = {
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
uploadingPercentage.value = 0.20f
uploadingDescription.value = "Hashing"
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
description,
onReady = {
uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.30f
val nip95 = account?.createNip95(bytes, headerInfo = it)
if (nip95 != null) {
uploadingDescription.value = "Sending"
uploadingPercentage.value = 0.60f
account?.sendNip95(nip95.first, nip95.second)
}
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
},
onError = {
uploadingDescription.value = null
uploadingPercentage.value = 0.00f
isUploadingImage = false
cancel()
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
fun isImage() = mediaType?.startsWith("image")
fun isVideo() = mediaType?.startsWith("video")
fun defaultServer() = account?.defaultFileServer
fun onceUploaded(onceUploaded: () -> Unit) {
this.onceUploaded = onceUploaded
}
}

View File

@@ -0,0 +1,222 @@
package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
val account = accountViewModel.accountLiveData.value?.account ?: return
val resolver = LocalContext.current.contentResolver
val context = LocalContext.current
val scroolState = rememberScrollState()
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.fillMaxWidth()
.fillMaxHeight().imePadding()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
PostButton(
onPost = {
onClose()
postViewModel.upload(context)
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
},
isActive = postViewModel.canPost()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scroolState)
) {
ImageVideoPost(postViewModel, account)
}
}
}
}
}
}
@Composable
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
val scope = rememberCoroutineScope()
val fileServers = listOf(
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = fileServers.map { it.second }
val fileServerExplainers = fileServers.map { it.third }
val resolver = LocalContext.current.contentResolver
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
if (postViewModel.isImage() == true) {
AsyncImage(
model = postViewModel.galleryUri.toString(),
contentDescription = postViewModel.galleryUri.toString(),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
)
} else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(key1 = postViewModel.galleryUri) {
scope.launch(Dispatchers.IO) {
postViewModel.galleryUri?.let {
try {
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
} catch (e: Exception) {
postViewModel.imageUploadingError.emit("Unable to load file")
}
}
}
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "some useful description",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
)
}
} else {
postViewModel.galleryUri?.let {
VideoView(it)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
TextSpinner(
label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
options = fileServerOptions,
explainers = fileServerExplainers,
onSelect = {
postViewModel.selectedServer = fileServers[it].first
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.weight(1f)
)
}
if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 ||
postViewModel.selectedServer == ServersAvailable.NIP95
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = postViewModel.description,
onValueChange = { postViewModel.description = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
)
)
}
}
}

View File

@@ -467,7 +467,8 @@ private fun ForwardZapTo(
placeholder = {
Text(
text = stringResource(R.string.zap_forward_lnAddress),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 14.sp
)
},
colors = TextFieldDefaults

View File

@@ -329,7 +329,8 @@ open class NewPostViewModel : ViewModel() {
mimeType,
description,
onReady = {
val note = account?.sendNip95(bytes, headerInfo = it)
val nip95 = account?.createNip95(bytes, headerInfo = it)
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
isUploadingImage = false

View File

@@ -18,6 +18,7 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import kotlinx.coroutines.launch
import java.io.File
/**
* A button to save the remote image to the gallery.
@@ -86,14 +87,14 @@ fun SaveToGallery(url: String) {
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveToGallery(byteArray: ByteArray, mimeType: String?) {
fun SaveToGallery(localFile: File, mimeType: String?) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
fun saveImage() {
ImageSaver.saveImage(
context = localContext,
byteArray = byteArray,
localFile = localFile,
mimeType = mimeType,
onSuccess = {
scope.launch {

View File

@@ -86,9 +86,9 @@ fun RichTextViewer(
navController: NavController
) {
Column(modifier = modifier) {
if (content.startsWith("# ") ||
if (content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("**") ||
content.contains("__") ||
content.contains("```")
) {

View File

@@ -1,17 +1,48 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.media.AudioManager
import android.net.Uri
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.google.android.exoplayer2.C
@@ -19,10 +50,20 @@ import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.util.Util
import com.vitorpamplona.amethyst.VideoCache
import java.io.File
public var muted = mutableStateOf(true)
@Composable
fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(localFile.toUri(), description, onDialog)
}
@Composable
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
@@ -34,6 +75,14 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val myAudioManager =
context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val myAudioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
val exoPlayer = remember(videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
@@ -48,35 +97,76 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
setAudioAttributes(myAudioAttributes, true)
if (Util.SDK_INT >= 23) {
myAudioManager.adjustStreamVolume(
C.STREAM_TYPE_MUSIC,
if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
0
)
)
} else {
myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value)
}
if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media)
} else {
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
)
}
prepare()
}
}
DisposableEffect(
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
Box() {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible) {
exoPlayer.play()
} else {
exoPlayer.pause()
innerOnDialog(it)
}
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
hideController()
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
innerOnDialog(it)
}
}
}
}
)
MuteButton(muted, Modifier) {
muted.value = !muted.value
if (Util.SDK_INT >= 23) {
myAudioManager.adjustStreamVolume(
C.STREAM_TYPE_MUSIC,
if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
0
)
} else {
myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value)
}
}
)
}
) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
@@ -96,6 +186,75 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
}
}
@Composable
fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed {
val view = LocalView.current
var isVisible: Boolean? by remember { mutableStateOf(null) }
LaunchedEffect(isVisible) {
onVisibilityChanges(isVisible == true)
}
onGloballyPositioned { coordinates ->
isVisible = coordinates.isCompletelyVisible(view)
}
}
fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
if (!isAttached) return false
// Window relative bounds of our compose root view that are visible on the screen
val globalRootRect = android.graphics.Rect()
if (!view.getGlobalVisibleRect(globalRootRect)) {
// we aren't visible at all.
return false
}
val bounds = boundsInWindow()
// Make sure we are completely in bounds.
return bounds.top >= globalRootRect.top &&
bounds.left >= globalRootRect.left &&
bounds.right <= globalRootRect.right &&
bounds.bottom <= globalRootRect.bottom
}
@Composable
private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle: () -> Unit) {
Box(
modifier
.width(70.dp)
.height(70.dp)
.padding(10.dp)
) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
)
if (muted.value) {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
Icon(
imageVector = Icons.Default.VolumeOff,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
}
} else {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
Icon(
imageVector = Icons.Default.VolumeUp,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
}
}
}
}

View File

@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -71,6 +74,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.io.File
import java.security.MessageDigest
abstract class ZoomableContent(
@@ -106,7 +110,7 @@ abstract class ZoomablePreloadedContent(
) : ZoomableContent(description)
class ZoomableBitmapImage(
val byteArray: ByteArray?,
val localFile: File,
val mimeType: String? = null,
description: String? = null,
val bluehash: String? = null,
@@ -115,7 +119,7 @@ class ZoomableBitmapImage(
) : ZoomablePreloadedContent(description, isVerified, uri)
class ZoomableBytesVideo(
val byteArray: ByteArray,
val localFile: File,
val mimeType: String? = null,
description: String? = null,
isVerified: Boolean? = null,
@@ -166,7 +170,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
}
}
} else if (content is ZoomableBitmapImage) {
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
@@ -174,12 +178,8 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
}
}
} else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
verifiedHash = content.isVerified
}
}
@@ -214,7 +214,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
}
if (content is ZoomableUrlImage) {
Box() {
Box(contentAlignment = Alignment.Center) {
AsyncImage(
model = content.url,
contentDescription = content.description,
@@ -231,13 +231,16 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
if (imageState !is AsyncImagePainter.State.Success) {
if (content.bluehash != null) {
DisplayBlueHash(content, mainImageModifier)
} else {
DisplayUrlWithLoadingSymbol(content)
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.bluehash != null) {
DisplayBlueHash(content, mainImageModifier)
} else {
DisplayUrlWithLoadingSymbol(content)
}
}
}
} else if (content is ZoomableUrlVideo) {
@@ -245,7 +248,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
} else if (content is ZoomableBitmapImage) {
Box() {
AsyncImage(
model = content.byteArray,
model = content.localFile,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = mainImageModifier,
@@ -270,6 +273,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
}
}
} else if (content is ZoomableBytesVideo) {
VideoView(content.localFile, content.description) { dialogOpen = true }
}
if (dialogOpen) {
@@ -384,8 +388,8 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
val myContent = allImages[pagerState.currentPage]
if (myContent is ZoomableUrlContent) {
SaveToGallery(url = myContent.url)
} else if (myContent is ZoomableBitmapImage && myContent.byteArray != null) {
SaveToGallery(byteArray = myContent.byteArray, mimeType = myContent.mimeType)
} else if (myContent is ZoomableBitmapImage && myContent.localFile != null) {
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
}
}
@@ -406,7 +410,7 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
}
@Composable
private fun RenderImageOrVideo(content: ZoomableContent) {
fun RenderImageOrVideo(content: ZoomableContent) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -428,7 +432,7 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
}
}
} else if (content is ZoomableBitmapImage) {
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
@@ -436,12 +440,8 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
}
}
} else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
verifiedHash = content.isVerified
}
}
@@ -461,11 +461,21 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.bluehash != null) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
DisplayUrlWithLoadingSymbol(content)
}
}
}
} else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
@@ -474,7 +484,7 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
} else if (content is ZoomableBitmapImage) {
Box() {
AsyncImage(
model = content.byteArray,
model = content.localFile,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = Modifier
@@ -496,7 +506,7 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
}
} else if (content is ZoomableBytesVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.byteArray, content.description)
VideoView(content.localFile, content.description)
}
}
}

View File

@@ -45,6 +45,6 @@ object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
return collection
.asSequence()
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) &&
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&

View File

@@ -0,0 +1,40 @@
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.*
object VideoFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = System.currentTimeMillis() / 1000
return collection
.asSequence()
.filter {
it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent
}
.filter { account.isAcceptable(it) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= now
}
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@@ -47,6 +47,7 @@ import kotlin.time.ExperimentalTime
val bottomNavigationItems = listOf(
Route.Home,
Route.Message,
Route.Video,
Route.Search,
Route.Notification
)

View File

@@ -17,9 +17,11 @@ import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
@@ -34,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -58,10 +61,31 @@ fun AppNavigation(
GlobalFeedFilter.account = account
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel()
VideoFeedFilter.account = account
val videoFeedViewModel: NostrVideoFeedViewModel = viewModel()
NotificationFeedFilter.account = account
val notifFeedViewModel: NotificationViewModel = viewModel()
NavHost(navController, startDestination = Route.Home.route) {
Route.Video.let { route ->
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
VideoScreen(
videoFeedView = videoFeedViewModel,
accountViewModel = accountViewModel,
navController = navController,
scrollToTop = scrollToTop
)
// Avoids running scroll to top when back button is pressed
if (scrollToTop) {
it.arguments?.remove("scrollToTop")
}
})
}
Route.Search.let { route ->
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false

View File

@@ -41,6 +41,12 @@ sealed class Route(
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
)
object Video : Route(
route = "Video?scrollToTop={scrollToTop}",
icon = R.drawable.ic_video,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
)
object Notification : Route(
route = "Notification?scrollToTop={scrollToTop}",
icon = R.drawable.ic_notifications,

View File

@@ -58,7 +58,9 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.math.BigDecimal
import java.net.URL
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -517,6 +519,26 @@ fun NoteComposeInner(
ReactionsRow(note, accountViewModel, navController)
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
} else if (noteEvent is HighlightEvent) {
DisplayHighlight(
noteEvent.quote(),
noteEvent.author(),
noteEvent.inUrl(),
makeItShort,
canPreview,
backgroundColor,
accountViewModel,
navController
)
if (!makeItShort) {
ReactionsRow(note, accountViewModel, navController)
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
@@ -574,6 +596,66 @@ fun NoteComposeInner(
}
}
@Composable
fun DisplayHighlight(
highlight: String,
authorHex: String?,
url: String?,
makeItShort: Boolean,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
val quote = highlight.split("\n").map { "> *${it.removeSuffix(" ")}*" }.joinToString("\n")
if (quote != null) {
TranslatableRichTextViewer(
quote,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
emptyList(),
backgroundColor,
accountViewModel,
navController
)
}
FlowRow() {
authorHex?.let { authorHex ->
val userBase = LocalCache.checkGetOrCreateUser(authorHex)
if (userBase != null) {
val userState by userBase.live().metadata.observeAsState()
val user = userState?.user
if (user != null) {
CreateClickableText(
user.toBestDisplayName(),
"",
"User/${user.pubkeyHex}",
navController
)
}
}
}
url?.let { url ->
val validatedUrl = try {
URL(url)
} catch (e: Exception) {
Log.w("Note Compose", "Invalid URI: $url")
null
}
validatedUrl?.host?.let { host ->
Text("on ")
ClickableUrl(urlText = host, url = url)
}
}
}
}
@Composable
fun DisplayFollowingHashtagsInPost(
noteEvent: EventInterface,
@@ -811,6 +893,7 @@ fun FileHeaderDisplay(note: Note) {
@Composable
fun FileStorageHeaderDisplay(baseNote: Note) {
val appContext = LocalContext.current.applicationContext
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
val fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
@@ -825,16 +908,17 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
LaunchedEffect(key1 = eventHeader.id, key2 = noteState) {
withContext(Dispatchers.IO) {
val uri = "nostr:" + baseNote.toNEvent()
val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex)
val bytes = eventBytes?.decode()
val blurHash = eventHeader.blurhash()
val description = eventHeader.content
val mimeType = eventHeader.mimeType()
content = if (mimeType?.startsWith("image") == true) {
ZoomableBitmapImage(bytes, mimeType, description, blurHash, true, uri)
ZoomableBitmapImage(localDir, mimeType, description, blurHash, true, uri)
} else {
if (bytes != null) {
ZoomableBytesVideo(bytes, mimeType, description, true, uri)
ZoomableBytesVideo(localDir, mimeType, description, true, uri)
} else {
null
}

View File

@@ -56,8 +56,7 @@ fun PollNote(
val account = accountState?.account ?: return
val pollViewModel = PollNoteViewModel()
pollViewModel.account = account
pollViewModel.load(zappedNote)
pollViewModel.load(account, zappedNote)
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
OptionNote(
@@ -212,7 +211,7 @@ fun ZapVote(
clickablePrepend: @Composable () -> Unit
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note
val zappedNote = zapsState?.note ?: return
var wantsToZap by remember { mutableStateOf(false) }
@@ -253,7 +252,7 @@ fun ZapVote(
)
.show()
}
} else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
} else if (accountViewModel.isLoggedUser(zappedNote.author)) {
scope.launch {
Toast
.makeText(
@@ -376,7 +375,7 @@ fun ZapVote(
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
}
}
}

View File

@@ -20,8 +20,10 @@ class PollNoteViewModel {
var consensusThreshold: BigDecimal? = null
var totalZapped: BigDecimal = BigDecimal.ZERO
var wasZappedByAuthor: Boolean = false
fun load(note: Note?) {
fun load(acc: Account, note: Note?) {
account = acc
pollNote = note
pollEvent = pollNote?.event as PollNoteEvent
pollOptions = pollEvent?.pollOptions()
@@ -31,13 +33,14 @@ class PollNoteViewModel {
closedAt = pollEvent?.getTagInt(CLOSED_AT)
totalZapped = totalZapped()
wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
}
fun canZap(): Boolean {
val account = account ?: return false
val user = account.userProfile() ?: return false
val note = pollNote ?: return false
return user != note.author && !note.isZappedBy(user, account)
return user != note.author && !wasZappedByAuthor
}
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum

View File

@@ -45,6 +45,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -116,6 +117,7 @@ fun ReplyReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
showCounter: Boolean = true,
iconSize: Dp = 20.dp,
onPress: () -> Unit
) {
val repliesState by baseNote.live().replies.observeAsState()
@@ -125,7 +127,7 @@ fun ReplyReaction(
val scope = rememberCoroutineScope()
IconButton(
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
onClick = {
if (accountViewModel.isWriteable()) {
onPress()
@@ -140,7 +142,7 @@ fun ReplyReaction(
}
}
) {
ReplyIcon()
ReplyIcon(iconSize)
}
if (showCounter) {
@@ -153,19 +155,20 @@ fun ReplyReaction(
}
@Composable
private fun ReplyIcon() {
private fun ReplyIcon(iconSize: Dp = 15.dp) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
modifier = Modifier.size(iconSize),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@Composable
private fun BoostReaction(
public fun BoostReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
onQuotePress: () -> Unit
) {
val boostsState by baseNote.live().boosts.observeAsState()
@@ -178,7 +181,7 @@ private fun BoostReaction(
var wantsToBoost by remember { mutableStateOf(false) }
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
modifier = Modifier.then(Modifier.size(iconSize)),
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasBoosted(baseNote)) {
@@ -215,14 +218,14 @@ private fun BoostReaction(
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_retweet),
null,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
tint = grayTint
)
}
@@ -238,7 +241,9 @@ private fun BoostReaction(
@Composable
fun LikeReaction(
baseNote: Note,
accountViewModel: AccountViewModel
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
heartSize: Dp = 16.dp
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note ?: return
@@ -248,7 +253,7 @@ fun LikeReaction(
val scope = rememberCoroutineScope()
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
modifier = Modifier.then(Modifier.size(iconSize)),
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasReactedTo(baseNote)) {
@@ -271,14 +276,14 @@ fun LikeReaction(
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier.size(16.dp),
modifier = Modifier.size(heartSize),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = Modifier.size(16.dp),
modifier = Modifier.size(heartSize),
tint = grayTint
)
}
@@ -296,13 +301,15 @@ fun LikeReaction(
fun ZapReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier
textModifier: Modifier = Modifier,
iconSize: Dp = 20.dp,
animationSize: Dp = 14.dp
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note
val zappedNote = zapsState?.note ?: return
val zapMessage = ""
var wantsToZap by remember { mutableStateOf(false) }
@@ -319,15 +326,14 @@ fun ZapReaction(
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
}
}
}
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.then(Modifier.size(20.dp))
modifier = Modifier.size(iconSize)
.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
@@ -427,7 +433,7 @@ fun ZapReaction(
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
tint = BitcoinOrange
)
} else {
@@ -435,14 +441,14 @@ fun ZapReaction(
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
tint = grayTint
)
} else {
Spacer(Modifier.width(3.dp))
CircularProgressIndicator(
progress = zappingProgress,
modifier = Modifier.size(14.dp),
modifier = Modifier.size(animationSize),
strokeWidth = 2.dp
)
}
@@ -453,7 +459,7 @@ fun ZapReaction(
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
zapAmount = zappedNote?.zappedAmount()
zapAmount = account.calculateZappedAmount(zappedNote)
}
}
@@ -466,18 +472,18 @@ fun ZapReaction(
}
@Composable
private fun ViewCountReaction(idHex: String) {
public fun ViewCountReaction(idHex: String, iconSize: Dp = 20.dp, barChartSize: Dp = 19.dp, numberSize: Dp = 24.dp) {
val uri = LocalUriHandler.current
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton(
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") }
) {
Icon(
imageVector = Icons.Outlined.BarChart,
null,
modifier = Modifier.size(19.dp),
modifier = Modifier.size(barChartSize),
tint = grayTint
)
}
@@ -490,7 +496,7 @@ private fun ViewCountReaction(idHex: String) {
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = stringResource(R.string.view_count),
modifier = Modifier.height(24.dp),
modifier = Modifier.height(numberSize),
colorFilter = ColorFilter.tint(grayTint)
)
}

View File

@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -34,6 +35,7 @@ import kotlinx.coroutines.launch
class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
class NostrVideoFeedViewModel : FeedViewModel(VideoFeedFilter)
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)

View File

@@ -1,17 +1,22 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.saveable.rememberSaveable
import com.vitorpamplona.amethyst.ui.navigation.Route
import kotlin.math.roundToInt
private val savedScrollStates = mutableMapOf<String, ScrollState>()
private data class ScrollState(val index: Int, val scrollOffset: Int)
private data class ScrollState(val index: Int, val scrollOffsetFraction: Float)
object ScrollStateKeys {
const val GLOBAL_SCREEN = "Global"
const val NOTIFICATION_SCREEN = "Notifications"
const val VIDEO_SCREEN = "Video"
val HOME_FOLLOWS = Route.Home.base + "Follows"
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
}
@@ -25,16 +30,42 @@ fun rememberForeverLazyListState(
val scrollState = rememberSaveable(saver = LazyListState.Saver) {
val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat()
LazyListState(
savedIndex,
savedOffset
savedOffset.roundToInt()
)
}
DisposableEffect(Unit) {
onDispose {
val lastIndex = scrollState.firstVisibleItemIndex
val lastOffset = scrollState.firstVisibleItemScrollOffset
savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat())
}
}
return scrollState
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun rememberForeverPagerState(
key: String,
initialFirstVisibleItemIndex: Int = 0,
initialFirstVisibleItemScrollOffset: Float = 0.0f
): PagerState {
val scrollState = rememberSaveable(saver = PagerState.Saver) {
val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
PagerState(
savedIndex,
savedOffset
)
}
DisposableEffect(Unit) {
onDispose {
val lastIndex = scrollState.currentPage
val lastOffset = scrollState.currentPageOffsetFraction
savedScrollStates[key] = ScrollState(lastIndex, lastOffset)
}
}

View File

@@ -54,6 +54,7 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
@@ -348,33 +349,46 @@ fun NoteMaster(
)
) {
Column() {
val eventContent = note.event?.content()
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
TranslatableRichTextViewer(
eventContent,
canPreview,
Modifier.fillMaxWidth(),
note.event?.tags(),
MaterialTheme.colors.background,
if (noteEvent is HighlightEvent) {
DisplayHighlight(
noteEvent.quote(),
noteEvent.author(),
noteEvent.inUrl(),
false,
true,
backgroundColor,
accountViewModel,
navController
)
} else {
val eventContent = note.event?.content()
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (noteEvent is PollNoteEvent) {
PollNote(
note,
if (eventContent != null) {
TranslatableRichTextViewer(
eventContent,
canPreview,
backgroundColor,
Modifier.fillMaxWidth(),
note.event?.tags(),
MaterialTheme.colors.background,
accountViewModel,
navController
)
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
if (noteEvent is PollNoteEvent) {
PollNote(
note,
canPreview,
backgroundColor,
accountViewModel,
navController
)
}
}
}

View File

@@ -59,6 +59,10 @@ class AccountViewModel(private val account: Account) : ViewModel() {
zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType)
}
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
return account.calculateIfNoteWasZappedByAccount(zappedNote)
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
@@ -88,6 +92,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)

View File

@@ -88,34 +88,24 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
fun FloatingButtons(navController: NavHostController, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel) {
val accountState by accountStateViewModel.accountContent.collectAsState()
if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) {
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.
}
is AccountState.LoggedOff -> {
// Does nothing.
}
is AccountState.LoggedIn -> {
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.
}
is AccountState.LoggedOff -> {
// Does nothing.
}
is AccountState.LoggedIn -> {
if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) {
NewNoteButton(state.account, accountViewModel, navController)
}
}
}
}
if (currentRoute(navController) == Route.Message.base) {
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.
}
is AccountState.LoggedOff -> {
// Does nothing.
}
is AccountState.LoggedIn -> {
if (currentRoute(navController) == Route.Message.base) {
NewChannelButton(state.account)
}
if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) {
NewImageButton(accountViewModel, navController)
}
}
}
}

View File

@@ -565,7 +565,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
onSuccess = {
// pay directly
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(it) { response ->
account.sendZapPaymentRequestFor(it, null) { response ->
if (response is PayInvoiceSuccessResponse) {
scope.launch {
Toast.makeText(

View File

@@ -0,0 +1,487 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.Manifest
import android.net.Uri
import android.os.Build
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
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.model.Note
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.ui.actions.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ViewCountReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun VideoScreen(
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
scrollToTop: Boolean = false
) {
val lifeCycleOwner = LocalLifecycleOwner.current
val account = accountViewModel.accountLiveData.value?.account ?: return
VideoFeedFilter.account = account
LaunchedEffect(accountViewModel) {
VideoFeedFilter.account = account
NostrVideoDataSource.resetFilters()
videoFeedView.invalidateData()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Video Start")
VideoFeedFilter.account = account
NostrVideoDataSource.start()
videoFeedView.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Video Stop")
NostrVideoDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(videoFeedView, accountViewModel, navController, ScrollStateKeys.VIDEO_SCREEN, scrollToTop)
}
}
}
@Composable
fun FeedView(
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
) {
val feedState by videoFeedView.feedContent.collectAsState()
Box() {
Column {
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {}
}
is FeedState.Loaded -> {
SlidingCarousel(
state.feed,
accountViewModel,
navController,
scrollStateKey,
scrollToTop
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SlidingCarousel(
feed: MutableState<List<Note>>,
accountViewModel: AccountViewModel,
navController: NavController,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
) {
val pagerState = if (scrollStateKey != null) {
rememberForeverPagerState(scrollStateKey)
} else {
remember { PagerState() }
}
if (scrollToTop) {
LaunchedEffect(Unit) {
pagerState.scrollToPage(page = 0)
}
}
VerticalPager(
pageCount = feed.value.size,
state = pagerState,
beyondBoundsPageCount = 1,
modifier = Modifier.fillMaxSize(1f),
key = { index ->
feed.value.getOrNull(index)?.idHex ?: "$index"
}
) { index ->
feed.value.getOrNull(index)?.let { note ->
RenderVideoOrPictureNote(note, accountViewModel, navController)
}
}
}
@Composable
private fun RenderVideoOrPictureNote(
note: Note,
accountViewModel: AccountViewModel,
navController: NavController
) {
val noteEvent = note.event
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val loggedIn = account.userProfile()
var moreActionsExpanded by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize(1f)) {
Row(Modifier.weight(1f, true), verticalAlignment = Alignment.CenterVertically) {
if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(note)
}
}
}
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) {
Column(Modifier.weight(1f)) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) {
Column(Modifier.size(55.dp), verticalArrangement = Arrangement.Center) {
NoteAuthorPicture(note, navController, loggedIn, 55.dp)
}
Column(
Modifier
.padding(start = 10.dp, end = 10.dp)
.height(60.dp)
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(note, Modifier.weight(1f))
IconButton(
modifier = Modifier.size(24.dp),
onClick = { moreActionsExpanded = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
NoteDropDownMenu(note, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp)) {
RelayBadges(baseNote = note)
}
}
}
}
Column(
Modifier
.width(65.dp)
.padding(bottom = 10.dp),
verticalArrangement = Arrangement.Center
) {
Row(horizontalArrangement = Arrangement.Center) {
ReactionsColumn(note, accountViewModel, navController)
}
}
}
}
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
var expanded by remember { mutableStateOf(false) }
val relaysToDisplay = noteRelays
val uri = LocalUriHandler.current
FlowRow() {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://").removePrefix("ws://")
Box(
Modifier
.size(15.dp)
.padding(1.dp)
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
}
}
}
}
@Composable
fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
}
var wantsToQuote by remember {
mutableStateOf<Note?>(null)
}
if (wantsToReplyTo != null) {
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, navController)
}
if (wantsToQuote != null) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, navController)
}
Spacer(modifier = Modifier.height(8.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) {
/*
ReplyReaction(baseNote, accountViewModel, iconSize = 40.dp) {
wantsToReplyTo = baseNote
}
BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) {
wantsToQuote = baseNote
}*/
LikeReaction(baseNote, accountViewModel, iconSize = 40.dp, heartSize = 35.dp)
ZapReaction(baseNote, accountViewModel, iconSize = 40.dp, animationSize = 35.dp)
ViewCountReaction(baseNote.idHex, iconSize = 40.dp, barChartSize = 39.dp)
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NewImageButton(accountViewModel: AccountViewModel, navController: NavController) {
var wantsToPost by remember {
mutableStateOf(false)
}
var pickedURI by remember {
mutableStateOf<Uri?>(null)
}
val scope = rememberCoroutineScope()
val postViewModel: NewMediaModel = viewModel()
postViewModel.onceUploaded {
scope.launch {
// awaits an refresh on the list
delay(250)
val route = Route.Video.route.replace("{scrollToTop}", "true")
navController.navigate(route) {
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start) { inclusive = false }
restoreState = true
}
launchSingleTop = true
restoreState = true
}
}
}
if (wantsToPost) {
val cameraPermissionState =
rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
GallerySelect(
onImageUri = { uri ->
wantsToPost = false
showGallerySelect = false
pickedURI = uri
}
)
}
showGallerySelect = true
} else {
LaunchedEffect(key1 = accountViewModel) {
cameraPermissionState.launchPermissionRequest()
}
}
}
pickedURI?.let {
NewMediaView(
uri = it,
onClose = { pickedURI = null },
postViewModel = postViewModel,
accountViewModel = accountViewModel,
navController = navController
)
}
if (postViewModel.isUploadingImage) {
ShowProgress(postViewModel)
} else {
OutlinedButton(
onClick = { wantsToPost = true },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}
}
@Composable
private fun ShowProgress(postViewModel: NewMediaModel) {
Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = postViewModel.uploadingPercentage.value,
modifier = Modifier
.size(55.dp)
.background(MaterialTheme.colors.background)
.clip(CircleShape),
strokeWidth = 5.dp
)
postViewModel.uploadingDescription.value?.let {
Text(
it,
color = MaterialTheme.colors.onSurface,
fontSize = 10.sp,
textAlign = TextAlign.Center
)
}
}
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="122.88dp"
android:height="111.34dp"
android:viewportWidth="122.88"
android:viewportHeight="111.34">
<path
android:fillColor="#FF9C9C9C"
android:pathData="M23.59,0h75.7a23.68,23.68 0,0 1,23.59 23.59L122.88,87.75A23.56,23.56 0,0 1,116 104.41l-0.22,0.2a23.53,23.53 0,0 1,-16.44 6.73L23.59,111.34a23.53,23.53 0,0 1,-16.66 -6.93l-0.2,-0.22A23.46,23.46 0,0 1,0 87.75L0,23.59A23.66,23.66 0,0 1,23.59 0ZM54,47.73 L79.25,65.36a3.79,3.79 0,0 1,0.14 6.3L54.22,89.05a3.75,3.75 0,0 1,-2.4 0.87A3.79,3.79 0,0 1,48 86.13L48,50.82h0A3.77,3.77 0,0 1,54 47.73ZM7.35,26.47h14L30.41,7.35L23.59,7.35A16.29,16.29 0,0 0,7.35 23.59v2.88ZM37.05,7.35 L28,26.47L53.36,26.47L62.43,7.38v0ZM69.05,7.35L59.92,26.47h24.7L93.7,7.35ZM100.37,7.35L91.26,26.47h24.27L115.53,23.59a16.32,16.32 0,0 0,-15.2 -16.21ZM115.57,34.03L7.35,34.03L7.35,87.75A16.21,16.21 0,0 0,12 99.05l0.17,0.16A16.19,16.19 0,0 0,23.59 104h75.7a16.21,16.21 0,0 0,11.3 -4.6l0.16,-0.18a16.17,16.17 0,0 0,4.78 -11.46L115.53,34.06Z"/>
</vector>

View File

@@ -315,4 +315,23 @@
<string name="zap_type_nonzap">Nem Zap</string>
<string name="zap_type_nonzap_explainer">Nostr-ban nyoma sincs, csak a Lightning-ben</string>
<string name="file_server">Fájl Szerver</string>
<string name="zap_forward_lnAddress">LnCím vagy @Felhasználó</string>
<string name="upload_server_imgur">imgur.com - kevésbé biztonságos</string>
<string name="upload_server_imgur_explainer">Feltöltés az Imgur-ba. Az Imgur a képet/videót bármikor megváltoztathatja</string>
<string name="upload_server_nostrimg">nostrimg.com - kevésbé biztonságos</string>
<string name="upload_server_nostrimg_explainer">Feltöltés a NostrImg-be. A NostrImg a képet bármikor megváltoztathatja</string>
<string name="upload_server_imgur_nip94">Ellenőrizhető Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Megvéd attól, hogy az Imgur a képet/videót a közzététel után megváltoztassa</string>
<string name="upload_server_nostrimg_nip94">Ellenőrizhető NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Megvéd attól, hogy az NostrImg a képet a közzététel után megváltoztassa</string>
<string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel</string>
</resources>

View File

@@ -0,0 +1,286 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="point_to_the_qr_code">QR குறியீட்டைச் சுட்டிக்காட்டவும்</string>
<string name="show_qr">QR ஐக் காட்டவும்</string>
<string name="profile_image">சுயவிவரப் படம்</string>
<string name="scan_qr">QR ஐ ஸ்கேன் செய்யவும்</string>
<string name="show_anyway">பரவாயில்லை, காட்டவும்</string>
<string name="post_was_flagged_as_inappropriate_by">குறிப்பு தகாதது என குறிக்கப்பட்டது. குறித்தவர்</string>
<string name="post_not_found">குறிப்பு கிடைக்கவில்லை</string>
<string name="channel_image">Channel Image</string>
<string name="referenced_event_not_found">குறிப்பிடப்பட்ட நிகழ்வு கிடைக்கவில்லை</string>
<string name="could_not_decrypt_the_message">செய்தியை மறைகுறியாக்க முடியவில்லை</string>
<string name="group_picture">குழுப் படம்</string>
<string name="explicit_content">வெளிப்படையான உள்ளடக்கம்</string>
<string name="spam">ஸ்பேம்</string>
<string name="impersonation">ஆள்மாறாட்டம்</string>
<string name="illegal_behavior">சட்டவிரோத நடத்தை</string>
<string name="unknown">தெரியாத</string>
<string name="relay_icon">ரிலே ஐகான்</string>
<string name="unknown_author">எழுதியவர் தெரியவில்லை</string>
<string name="copy_text">உரையை நகலெடுக்கவும்</string>
<string name="copy_user_pubkey">எழுதியவர் IDஐ நகலெடுக்கவும்</string>
<string name="copy_note_id">குறிப்பின் IDஐ நகலெடுக்கவும்</string>
<string name="broadcast">ஒளிபரப்பு</string>
<string name="request_deletion">நீக்குவதற்கு கோரிக்கை செய்</string>
<string name="block_hide_user">பயனரை முடக்கு + மறை</string>
<string name="report_spam_scam">ஸ்பேம் / மோசடி என்று புகாரளிக்கவும்</string>
<string name="report_impersonation">ஆள்மாறாட்டத்தைப் புகாரளிக்கவும்</string>
<string name="report_explicit_content">வெளிப்படையான உள்ளடக்கத்தைப் புகாரளிக்கவும்</string>
<string name="report_illegal_behaviour">சட்டவிரோத நடத்தையைப் புகாரளிக்கவும்</string>
<string name="login_with_a_private_key_to_be_able_to_reply">பதிலளிக்க ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">குறிப்புகளை உயர்த்த ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="login_with_a_private_key_to_like_posts">குறிப்புகளை விரும்புவதற்கு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="no_zap_amount_setup_long_press_to_change">ஜாப் தொகை அமைப்பு இல்லை. மாற்ற நீண்ட நேரம் அழுத்தவும்</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">ஜாப்களை அனுப்ப ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக</string>
<string name="zaps">ஜாப்கள்</string>
<string name="view_count">பார்வை எண்ணிக்கை</string>
<string name="boost">உயர்த்து</string>
<string name="boosted">உயர்த்தப்பட்டது</string>
<string name="quote">மேற்கோள் காட்டு</string>
<string name="new_amount_in_sats">புதிய தொகை (ஸாட்களில்)</string>
<string name="add">சேர்</string>
<string name="replying_to">"பதில் பெறுநர் "</string>
<string name="and">மற்றும்</string>
<string name="in_channel">"அலை வரிசையில் "</string>
<string name="profile_banner">சுயவிவர பேனர்</string>
<string name="following">" பின்பற்றப் படுவோர்"</string>
<string name="followers">" பின்பற்றுவோர்"</string>
<string name="profile">சுயவிவரம்</string>
<string name="security_filters">பாதுகாப்பு வடிப்பான்கள்</string>
<string name="log_out">வெளியேறு</string>
<string name="show_more">மேலும் காட்டு</string>
<string name="lightning_invoice">லைட்நிங் விலைப்பட்டியல்</string>
<string name="pay">செலுத்து</string>
<string name="lightning_tips">லைட்நிங் இனாம்</string>
<string name="note_to_receiver">பெறுபவருக்கான குறிப்பு</string>
<string name="thank_you_so_much">மிக்க நன்றி!</string>
<string name="amount_in_sats">தொகை (ஸாட்களில்)</string>
<string name="send_sats">ஸாட்கள் அனுப்பவும்</string>
<string name="error_parsing_preview_for">"%1$s க்கான முன்னோட்டத்தை புரிந்து கொள்வதில் பிழை: %2$s"</string>
<string name="preview_card_image_for">" %1$s க்கான முன்னோட்ட அட்டைப் படம்"</string>
<string name="new_channel">புதிய அலைவரிசை</string>
<string name="channel_name">அலைவரிசைப் பெயர்</string>
<string name="my_awesome_group">எனது அருமையான குழு</string>
<string name="picture_url">பட Url</string>
<string name="description">தகவல்</string>
<string name="about_us">"எங்களைப் பற்றி.. "</string>
<string name="what_s_on_your_mind">உங்கள் மனதில் என்ன உள்ளது?</string>
<string name="post">குறிப்பு</string>
<string name="save">சேமி</string>
<string name="create">உருவாக்கு</string>
<string name="cancel">ரத்து</string>
<string name="failed_to_upload_the_image">படத்தை பதிவேற்றுதல் தோல்வி</string>
<string name="relay_address">ரிலே முகவரி</string>
<string name="posts">குறிப்புகள்</string>
<string name="bytes">பைட்டுகள்</string>
<string name="errors">பிழைகள்</string>
<string name="home_feed">முகப்பு ஊட்டம்</string>
<string name="private_message_feed">தனிப்பட்ட செய்தி ஊட்டம்</string>
<string name="public_chat_feed">பொது செய்தி ஊட்டம்</string>
<string name="global_feed">முழுதளாவிய ஊட்டம்</string>
<string name="search_feed">தேடுதல் ஊட்டம்</string>
<string name="add_a_relay">ஒரு ரிலேவை சேர்</string>
<string name="display_name">காட்சிப் பெயர்</string>
<string name="my_display_name">எனது காட்சிப் பெயர்</string>
<string name="username">பயனர் பெயர்</string>
<string name="my_username">எனது பயனர் பெயர்</string>
<string name="about_me">என்னைப் பற்றி</string>
<string name="avatar_url">சுயவிவரப் படம் URL</string>
<string name="banner_url">பேனர் URL</string>
<string name="website_url">வலைத்தள URL</string>
<string name="ln_address">LN முகவரி </string>
<string name="ln_url_outdated">LN URL (காலாவதியானது)</string>
<string name="image_saved_to_the_gallery">படம் கேலரியில் சேமிக்கப் பட்டது</string>
<string name="failed_to_save_the_image">படத்தை சேமிப்பது தோல்வியடைந்தது</string>
<string name="upload_image">படத்தை பதிவேற்று</string>
<string name="uploading">பதிவேற்றம்...</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>
<string name="reply_here">"இங்கு பதிலளிக்கவும்.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும்</string>
<string name="copy_channel_id_note_to_the_clipboard">அலைவரிசை ID ஐ கிளிப்போர்டில் பிரதி எடு</string>
<string name="edits_the_channel_metadata">அலைவரிசை பற்றிய தகவல்களை மாற்றும்</string>
<string name="join">சேர்</string>
<string name="known">தெரிந்த</string>
<string name="new_requests">புதிய கோரிக்கைகள்</string>
<string name="blocked_users">முடக்கப்பட்ட பயனர்கள்</string>
<string name="new_threads">புதிய நூல்கள்</string>
<string name="conversations">உரையாடல்கள்</string>
<string name="notes">குறிப்புகள்</string>
<string name="replies">பதில்கள்</string>
<string name="follows">"பின்தொடர படுவோர்"</string>
<string name="reports">"புகார்கள்"</string>
<string name="more_options">மேலும் </string>
<string name="relays">" ரிலேகள்"</string>
<string name="website">இணையதளம்</string>
<string name="lightning_address">லைட்னிங் முகவரி</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Nsec IDயை (உங்கள் கடவுச்சொல்) காப்புப்பிரதிக்காக கிளிப்போர்டுக்கு நகலெடுக்கிறது</string>
<string name="copy_private_key_to_the_clipboard">கிளிப்போர்டுக்கு ரகசிய சாவியை நகலெடுக்கவும்</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">பகிர்வுக்காக கிளிப்போர்டுக்கு பொதுசாவியை நகலெடுக்கிறது</string>
<string name="copy_public_key_npub_to_the_clipboard">கிளிப்போர்டுக்கு பொதுசாவியை (NPUB) நகலெடுக்கவும்</string>
<string name="send_a_direct_message">நேரடி செய்தியை அனுப்பவும்</string>
<string name="edits_the_user_s_metadata">பயனரின்தகவலை திருத்துகிறது</string>
<string name="follow">பின்தொடர்</string>
<string name="follow_back">பதிலுக்கு பின்தொடரவும்</string>
<string name="unblock">தடையை நீக்கு</string>
<string name="copy_user_id">பயனர் IDயை நகலெடுக்கவும்</string>
<string name="unblock_user">பயனரைத் தடைசெய்க</string>
<string name="npub_hex_username">"npub, hex, பயனர்பெயர் "</string>
<string name="clear">அழி</string>
<string name="app_logo">பயன்பாட்டு லோகோ</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex தனிப்பட்ட சாவி</string>
<string name="show_password">கடவுச்சொல்லை காட்டவும்</string>
<string name="hide_password">கடவுச்சொல்லை மறைக்கவும்</string>
<string name="invalid_key">தவறான சாவி</string>
<string name="i_accept_the">"பின்வருவதை நான் ஏற்றுக்கொள்கிறேன்: "</string>
<string name="terms_of_use">பயன்பாட்டு விதிமுறைகளை</string>
<string name="acceptance_of_terms_is_required">விதிமுறைகளை ஏற்றுக்கொள்வது தேவை</string>
<string name="key_is_required">சாவி தேவை</string>
<string name="login">உள்நுழை</string>
<string name="generate_a_new_key">புதிய சாவியை உருவாக்குங்கள்</string>
<string name="loading_feed">ஊட்டம் ஏற்றப்படுகிறது</string>
<string name="error_loading_replies">"பதில்களை ஏற்றுவதில் பிழை: "</string>
<string name="try_again">மீண்டும் முயற்சி செய்</string>
<string name="feed_is_empty">ஊட்டம் காலியாக உள்ளது.</string>
<string name="refresh">புதுப்பி</string>
<string name="created">உருவாக்கப்பட்டது</string>
<string name="with_description_of">விளக்கம்:</string>
<string name="and_picture">மற்றும் படம்</string>
<string name="changed_chat_name_to">மாற்றப்பட்ட அரட்டை பெயர்</string>
<string name="description_to">விளக்கம்</string>
<string name="and_picture_to">மற்றும் படம்</string>
<string name="leave">வெளியேறு</string>
<string name="unfollow">பின்தொடர்வதை நிறுத்து</string>
<string name="channel_created">அலைவரிசை உருவாக்கப் பட்டது</string>
<string name="channel_information_changed_to">"அலைவரிசை தகவல் மாற்றப்பட்டது"</string>
<string name="public_chat">பொது அரட்டை</string>
<string name="posts_received">குறிப்புகள் பெறப்பட்டன</string>
<string name="remove">அகற்று</string>
<string name="translations_auto">தானியங்கி</string>
<string name="translations_translated_from">மொழிபெயர்க்கப்பட்டுள்ளது மூலம்:</string>
<string name="translations_to"></string>
<string name="translations_show_in_lang_first">முதலில் %1$s இல் காண்பி</string>
<string name="translations_always_translate_to_lang">எப்போதும் %1$s க்கு மொழிபெயர்க்கவும்</string>
<string name="translations_never_translate_from_lang">%1$s இலிருந்து ஒருபோதும் மொழிபெயர்க்க வேண்டாம்</string>
<string name="never">ஒருபோதும் இல்லை</string>
<string name="now">இப்போது</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">நிர்வாணம்</string>
<string name="profanity_hateful_speech">அவதூறு / வெறுக்கத்தக்க பேச்சு</string>
<string name="report_hateful_speech">வெறுக்கத்தக்க பேச்சைப் புகாரளிக்கவும்</string>
<string name="report_nudity_porn">நிர்வாணம் / ஆபாசத்தைப் புகாரளிக்கவும்</string>
<string name="others">மற்றவைகள்</string>
<string name="mark_all_known_as_read">அனைத்து தெரிந்தவைகளையும் படித்ததாக குறிக்கவும்</string>
<string name="mark_all_new_as_read">அனைத்து புதியதையும் படித்ததாக குறிக்கவும்</string>
<string name="mark_all_as_read">அனைத்தையும் படித்ததாக குறிக்கவும்</string>
<string name="backup_keys">காப்பு சாவிகள்</string>
<string name="account_backup_tips_md">
## முக்கிய காப்புப்பிரதி மற்றும் பாதுகாப்பு உதவிக்குறிப்புகள்
\n\n உங்கள் கணக்கு ஒரு ரகசியசாவியால் பாதுகாக்கப்படுகிறது. சாவி **nsec1** என்று தொடங்கும் நீண்ட சீரற்ற எழுத்து வடிவம். உங்கள் ரகசிய சாவியை அணுகக்கூடிய எவரும் உங்கள் அடையாளத்தைப் பயன்படுத்தி உள்ளடக்கத்தை வெளியிடலாம்.
\n\n- நீங்கள் நம்பாத எந்த வலைத்தளம் அல்லது மென்பொருளிலும் உங்கள் ரகசிய விசையை வைக்க **வேண்டாம்**.
\ n- அமேதிஸ்ட் டெவலப்பர்கள் உங்கள் ரகசியசாவியை உங்களிடம் எப்போதும் கேட்க மாட்டார்கள்.
\ n- கணக்கு மீட்டெடுப்பதற்கு உங்கள் ரகசியசாவியின் பாதுகாப்பான காப்புப்பிரதியை **தவறாமல்** வைத்திருங்கள். கடவுச்சொல் நிர்வாகியைப் பயன்படுத்த பரிந்துரைக்கிறோம்.
</string>
<string name="secret_key_copied_to_clipboard">ரகசிய சாவி (nsec) கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது</string>
<string name="copy_my_secret_key">எனது ரகசிய சாவியை நகலெடுக்கவும்</string>
<string name="biometric_authentication_failed">அங்கீகரிப்பு தோல்வியுற்றது</string>
<string name="biometric_error">பிழை</string>
<string name="badge_created_by">" %1$s ஆல் உருவாக்கப்பட்டது"</string>
<string name="badge_award_image_for">" %1$s க்கான பேட்ஜ் விருது படம்"</string>
<string name="new_badge_award_notif">நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள்</string>
<string name="award_granted_to">பேட்ஜ் விருது வழங்கப்பட்டது. பெறுநர்:</string>
<string name="copied_note_text_to_clipboard">குறிப்பு கிளிப்போர்டுக்கு நகலெடுக்கப் பட்டது</string>
<string name="copied_user_id_to_clipboard">கிளிப்போர்டுக்கு எழுத்தாளரின் @npub நகலெடுக்கப் பட்டது</string>
<string name="copied_note_id_to_clipboard">கிளிப்போர்டுக்கு குறிப்பு ஐடி (@note1) நகலெடுக்கப் பட்டது</string>
<string name="select_text_dialog_top">உரையைத் தேர்ந்தெடுக்கவும்</string>
<string name="private_conversation_notification">"தனிப்பட்ட செய்தியை மறைக்குறியாக்க இயலவில்லை \n \n %1$s மற்றும் %2$s க்கு இடையே தனிப்பட்ட/ குறியாக்கப்பட்ட உரையாடலில் நீங்கள் மேற்கோள் காட்டப்பட்டீர்கள்."</string>
<string name="account_switch_add_account_dialog_title">புதிய கணக்கைச் சேர்க்கவும்</string>
<string name="drawer_accounts">கணக்குகள்</string>
<string name="account_switch_select_account">கணக்கைத் தேர்ந்தெடுக்கவும்</string>
<string name="account_switch_add_account_btn">புதிய கணக்கைச் சேர்க்கவும்</string>
<string name="account_switch_active_account">செயலில் உள்ள கணக்கு</string>
<string name="account_switch_has_private_key">தனிப்பட்ட சாவி உள்ளது</string>
<string name="account_switch_pubkey_only">படிக்க மட்டும், தனிப்பட்ட சாவி இல்லை</string>
<string name="back">பின்</string>
<string name="quick_action_select">தேர்ந்தெடுக்கவும்</string>
<string name="quick_action_share_browser_link">உலாவி இணைப்பைப் பகிரவும்</string>
<string name="quick_action_share">பகிர்</string>
<string name="quick_action_copy_user_id">ஆசிரியர் ஐடி</string>
<string name="quick_action_copy_note_id">குறிப்பு ஐடி</string>
<string name="quick_action_copy_text">உரையை நகலெடுக்கவும்</string>
<string name="quick_action_delete">அழி</string>
<string name="quick_action_unfollow">பின்தொடர்வதை நிறுத்து</string>
<string name="quick_action_follow">பின்தொடர்</string>
<string name="quick_action_request_deletion_alert_title">நீக்க கோரிக்கை செய்</string>
<string name="quick_action_request_deletion_alert_body">நீங்கள் தற்போது இணைக்கப்பட்டுள்ள ரிலேக்களிலிருந்து உங்கள் குறிப்பை நீக்குமாறு அமேதிஸ்ட் கோரும். உங்கள் குறிப்பு அந்த ரிலேக்களிலிருந்து அல்லது அது சேமிக்கப்பட்டுள்ள பிற ரிலேக்களிலிருந்து நிரந்தரமாக நீக்கப்படும் என்பதற்கு எந்த உத்தரவாதமும் இல்லை.</string>
<string name="quick_action_block_dialog_btn">தடைசெய்</string>
<string name="quick_action_delete_dialog_btn">அழி</string>
<string name="quick_action_block">தடைசெய்</string>
<string name="quick_action_report">புகார் </string>
<string name="quick_action_delete_button">அழி</string>
<string name="quick_action_dont_show_again_button">மீண்டும் காட்ட வேண்டாம்</string>
<string name="report_dialog_spam">ஸ்பேம் அல்லது மோசடிகள்</string>
<string name="report_dialog_profanity">அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை</string>
<string name="report_dialog_impersonation">தீங்கிழைக்கும் ஆள்மாறாட்டம்</string>
<string name="report_dialog_nudity">நிர்வாணம் அல்லது கிராஃபிக் உள்ளடக்கம்</string>
<string name="report_dialog_illegal">சட்டவிரோத நடத்தை</string>
<string name="report_dialog_blocking_a_user">ஒரு பயனரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட பயனர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.</string>
<string name="report_dialog_block_hide_user_btn">பயனரைத் தடுத்து மறைக்கவும்</string>
<string name="report_dialog_report_btn">தகாதது என பதிவுசெய்</string>
<string name="report_dialog_reminder_public">இடுகையிடப்பட்ட அனைத்து அறிக்கைகளும் பகிரங்கமாக தெரியும்.</string>
<string name="report_dialog_additional_reason_placeholder">உங்கள் அறிக்கையைப் பற்றிய கூடுதல் சூழலை விருப்பமாக வழங்கவும்…</string>
<string name="report_dialog_additional_reason_label">கூடுதல் சூழல்</string>
<string name="report_dialog_select_reason_label">காரணம்</string>
<string name="report_dialog_select_reason_placeholder">ஒரு காரணத்தைத் தேர்ந்தெடுக்கவும்…</string>
<string name="report_dialog_post_report_btn">புகார் அளிக்கவும்</string>
<string name="report_dialog_title">தடு மற்றும் புகாரளி</string>
<string name="block_only">தடு</string>
<string name="bookmarks">புக்மார்க்குகள்</string>
<string name="private_bookmarks">தனிப்பட்ட புக்மார்க்குகள்</string>
<string name="public_bookmarks">பொது புக்மார்க்குகள்</string>
<string name="add_to_private_bookmarks">தனிப்பட்ட புக்மார்க்குகளில் சேர்க்கவும்</string>
<string name="add_to_public_bookmarks">பொது புக்மார்க்குகளில் சேர்க்கவும்</string>
<string name="remove_from_private_bookmarks">தனிப்பட்ட புக்மார்க்குகளிலிருந்து அகற்று</string>
<string name="remove_from_public_bookmarks">பொது புக்மார்க்குகளிலிருந்து அகற்று</string>
<string name="wallet_connect_service">பணப்பை இணைப்பு சேவை</string>
<string name="wallet_connect_service_explainer">பயன்பாட்டை விட்டு வெளியேறாமல் ஜாப்களை செலுத்த ஒரு நாஸ்டர் ரகசியத்தை அங்கீகரிக்கிறது. ரகசியத்தை பாதுகாப்பாக வைத்து, முடிந்தால் தனியார் ரிலேவைப் பயன்படுத்துங்கள்</string>
<string name="wallet_connect_service_pubkey">பணப்பை இணைப்பு பொது சாவி</string>
<string name="wallet_connect_service_relay">பணப்பை இணைப்பு ரிலே</string>
<string name="wallet_connect_service_secret">பணப்பை இணைப்பு ரகசியம்</string>
<string name="wallet_connect_service_show_secret">ரகசிய சாவியைக் காட்டு</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex தனிப்பட்ட சாவி</string>
<string name="pledge_amount_in_sats">உறுதிமொழி தொகை (ஸாட்களில்)</string>
<string name="post_poll"></string>
<string name="poll_heading_required">தேவையான புலங்கள்:</string>
<string name="poll_zap_recipients">ஜாப் பெறுநர்கள்</string>
<string name="poll_primary_description">முதன்மை வாக்கெடுப்பு விளக்கம்…</string>
<string name="poll_option_index">விருப்பம் %s</string>
<string name="poll_option_description">வாக்கெடுப்பு விருப்ப விளக்கம்</string>
<string name="poll_heading_optional">விருப்ப புலங்கள்:</string>
<string name="poll_zap_value_min">குறைந்தபட்ச ஜாப்</string>
<string name="poll_zap_value_max">அதிகபட்ச ஜாப்</string>
<string name="poll_consensus_threshold">ஒருமித்த கருத்து</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">நிறைவு செய்வது</string>
<string name="poll_closing_time_days">நாட்களில்</string>
<string name="poll_is_closed">புதிய வாக்குகளுக்கு வாக்கெடுப்பு மூடப்பட்டுள்ளது</string>
<string name="poll_zap_amount">ஜாப் தொகை</string>
<string name="one_vote_per_user_on_atomic_votes">இந்த வகை வாக்கெடுப்பில் ஒரு பயனருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது</string>
<string name="looking_for_event">"நிகழ்வு %1$s ஐத் தேடி"</string>
<string name="custom_zaps_add_a_message">ஒரு பொது செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_private">ஒரு தனிப்பட்ட செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_nonzap">விலைப்பட்டியல் செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_example">உங்கள் எல்லா வேலைகளுக்கும் நன்றி!</string>
<string name="lightning_create_and_add_invoice">உருவாக்கி சேர்க்கவும்</string>
<string name="poll_author_no_vote">வாக்கெடுப்பு ஆசிரியர்கள் தங்கள் சொந்த வாக்கெடுப்பில் வாக்களிக்க முடியாது.</string>
<string name="hash_verification_passed">குறிப்பு பதிவு செய்யப்பட்ட பின்னர் படம் மாற்றப்படவில்லை</string>
<string name="hash_verification_failed">படம் மாறிவிட்டது. மாற்றத்தை ஆசிரியர் பார்த்திருக்க மாட்டார்</string>
<string name="content_description_add_image">படத்தைச் சேர்க்கவும்</string>
<string name="content_description_add_video">வீடியோ சேர்க்கவும்</string>
<string name="content_description_add_document">ஆவணத்தைச் சேர்க்கவும்</string>
<string name="add_content">உருவாக்கி சேர்க்கவும்</string>
<string name="content_description">உள்ளடக்கங்களின் விளக்கம்</string>
<string name="content_description_example">சூரியன் மறையும் வேளையில் வெள்ளை மண் நிறைந்த கடற்கரையில் ஒரு நீலப் படகு</string>
</resources>

View File

@@ -334,10 +334,10 @@
<string name="upload_server_nostrimg_explainer">Uploads to NostrImg. NostrImg can change your image at any time</string>
<string name="upload_server_imgur_nip94">Verifiable Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Protects from Imgur changing your image/video after you post</string>
<string name="upload_server_imgur_nip94_explainer">Protects from Imgur modifying the content afterwards. This is a new NIP: other clients might not see the image</string>
<string name="upload_server_nostrimg_nip94">Verifiable NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Protects from NostrImg changing your image after you post</string>
<string name="upload_server_nostrimg_nip94_explainer">Protects from NostrImg modifying the content afterwards. This is a new NIP: other clients might not see the image</string>
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Files are uploaded to and hosted by relays. They are free from a fixed url (third-party dependency). Make sure to have a NIP-95 relay in your relay list</string>