mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 15:44:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22ba150c4e | ||
|
|
63cc00976f | ||
|
|
cc9d4c237a | ||
|
|
a38bbd7168 | ||
|
|
635ed1afc1 | ||
|
|
130ad40a3a | ||
|
|
be5e58111e | ||
|
|
8b012884b1 | ||
|
|
4949a5c459 | ||
|
|
ab330fea96 | ||
|
|
8209c45303 | ||
|
|
206935289e | ||
|
|
571ff0e72b | ||
|
|
940914adf5 | ||
|
|
1496f012a5 | ||
|
|
2a552bea4d | ||
|
|
d1dc06a467 | ||
|
|
f173f3194b | ||
|
|
8ca63f32f4 | ||
|
|
6b3e10ed28 | ||
|
|
1ae785061e | ||
|
|
ddba4ac510 | ||
|
|
0fe6bd6c83 | ||
|
|
925289ae64 | ||
|
|
f6f82a16f5 | ||
|
|
54511dcc69 | ||
|
|
2f457d135f | ||
|
|
362082d608 | ||
|
|
78cfa98456 | ||
|
|
5e37a97d1e | ||
|
|
f105188738 |
+2
-2
@@ -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.
|
||||
|
||||
|
||||
+5
-5
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 128
|
||||
versionName "0.37.1"
|
||||
versionCode 135
|
||||
versionName "0.40.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -86,7 +86,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.10.0'
|
||||
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
|
||||
implementation 'androidx.activity:activity-compose:1.7.0'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
@@ -134,7 +134,7 @@ dependencies {
|
||||
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
|
||||
@@ -177,7 +177,7 @@ dependencies {
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'io.mockk:mockk:1.13.5'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.util.Log
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
@@ -395,10 +396,10 @@ class Account(
|
||||
)
|
||||
|
||||
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]
|
||||
}
|
||||
@@ -417,7 +418,7 @@ class Account(
|
||||
)
|
||||
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
}
|
||||
@@ -779,7 +780,11 @@ class Account(
|
||||
}
|
||||
|
||||
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
|
||||
LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
|
||||
val result = LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
|
||||
if (result == null) {
|
||||
Log.w("Private ZAP Decrypt", "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}")
|
||||
}
|
||||
result
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -653,40 +653,72 @@ 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)
|
||||
|
||||
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())
|
||||
|
||||
refreshObservers(note)
|
||||
|
||||
@@ -19,9 +19,14 @@ 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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
+1
-1
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageEvent
|
||||
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, FileStorageEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
val videoFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -227,6 +229,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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ class Relay(
|
||||
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
|
||||
if (filters.isNotEmpty()) {
|
||||
val request =
|
||||
"""["REQ","$requestId",${filters.take(12).joinToString(",") { it.filter.toJson(url) }}]"""
|
||||
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
|
||||
// println("FILTERSSENT $url $request")
|
||||
socket?.send(request)
|
||||
eventUploadCounterInBytes += request.bytesUsedInMemory()
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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, onClose: () -> Unit) {
|
||||
isUploadingImage = true
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val uri = galleryUri ?: return
|
||||
val serverToUse = selectedServer ?: return
|
||||
|
||||
if (selectedServer == ServersAvailable.NIP95) {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
contentResolver.openInputStream(uri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, description, onClose)
|
||||
}
|
||||
?: viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
} else {
|
||||
ImageUploader.uploadImage(
|
||||
uri = uri,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(imageUrl, mimeType, description, onClose)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
galleryUri = null
|
||||
isUploadingImage = false
|
||||
mediaType = null
|
||||
|
||||
description = ""
|
||||
selectedServer = account?.defaultFileServer
|
||||
}
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, onClose: () -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(5000)
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
val note = account?.sendHeader(it)
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
onClose()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, onClose: () -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
account?.sendNip95(bytes, headerInfo = it)
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
onClose()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isImage() = mediaType?.startsWith("image")
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
fun defaultServer() = account?.defaultFileServer
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
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.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val postViewModel: NewMediaModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
|
||||
val scroolState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
delay(100)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
if (postViewModel.isUploadingImage) {
|
||||
LoadingAnimation()
|
||||
}
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.upload(context) {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scroolState)
|
||||
) {
|
||||
ImageVideoPost(postViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel) {
|
||||
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 {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.filter { it.first == postViewModel.defaultServer() }.firstOrNull()?.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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,7 +307,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().height(50.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
@@ -406,31 +411,43 @@ private fun ForwardZapTo(
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
Box(Modifier.height(20.dp).width(25.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp)
|
||||
) {
|
||||
if (!postViewModel.wantsForwardZapTo) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier.size(20.dp).align(Alignment.CenterStart),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowForwardIos,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier.size(13.dp).align(Alignment.CenterEnd),
|
||||
modifier = Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier.size(20.dp).align(Alignment.CenterStart),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowForwardIos,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier.size(13.dp).align(Alignment.CenterEnd),
|
||||
modifier = Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
}
|
||||
@@ -443,11 +460,15 @@ private fun ForwardZapTo(
|
||||
onValueChange = {
|
||||
postViewModel.updateZapForwardTo(it)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).padding(0.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.padding(0.dp),
|
||||
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
|
||||
@@ -587,11 +608,11 @@ fun ImageVideoDescription(
|
||||
val isVideo = mediaType.startsWith("video")
|
||||
|
||||
val fileServers = listOf(
|
||||
Triple(ServersAvailable.IMGUR, "imgur.com", "Uploads to ImgUR. ImgUR can change your image at any time"),
|
||||
Triple(ServersAvailable.NOSTRIMG, "nostrimg.com", "Regular NostrImg. NostrImg can change your image at any time"),
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, "Verifiable ImgUr (NIP-94)", "Protects from ImgUr changing your image after you post"),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, "Verifiable NostrIMG (NIP-94)", "Protects from NostrIMG changing your image after you post"),
|
||||
Triple(ServersAvailable.NIP95, "Your relays (NIP-95)", "The image is hosted in the relay itself. Your image will be free from a domain name / third-party control")
|
||||
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
|
||||
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 }
|
||||
@@ -638,7 +659,8 @@ fun ImageVideoDescription(
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1.0f).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.weight(1.0f)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
)
|
||||
|
||||
IconButton(
|
||||
@@ -662,7 +684,8 @@ fun ImageVideoDescription(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.padding(bottom = 10.dp)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
if (mediaType.startsWith("image")) {
|
||||
AsyncImage(
|
||||
@@ -671,7 +694,8 @@ fun ImageVideoDescription(
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
)
|
||||
} else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
@@ -709,7 +733,8 @@ fun ImageVideoDescription(
|
||||
onSelect = {
|
||||
selectedServer = fileServers[it].first
|
||||
},
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
@@ -720,12 +745,14 @@ fun ImageVideoDescription(
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
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()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
|
||||
@@ -11,9 +11,11 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
fun ClickableUrl(urlText: String, url: String) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(urlText),
|
||||
onClick = { runCatching { uri.openUri(url) } },
|
||||
onClick = { runCatching { uri.openUri(doubleCheckedUrl) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\
|
||||
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
|
||||
|
||||
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
|
||||
val hashTagsPattern: Pattern = Pattern.compile("#([a-z0-9_\\-]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
val urlPattern: Pattern = Patterns.WEB_URL
|
||||
|
||||
fun isValidURL(url: String?): Boolean {
|
||||
@@ -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("```")
|
||||
) {
|
||||
|
||||
@@ -80,25 +80,29 @@ abstract class ZoomableContent(
|
||||
abstract class ZoomableUrlContent(
|
||||
val url: String,
|
||||
description: String? = null,
|
||||
val hash: String? = null
|
||||
val hash: String? = null,
|
||||
val uri: String? = null
|
||||
) : ZoomableContent(description)
|
||||
|
||||
class ZoomableUrlImage(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
val bluehash: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash)
|
||||
val bluehash: String? = null,
|
||||
uri: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, uri)
|
||||
|
||||
class ZoomableUrlVideo(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash)
|
||||
hash: String? = null,
|
||||
uri: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, uri)
|
||||
|
||||
abstract class ZoomablePreloadedContent(
|
||||
description: String? = null,
|
||||
val isVerified: Boolean? = null
|
||||
val isVerified: Boolean? = null,
|
||||
val uri: String
|
||||
) : ZoomableContent(description)
|
||||
|
||||
class ZoomableBitmapImage(
|
||||
@@ -106,15 +110,17 @@ class ZoomableBitmapImage(
|
||||
val mimeType: String? = null,
|
||||
description: String? = null,
|
||||
val bluehash: String? = null,
|
||||
isVerified: Boolean? = null
|
||||
) : ZoomablePreloadedContent(description, isVerified)
|
||||
isVerified: Boolean? = null,
|
||||
uri: String
|
||||
) : ZoomablePreloadedContent(description, isVerified, uri)
|
||||
|
||||
class ZoomableBytesVideo(
|
||||
val byteArray: ByteArray,
|
||||
val mimeType: String? = null,
|
||||
description: String? = null,
|
||||
isVerified: Boolean? = null
|
||||
) : ZoomablePreloadedContent(description, isVerified)
|
||||
isVerified: Boolean? = null,
|
||||
uri: String
|
||||
) : ZoomablePreloadedContent(description, isVerified, uri)
|
||||
|
||||
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
@@ -189,7 +195,17 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
if (content is ZoomableUrlContent) {
|
||||
mainImageModifier = mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
|
||||
)
|
||||
} else if (content is ZoomableBitmapImage) {
|
||||
mainImageModifier = mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
|
||||
)
|
||||
} else if (content is ZoomableBytesVideo) {
|
||||
mainImageModifier = mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
|
||||
)
|
||||
} else {
|
||||
mainImageModifier = mainImageModifier.clickable {
|
||||
@@ -390,7 +406,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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import kotlin.time.ExperimentalTime
|
||||
val bottomNavigationItems = listOf(
|
||||
Route.Home,
|
||||
Route.Message,
|
||||
Route.Video,
|
||||
Route.Search,
|
||||
Route.Notification
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.theme.Following
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.net.URL
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@@ -517,6 +518,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 +595,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,
|
||||
@@ -795,10 +876,11 @@ fun FileHeaderDisplay(note: Note) {
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val uri = "nostr:" + note.toNEvent()
|
||||
content = if (isImage) {
|
||||
ZoomableUrlImage(fullUrl, description, hash, blurHash)
|
||||
ZoomableUrlImage(fullUrl, description, hash, blurHash, uri)
|
||||
} else {
|
||||
ZoomableUrlVideo(fullUrl, description, hash)
|
||||
ZoomableUrlVideo(fullUrl, description, hash, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -823,16 +905,17 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
|
||||
|
||||
LaunchedEffect(key1 = eventHeader.id, key2 = noteState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val uri = "nostr:" + baseNote.toNEvent()
|
||||
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)
|
||||
ZoomableBitmapImage(bytes, mimeType, description, blurHash, true, uri)
|
||||
} else {
|
||||
if (bytes != null) {
|
||||
ZoomableBytesVideo(bytes, mimeType, description, true)
|
||||
ZoomableBytesVideo(bytes, mimeType, description, true, uri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -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,7 +301,9 @@ 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
|
||||
@@ -326,8 +333,7 @@ fun ZapReaction(
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,7 +496,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
||||
|
||||
userBadge.acceptedBadges?.let { note ->
|
||||
(note.event as? BadgeProfilesEvent)?.let { event ->
|
||||
FlowRow(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
event.badgeAwardEvents().forEach { badgeAwardEvent ->
|
||||
val baseNote = LocalCache.notes[badgeAwardEvent]
|
||||
if (baseNote != null) {
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
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.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.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.navigation.NavController
|
||||
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.NewMediaView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
|
||||
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
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeedView(
|
||||
videoFeedView: NostrVideoFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
is FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SlidingCarousel(
|
||||
feed: MutableState<List<Note>>,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val pagerState: PagerState = remember { PagerState() }
|
||||
|
||||
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(45.dp), verticalArrangement = Arrangement.Center) {
|
||||
NoteAuthorPicture(note, navController, loggedIn, 45.dp)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.padding(start = 10.dp, end = 10.dp)
|
||||
.height(45.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.width(65.dp)
|
||||
.padding(bottom = 10.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.Center) {
|
||||
ReactionsColumn(note, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
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(it, onClose = { pickedURI = null }, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -326,4 +326,20 @@
|
||||
|
||||
<string name="file_server">File Server</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress or @User</string>
|
||||
|
||||
<string name="upload_server_imgur">imgur.com - less secure</string>
|
||||
<string name="upload_server_imgur_explainer">Uploads to Imgur. Imgur can change your image/video at any time</string>
|
||||
|
||||
<string name="upload_server_nostrimg">nostrimg.com - less secure</string>
|
||||
<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_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_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>
|
||||
|
||||
</resources>
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
buildscript {
|
||||
ext {
|
||||
fragment_version = "1.5.6"
|
||||
fragment_version = "1.5.7"
|
||||
lifecycle_version = '2.6.1'
|
||||
compose_ui_version = '1.5.0-alpha03'
|
||||
compose_ui_version = '1.4.2'
|
||||
nav_version = "2.5.3"
|
||||
room_version = "2.4.3"
|
||||
accompanist_version = '0.30.0'
|
||||
|
||||
Reference in New Issue
Block a user