diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 24e124faf..a1579b7de 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -99,7 +99,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk + asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -110,7 +110,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/app-play-x86-release-unsigned-signed.apk + asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -121,7 +121,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/app-play-x86_64-release-unsigned-signed.apk + asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -132,7 +132,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/app-play-arm64-v8a-release-unsigned-signed.apk + asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -143,7 +143,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/play/release/app-play-armeabi-v7a-release-unsigned-signed.apk + asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -155,7 +155,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/app-fdroid-universal-release-unsigned-signed.apk + asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -166,7 +166,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/app-fdroid-x86-release-unsigned-signed.apk + asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -177,7 +177,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/app-fdroid-x86_64-release-unsigned-signed.apk + asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -188,7 +188,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release-unsigned-signed.apk + asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -199,7 +199,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release-unsigned-signed.apk + asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -213,7 +213,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/bundle/playRelease/app-play-release.aab + asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab asset_name: amethyst-googleplay-${{ github.ref_name }}.aab asset_content_type: application/zip @@ -225,6 +225,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: amethyst/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab + asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab asset_name: amethyst-fdroid-${{ github.ref_name }}.aab asset_content_type: application/zip diff --git a/amethyst/build.gradle b/amethyst/build.gradle index b39f077c5..869259684 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 34 - versionCode 381 - versionName "0.88.4" + versionCode 382 + versionName "0.88.5" buildConfigField "String", "RELEASE_NOTES_ID", "\"2a34cbadd03212c8162e1ff896ba12641821088a2ec8d5e40d54aa80c0510800\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2331f3c37..59f8501e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2203,6 +2203,7 @@ class Account( url: String, ) { if (!isWriteable()) return + GalleryListEvent.addEvent( userProfile().latestGalleryList, idHex, @@ -2226,7 +2227,7 @@ class Account( GalleryListEvent.removeReplaceable( galleryentries, note.address, - false, + url, signer, ) { Client.send(it) @@ -2236,7 +2237,7 @@ class Account( GalleryListEvent.removeEvent( galleryentries, note.idHex, - false, + url, signer, ) { Client.send(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/HttpStatusMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/HttpStatusMessages.kt new file mode 100644 index 000000000..009038d54 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/HttpStatusMessages.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.R + +class HttpStatusMessages { + companion object { + fun resourceIdFor(statusCode: Int = 0): Int? = + when (statusCode) { + 400 -> R.string.http_status_400 + 401 -> R.string.http_status_401 + 402 -> R.string.http_status_402 + 403 -> R.string.http_status_403 + 404 -> R.string.http_status_404 + 405 -> R.string.http_status_405 + 406 -> R.string.http_status_406 + 407 -> R.string.http_status_407 + 408 -> R.string.http_status_408 + 409 -> R.string.http_status_409 + 410 -> R.string.http_status_410 + 411 -> R.string.http_status_411 + 412 -> R.string.http_status_412 + 413 -> R.string.http_status_413 + 414 -> R.string.http_status_414 + 415 -> R.string.http_status_415 + 416 -> R.string.http_status_416 + 417 -> R.string.http_status_417 + 426 -> R.string.http_status_426 + + 500 -> R.string.http_status_500 + 501 -> R.string.http_status_501 + 502 -> R.string.http_status_502 + 503 -> R.string.http_status_503 + 504 -> R.string.http_status_504 + 505 -> R.string.http_status_505 + 506 -> R.string.http_status_506 + 507 -> R.string.http_status_507 + 508 -> R.string.http_status_508 + 511 -> R.string.http_status_511 + + else -> null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt index 7d069d0f1..a2b915363 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service import android.content.ContentResolver +import android.content.Context import android.net.Uri import android.provider.OpenableColumns import android.webkit.MimeTypeMap @@ -29,7 +30,9 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.ammolite.service.HttpClientManager import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine @@ -48,7 +51,9 @@ val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') fun randomChars() = List(16) { charPool.random() }.joinToString("") -class Nip96Uploader(val account: Account?) { +class Nip96Uploader( + val account: Account?, +) { suspend fun uploadImage( uri: Uri, contentType: String?, @@ -58,6 +63,7 @@ class Nip96Uploader(val account: Account?) { server: Nip96MediaServers.ServerName, contentResolver: ContentResolver, onProgress: (percentage: Float) -> Unit, + context: Context, ): PartialEvent { val serverInfo = Nip96Retriever() @@ -74,6 +80,7 @@ class Nip96Uploader(val account: Account?) { serverInfo, contentResolver, onProgress, + context, ) } @@ -86,6 +93,7 @@ class Nip96Uploader(val account: Account?) { server: Nip96Retriever.ServerInfo, contentResolver: ContentResolver, onProgress: (percentage: Float) -> Unit, + context: Context, ): PartialEvent { checkNotInMainThread() @@ -111,6 +119,7 @@ class Nip96Uploader(val account: Account?) { sensitiveContent, server, onProgress, + context, ) } @@ -122,6 +131,7 @@ class Nip96Uploader(val account: Account?) { sensitiveContent: String?, server: Nip96Retriever.ServerInfo, onProgress: (percentage: Float) -> Unit, + context: Context, ): PartialEvent { checkNotInMainThread() @@ -130,11 +140,11 @@ class Nip96Uploader(val account: Account?) { contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val client = HttpClientManager.getHttpClient() - val requestBody: RequestBody val requestBuilder = Request.Builder() - requestBody = - MultipartBody.Builder() + val requestBody: RequestBody = + MultipartBody + .Builder() .setType(MultipartBody.FORM) .addFormDataPart("expiration", "") .addFormDataPart("size", length.toString()) @@ -142,8 +152,7 @@ class Nip96Uploader(val account: Account?) { alt?.let { body.addFormDataPart("alt", it) } sensitiveContent?.let { body.addFormDataPart("content-warning", it) } contentType?.let { body.addFormDataPart("content_type", it) } - } - .addFormDataPart( + }.addFormDataPart( "file", "$fileName.$extension", object : RequestBody() { @@ -155,8 +164,7 @@ class Nip96Uploader(val account: Account?) { inputStream.source().use(sink::writeAll) } }, - ) - .build() + ).build() nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } @@ -178,11 +186,16 @@ class Nip96Uploader(val account: Account?) { } else if (result.status == "success" && result.nip94Event != null) { return result.nip94Event } else { - throw RuntimeException("Failed to upload with message: ${result.message}") + throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message)) } } } else { - throw RuntimeException("Error Uploading image: ${response.code}") + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code)) + } } } } @@ -191,6 +204,7 @@ class Nip96Uploader(val account: Account?) { hash: String, contentType: String?, server: Nip96Retriever.ServerInfo, + context: Context, ): Boolean { val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" @@ -216,7 +230,12 @@ class Nip96Uploader(val account: Account?) { return result.status == "success" } } else { - throw RuntimeException("Error Uploading image: ${response.code}") + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + } } } } @@ -233,7 +252,8 @@ class Nip96Uploader(val account: Account?) { onProgress((currentResult.percentage ?: 100) / 100f) val request: Request = - Request.Builder() + Request + .Builder() .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(result.processingUrl) .build() @@ -257,13 +277,12 @@ class Nip96Uploader(val account: Account?) { } } - suspend fun nip98Header(url: String): String? { - return withTimeoutOrNull(5000) { + suspend fun nip98Header(url: String): String? = + withTimeoutOrNull(5000) { suspendCancellableCoroutine { continuation -> nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) } } } - } fun nip98Header( url: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 0d91a22f8..4df69e1e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -118,7 +118,8 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { ) fun createOtherAccountsBaseFilter(): TypedFilter? { - if (otherAccounts.isEmpty()) return null + val otherAuthors = otherAccounts.filter { it != account.userProfile().pubkeyHex } + if (otherAuthors.isEmpty()) return null return TypedFilter( types = EVENT_FINDER_TYPES, filter = @@ -134,7 +135,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { MuteListEvent.KIND, PeopleListEvent.KIND, ), - authors = otherAccounts.filter { it != account.userProfile().pubkeyHex }, + authors = otherAuthors, limit = 100, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 4ff65b474..7580bdc2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -205,38 +205,54 @@ class EventNotificationConsumer( event: LnZapEvent, acc: Account, ) { + Log.d("EventNotificationConsumer", "Notify Start ${event.toNostrUri()}") val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return + Log.d("EventNotificationConsumer", "Notify Not Notified Yet") + // old event being re-broadcast if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return + Log.d("EventNotificationConsumer", "Notify Not an old event") + val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return + Log.d("EventNotificationConsumer", "Notify ZapRequest $noteZapRequest zapped $noteZapped") + if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return + Log.d("EventNotificationConsumer", "Notify Amount Bigger than 10") + if (event.isTaggedUser(acc.userProfile().pubkeyHex)) { val amount = showAmount(event.amount) + + Log.d("EventNotificationConsumer", "Notify Amount $amount") + (noteZapRequest.event as? LnZapRequestEvent)?.let { event -> acc.decryptZapContentAuthor(noteZapRequest) { + Log.d("EventNotificationConsumer", "Notify Decrypted if Private Zap ${event.id}") + val author = LocalCache.getOrCreateUser(it.pubKey) val senderInfo = Pair(author, it.content.ifBlank { null }) acc.decryptContent(noteZapped) { + Log.d("EventNotificationConsumer", "Notify Decrypted if Private Note") + val zappedContent = it.split("\n").get(0) val user = senderInfo.first.toBestDisplayName() - var title = - stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount) + var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount) senderInfo.second?.ifBlank { null }?.let { title += " ($it)" } + var content = stringRes( applicationContext, R.string.app_notification_zaps_channel_message_from, user, ) - zappedContent?.let { + zappedContent.let { content += " " + stringRes( @@ -247,6 +263,9 @@ class EventNotificationConsumer( } val userPicture = senderInfo?.first?.profilePicture() val noteUri = "nostr:Notifications" + + Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri") + notificationManager() .sendZapNotification( event.id, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt index 2caf1d52b..c193a35aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt @@ -73,7 +73,7 @@ fun stringRes( return String .format( res.configuration.locales.get(0), - resourceCache.get(id) ?: res.getString(id), + resourceCache.get(id) ?: res.getString(id).also { resourceCache.put(id, it) }, *args, - ).also { resourceCache.put(id, it) } + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index a951d4a7a..53aa0a0e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -53,7 +53,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch @Stable -open class EditPostViewModel() : ViewModel() { +open class EditPostViewModel : ViewModel() { var accountViewModel: AccountViewModel? = null var account: Account? = null @@ -185,6 +185,7 @@ open class EditPostViewModel() : ViewModel() { server = server.server, contentResolver = contentResolver, onProgress = {}, + context = context, ) createNIP94Record( @@ -235,13 +236,12 @@ open class EditPostViewModel() : ViewModel() { NostrSearchEventOrUserDataSource.clear() } - open fun findUrlInMessage(): String? { - return message.text.split('\n').firstNotNullOfOrNull { paragraph -> + open fun findUrlInMessage(): String? = + message.text.split('\n').firstNotNullOfOrNull { paragraph -> paragraph.split(' ').firstOrNull { word: String -> RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word) } } - } open fun updateMessage(it: TextFieldValue) { message = it @@ -249,14 +249,18 @@ open class EditPostViewModel() : ViewModel() { if (it.selection.collapsed) { val lastWord = - it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + it.text + .substring(0, it.selection.end) + .substringAfterLast("\n") + .substringAfterLast(" ") userSuggestionAnchor = it.selection userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE if (lastWord.startsWith("@") && lastWord.length > 2) { NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) viewModelScope.launch(Dispatchers.IO) { userSuggestions = - LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + LocalCache + .findUsersStartingWith(lastWord.removePrefix("@")) .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) .reversed() } @@ -271,7 +275,10 @@ open class EditPostViewModel() : ViewModel() { userSuggestionAnchor?.let { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = - message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + message.text + .substring(0, it.end) + .substringAfterLast("\n") + .substringAfterLast(" ") val lastWordStart = it.end - lastWord.length val wordToInsert = "@${item.pubkeyNpub()}" @@ -288,12 +295,11 @@ open class EditPostViewModel() : ViewModel() { } } - fun canPost(): Boolean { - return message.text.isNotBlank() && + fun canPost(): Boolean = + message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && contentToAddUrl == null - } suspend fun createNIP94Record( uploadingResult: Nip96Uploader.PartialEvent, @@ -304,11 +310,20 @@ open class EditPostViewModel() : ViewModel() { // Images don't seem to be ready immediately after upload val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) val remoteMimeType = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "m" } + ?.get(1) + ?.ifBlank { null } val originalHash = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "ox" } + ?.get(1) + ?.ifBlank { null } val dim = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "dim" } + ?.get(1) + ?.ifBlank { null } val magnet = uploadingResult.tags ?.firstOrNull { it.size > 1 && it[0] == "magnet" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index c9d3ccf79..d3cdcfa45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -139,6 +139,7 @@ open class NewMediaModel : ViewModel() { onProgress = { percent: Float -> uploadingPercentage.value = 0.2f + (0.2f * percent) }, + context = context, ) createNIP94Record( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 4dd0e23a5..55dc95a68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -801,6 +801,7 @@ open class NewPostViewModel : ViewModel() { server = server.server, contentResolver = contentResolver, onProgress = {}, + context = context, ) createNIP94Record( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 02dc193f1..93fac1474 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -184,6 +184,7 @@ class NewUserMetadataViewModel : ViewModel() { server = account.defaultFileServer, contentResolver = contentResolver, onProgress = {}, + context = context, ) val url = result.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 8618ae622..239d88b0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -142,7 +142,6 @@ fun LoadThumbAndThenVideoView( roundedCorner: Boolean, isFiniteHeight: Boolean, nostrUriCallback: String? = null, - nostrIdCallback: String? = null, accountViewModel: AccountViewModel, onDialog: ((Boolean) -> Unit)? = null, ) { @@ -177,7 +176,6 @@ fun LoadThumbAndThenVideoView( artworkUri = thumbUri, authorName = authorName, nostrUriCallback = nostrUriCallback, - nostrIdCallback = nostrIdCallback, accountViewModel = accountViewModel, onDialog = onDialog, ) @@ -192,7 +190,6 @@ fun LoadThumbAndThenVideoView( artworkUri = thumbUri, authorName = authorName, nostrUriCallback = nostrUriCallback, - nostrIdCallback = nostrIdCallback, accountViewModel = accountViewModel, onDialog = onDialog, ) @@ -214,7 +211,6 @@ fun VideoView( dimensions: String? = null, blurhash: String? = null, nostrUriCallback: String? = null, - nostrIdCallback: String? = null, onDialog: ((Boolean) -> Unit)? = null, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, accountViewModel: AccountViewModel, @@ -256,7 +252,6 @@ fun VideoView( artworkUri = artworkUri, authorName = authorName, nostrUriCallback = nostrUriCallback, - nostrIDCallback = nostrIdCallback, automaticallyStartPlayback = automaticallyStartPlayback, onControllerVisibilityChanged = onControllerVisibilityChanged, onDialog = onDialog, @@ -310,7 +305,6 @@ fun VideoView( artworkUri = artworkUri, authorName = authorName, nostrUriCallback = nostrUriCallback, - nostrIDCallback = nostrIdCallback, automaticallyStartPlayback = automaticallyStartPlayback, onControllerVisibilityChanged = onControllerVisibilityChanged, onDialog = onDialog, @@ -335,7 +329,6 @@ fun VideoViewInner( artworkUri: String? = null, authorName: String? = null, nostrUriCallback: String? = null, - nostrIDCallback: String? = null, automaticallyStartPlayback: State, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, onDialog: ((Boolean) -> Unit)? = null, @@ -357,7 +350,6 @@ fun VideoViewInner( roundedCorner = roundedCorner, isFiniteHeight = isFiniteHeight, nostrUriCallback = nostrUriCallback, - nostrIDCallback = nostrIDCallback, waveform = waveform, keepPlaying = keepPlaying, automaticallyStartPlayback = automaticallyStartPlayback, @@ -705,7 +697,6 @@ private fun RenderVideoPlayer( roundedCorner: Boolean, isFiniteHeight: Boolean, nostrUriCallback: String?, - nostrIDCallback: String?, waveform: ImmutableList? = null, keepPlaying: MutableState, automaticallyStartPlayback: State, @@ -819,7 +810,7 @@ private fun RenderVideoPlayer( } AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle -> - ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, nostrIDCallback, toggle) + ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, toggle) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 1f1ed554a..5fed68693 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.toHexKey import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -138,7 +139,6 @@ fun ZoomableContentView( roundedCorner = roundedCorner, isFiniteHeight = isFiniteHeight, nostrUriCallback = content.uri, - nostrIdCallback = content.id, onDialog = { dialogOpen = true }, accountViewModel = accountViewModel, ) @@ -163,7 +163,6 @@ fun ZoomableContentView( roundedCorner = roundedCorner, isFiniteHeight = isFiniteHeight, nostrUriCallback = content.uri, - nostrIdCallback = content.id, onDialog = { dialogOpen = true }, accountViewModel = accountViewModel, ) @@ -609,7 +608,6 @@ fun ShareImageAction( popupExpanded = popupExpanded, videoUri = content.url, postNostrUri = content.uri, - postNostrid = content.id, onDismiss = onDismiss, ) } else if (content is MediaPreloadedContent) { @@ -618,7 +616,6 @@ fun ShareImageAction( popupExpanded = popupExpanded, videoUri = content.localFile?.toUri().toString(), postNostrUri = content.uri, - postNostrid = content.id, onDismiss = onDismiss, ) } @@ -631,7 +628,6 @@ fun ShareImageAction( popupExpanded: MutableState, videoUri: String?, postNostrUri: String?, - postNostrid: String?, onDismiss: () -> Unit, ) { DropdownMenu( @@ -665,13 +661,13 @@ fun ShareImageAction( text = { Text(stringRes(R.string.add_media_to_gallery)) }, onClick = { if (videoUri != null) { - if (postNostrid != null) { - print("TODO") - print(postNostrid) - // TODO this still crashes - accountViewModel.account.addToGallery(postNostrid, videoUri) + var n19 = Nip19Bech32.uriToRoute(postNostrUri)?.entity as? Nip19Bech32.NEvent + if (n19 != null) { + accountViewModel.addMediaToGallery(n19.hex, videoUri) + accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery) } } + onDismiss() }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt new file mode 100644 index 000000000..8b81e5bde --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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.model.User + +class UserProfileGalleryFeedFilter(val user: User, val account: Account) : FeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-Gallery-" + user.pubkeyHex + } + + override fun feed(): List { + val notes = + user.latestGalleryList + ?.taggedGalleryEntries() + ?.mapNotNull { LocalCache.checkGetOrCreateNote(it.id) } + ?.toSet() + ?: emptySet() + + /*val addresses = + user.latestGalleryList + ?.taggedAddresses() + ?.map { LocalCache.getOrCreateAddressableNote(it) } + ?.toSet() + ?: emptySet() */ + + // .sortedWith(DefaultFeedOrder) + return (notes) + .filter { account.isAcceptable(it) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 2cc7a5c62..957a0e77e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -154,6 +154,38 @@ fun LongPressToQuickAction( } } +@Composable +fun LongPressToQuickActionGallery( + baseNote: Note, + accountViewModel: AccountViewModel, + content: @Composable (() -> Unit) -> Unit, +) { + val popupExpanded = remember { mutableStateOf(false) } + + content { popupExpanded.value = true } + + if (popupExpanded.value) { + NoteQuickActionMenuGallery( + note = baseNote, + onDismiss = { popupExpanded.value = false }, + accountViewModel = accountViewModel, + nav = {}, + ) + } +} + +@Composable +fun NoteQuickActionMenuGallery( + note: Note, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + DeleteFromGalleryDialog(note, accountViewModel) { + onDismiss() + } +} + @Composable fun NoteQuickActionMenu( note: Note, @@ -435,6 +467,169 @@ private fun RenderMainPopup( } } +@Composable +private fun RenderDeleteFromGalleryPopup( + accountViewModel: AccountViewModel, + note: Note, + showDeleteAlertDialog: MutableState, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f) + val cardShape = RoundedCornerShape(5.dp) + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + + val backgroundColor = + if (MaterialTheme.colorScheme.isLight) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondaryButtonBackground + } + + val showToast = { stringRes: Int -> + scope.launch { + Toast + .makeText( + context, + stringRes(context, stringRes), + Toast.LENGTH_SHORT, + ).show() + } + } + + val isOwnNote = accountViewModel.isLoggedUser(note.author) + val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) + + Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) { + Card( + modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape), + shape = cardShape, + colors = CardDefaults.cardColors(containerColor = backgroundColor), + ) { + Column(modifier = Modifier.width(IntrinsicSize.Min)) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + NoteQuickActionItem( + icon = Icons.Default.ContentCopy, + label = stringRes(R.string.quick_action_copy_text), + ) { + accountViewModel.decrypt(note) { + clipboardManager.setText(AnnotatedString(it)) + showToast(R.string.copied_note_text_to_clipboard) + } + + onDismiss() + } + VerticalDivider(color = primaryLight) + NoteQuickActionItem( + Icons.Default.AlternateEmail, + stringRes(R.string.quick_action_copy_user_id), + ) { + note.author?.let { + scope.launch { + clipboardManager.setText(AnnotatedString(it.toNostrUri())) + showToast(R.string.copied_user_id_to_clipboard) + onDismiss() + } + } + } + VerticalDivider(color = primaryLight) + NoteQuickActionItem( + Icons.Default.FormatQuote, + stringRes(R.string.quick_action_copy_note_id), + ) { + scope.launch { + clipboardManager.setText(AnnotatedString(note.toNostrUri())) + showToast(R.string.copied_note_id_to_clipboard) + onDismiss() + } + } + } + HorizontalDivider( + color = primaryLight, + ) + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + if (isOwnNote) { + NoteQuickActionItem( + Icons.Default.Delete, + stringRes(R.string.quick_action_delete), + ) { + if (accountViewModel.hideDeleteRequestDialog) { + accountViewModel.delete(note) + onDismiss() + } else { + showDeleteAlertDialog.value = true + } + } + } else if (isFollowingUser) { + NoteQuickActionItem( + Icons.Default.PersonRemove, + stringRes(R.string.quick_action_unfollow), + ) { + accountViewModel.unfollow(note.author!!) + onDismiss() + } + } else { + NoteQuickActionItem( + Icons.Default.PersonAdd, + stringRes(R.string.quick_action_follow), + ) { + accountViewModel.follow(note.author!!) + onDismiss() + } + } + + VerticalDivider(color = primaryLight) + NoteQuickActionItem( + icon = ImageVector.vectorResource(id = R.drawable.relays), + label = stringRes(R.string.broadcast), + ) { + accountViewModel.broadcast(note) + // showSelectTextDialog = true + onDismiss() + } + VerticalDivider(color = primaryLight) + if (isOwnNote && note.isDraft()) { + NoteQuickActionItem( + Icons.Default.Edit, + stringRes(R.string.edit_draft), + ) { + onDismiss() + } + } else { + NoteQuickActionItem( + icon = Icons.Default.Share, + label = stringRes(R.string.quick_action_share), + ) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + externalLinkForNote(note), + ) + putExtra( + Intent.EXTRA_TITLE, + stringRes(context, R.string.quick_action_share_browser_link), + ) + } + + val shareIntent = + Intent.createChooser( + sendIntent, + stringRes(context, R.string.quick_action_share), + ) + ContextCompat.startActivity(context, shareIntent, null) + onDismiss() + } + } + } + } + } + } +} + @Composable fun NoteQuickActionItem( icon: ImageVector, @@ -462,6 +657,25 @@ fun NoteQuickActionItem( } } +@Composable +fun DeleteFromGalleryDialog( + note: Note, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + QuickActionAlertDialogOneButton( + title = stringRes(R.string.quick_action_request_deletion_gallery_title), + textContent = stringRes(R.string.quick_action_request_deletion_gallery_alert_body), + buttonIcon = Icons.Default.Delete, + buttonText = stringRes(R.string.quick_action_delete_dialog_btn), + onClickDoOnce = { + note.event?.firstTaggedUrl()?.let { accountViewModel.removefromMediaGallery(note, it) } + onDismiss() + }, + onDismiss = onDismiss, + ) +} + @Composable fun DeleteAlertDialog( note: Note, @@ -612,3 +826,95 @@ fun QuickActionAlertDialog( }, ) } + +@Composable +fun QuickActionAlertDialogOneButton( + title: String, + textContent: String, + buttonIcon: ImageVector, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onDismiss: () -> Unit, +) { + QuickActionAlertDialogOneButton( + title = title, + textContent = textContent, + icon = { + Icon( + imageVector = buttonIcon, + contentDescription = null, + ) + }, + buttonText = buttonText, + buttonColors = buttonColors, + onClickDoOnce = onClickDoOnce, + onDismiss = onDismiss, + ) +} + +@Composable +fun QuickActionAlertDialogOneButton( + title: String, + textContent: String, + buttonIconResource: Int, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onDismiss: () -> Unit, +) { + QuickActionAlertDialogOneButton( + title = title, + textContent = textContent, + icon = { + Icon( + painter = painterResource(buttonIconResource), + contentDescription = null, + ) + }, + buttonText = buttonText, + buttonColors = buttonColors, + onClickDoOnce = onClickDoOnce, + onDismiss = onDismiss, + ) +} + +@Composable +fun QuickActionAlertDialogOneButton( + title: String, + textContent: String, + icon: @Composable () -> Unit, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(textContent) }, + confirmButton = { + Row( + modifier = + Modifier + .padding(all = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Button( + onClick = onClickDoOnce, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Spacer(Modifier.width(8.dp)) + Text(buttonText) + } + } + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt index f1879ea25..d7875628e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -212,7 +212,6 @@ fun AudioHeader( isFiniteHeight = isFiniteHeight, accountViewModel = accountViewModel, nostrUriCallback = note.toNostrUri(), - nostrIdCallback = note.idHex, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index 766aeea97..6d0a16f35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -160,7 +160,6 @@ fun RenderLiveActivityEventInner( isFiniteHeight = false, accountViewModel = accountViewModel, nostrUriCallback = "nostr:${baseNote.toNEvent()}", - nostrIdCallback = baseNote.idHex, ) } } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 422bd15a2..04169e499 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter +import com.vitorpamplona.amethyst.ui.dal.UserProfileGalleryFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter @@ -214,6 +215,16 @@ class NostrUserProfileReportFeedViewModel(val user: User) : } } +class NostrUserProfileGalleryFeedViewModel(val user: User, val account: Account) : + FeedViewModel(UserProfileGalleryFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileGalleryFeedViewModel { + return NostrUserProfileGalleryFeedViewModel(user, account) + as NostrUserProfileGalleryFeedViewModel + } + } +} + class NostrUserProfileBookmarksFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) { class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index b1e5c4eef..5c6504503 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -669,6 +669,20 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(usersEmojiList, emojiList) } } + fun addMediaToGallery( + hex: String, + url: String, + ) { + viewModelScope.launch(Dispatchers.IO) { account.addToGallery(hex, url) } + } + + fun removefromMediaGallery( + note: Note, + url: String, + ) { + viewModelScope.launch(Dispatchers.IO) { account.removeFromGallery(note, url) } + } + fun addPrivateBookmark(note: Note) { viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileGallery.kt index 08204061f..41850616d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileGallery.kt @@ -20,86 +20,163 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.BottomStart import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import coil.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport import com.vitorpamplona.amethyst.ui.note.ClickableNote -import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction +import com.vitorpamplona.amethyst.ui.note.LongPressToQuickActionGallery import com.vitorpamplona.amethyst.ui.note.WatchAuthor import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor import com.vitorpamplona.amethyst.ui.note.elements.BannerImage +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.FeedViewModel +import com.vitorpamplona.amethyst.ui.screen.LoadingFeed +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.quartz.events.GalleryListEvent +import com.vitorpamplona.quartz.events.TextNoteEvent -// TODO This is to large parts from the ChannelCardCompose -// Why does it not be in a grid, like the marketplace @Composable -fun ProfileGallery( - baseNotes: List, +fun RenderGalleryFeed( + viewModel: FeedViewModel, + routeForLastRead: String?, + forceEventKind: Int?, + listState: LazyGridState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + CrossfadeIfEnabled( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "RenderDiscoverFeed", + accountViewModel = accountViewModel, + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + GalleryFeedLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav, + ) + } + is FeedState.Loading -> { + LoadingFeed() + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun GalleryFeedLoaded( + state: FeedState.Loaded, + routeForLastRead: String?, + listState: LazyGridState, + forceEventKind: Int?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } + + Row(defaultModifier) { + GalleryCardCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + modifier = Modifier, + forceEventKind = forceEventKind, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } +} + +@Composable +fun GalleryCardCompose( + baseNote: Note, + routeForLastRead: String? = null, modifier: Modifier = Modifier, parentBackgroundColor: MutableState? = null, + forceEventKind: Int?, isHiddenFeed: Boolean = false, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - for (thumb in baseNotes) { - thumb.baseNote?.let { - WatchNoteEvent(baseNote = it, accountViewModel = accountViewModel) { - if (thumb.baseNote.event?.kind() == GalleryListEvent.KIND) { - CheckHiddenFeedWatchBlockAndReport( - note = thumb.baseNote, - modifier = modifier, - ignoreAllBlocksAndReports = isHiddenFeed, - showHiddenWarning = false, - accountViewModel = accountViewModel, - nav = nav, - ) { canPreview -> - - thumb.image?.let { it1 -> - GalleryCard( - baseNote = thumb.baseNote, - url = it1, - modifier = modifier, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - } + WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel) { + CheckHiddenFeedWatchBlockAndReport( + note = baseNote, + modifier = modifier, + ignoreAllBlocksAndReports = isHiddenFeed, + showHiddenWarning = false, + accountViewModel = accountViewModel, + nav = nav, + ) { canPreview -> + GalleryCard( + baseNote = baseNote, + modifier = modifier, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } } } @@ -107,18 +184,15 @@ fun ProfileGallery( @Composable fun GalleryCard( baseNote: Note, - url: String, modifier: Modifier = Modifier, parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { // baseNote.event?.let { Text(text = it.pubKey()) } - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - + LongPressToQuickActionGallery(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> CheckNewAndRenderChannelCard( baseNote, - url, modifier, parentBackgroundColor, accountViewModel, @@ -131,7 +205,6 @@ fun GalleryCard( @Composable private fun CheckNewAndRenderChannelCard( baseNote: Note, - url: String, modifier: Modifier = Modifier, parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, @@ -156,7 +229,6 @@ private fun CheckNewAndRenderChannelCard( // baseNote.event?.let { Text(text = it.pubKey()) } InnerGalleryCardWithReactions( baseNote = baseNote, - url = url, accountViewModel = accountViewModel, nav = nav, ) @@ -166,17 +238,15 @@ private fun CheckNewAndRenderChannelCard( @Composable fun InnerGalleryCardWithReactions( baseNote: Note, - url: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - InnerGalleryCardBox(baseNote, url, accountViewModel, nav) + InnerGalleryCardBox(baseNote, accountViewModel, nav) } @Composable fun InnerGalleryCardBox( baseNote: Note, - url: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -185,14 +255,13 @@ fun InnerGalleryCardBox( note = baseNote, accountViewModel = accountViewModel, ) { - RenderGalleryThumb(baseNote, url, accountViewModel, nav) + RenderGalleryThumb(baseNote, accountViewModel, nav) } } } @Immutable data class GalleryThumb( - val baseNote: Note?, val id: String?, val image: String?, val title: String?, @@ -202,34 +271,31 @@ data class GalleryThumb( @Composable fun RenderGalleryThumb( baseNote: Note, - url: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? GalleryListEvent ?: return + val noteEvent = baseNote.event as? TextNoteEvent ?: return val card by baseNote .live() .metadata .map { - val noteEvent = baseNote.event as GalleryListEvent + val noteEvent = baseNote.event as TextNoteEvent GalleryThumb( - baseNote = baseNote, id = "", - image = url, - title = "Hello", + image = noteEvent.firstTaggedUrl(), + title = noteEvent.content(), // noteEvent?.title(), // price = noteEvent?.price(), ) }.distinctUntilChanged() .observeAsState( GalleryThumb( - baseNote = baseNote, id = "", - image = "https://gokaygokay-aurasr.hf.space/file=/tmp/gradio/68292f324a38d7071453cf6912dfb1da9d1305c8/image3.png", - title = "Hello", + image = noteEvent.firstTaggedUrl(), + title = noteEvent.content(), // image = noteEvent.image(), // title = noteEvent.title(), // price = noteEvent.price(), @@ -246,7 +312,6 @@ fun RenderGalleryThumbPreview() { InnerRenderGalleryThumb( card = GalleryThumb( - baseNote = null, id = "", image = null, title = "Like New", @@ -277,7 +342,7 @@ fun InnerRenderGalleryThumb( ) } ?: run { DisplayGalleryAuthorBanner(note) } - Row( + /* Row( Modifier .fillMaxWidth() .background(Color.Black.copy(0.6f)) @@ -294,7 +359,7 @@ fun InnerRenderGalleryThumb( modifier = Modifier.weight(1f), ) } - /* + card.price?.let { val priceTag = remember(card) { @@ -315,8 +380,8 @@ fun InnerRenderGalleryThumb( overflow = TextOverflow.Ellipsis, color = Color.White, ) - }*/ - } + } + }*/ } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 735444d82..0a0ec78a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState @@ -148,6 +149,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewMod import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowersUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowsUserFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileGalleryFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileNewThreadsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileReportFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileZapsFeedViewModel @@ -155,6 +157,7 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.screen.RelayFeedView import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -294,6 +297,16 @@ fun PrepareViewModels( ), ) + val galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserGalleryFeedViewModel", + factory = + NostrUserProfileGalleryFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), + ) + val reportsFeedViewModel: NostrUserProfileReportFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel", @@ -312,6 +325,7 @@ fun PrepareViewModels( appRecommendations, zapFeedViewModel, bookmarksFeedViewModel, + galleryFeedViewModel, reportsFeedViewModel, accountViewModel = accountViewModel, nav = nav, @@ -328,6 +342,7 @@ fun ProfileScreen( appRecommendations: NostrUserAppRecommendationsFeedViewModel, zapFeedViewModel: NostrUserProfileZapsFeedViewModel, bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, reportsFeedViewModel: NostrUserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -372,6 +387,7 @@ fun ProfileScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + galleryFeedViewModel, reportsFeedViewModel, accountViewModel, nav, @@ -388,6 +404,7 @@ private fun RenderSurface( followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, zapFeedViewModel: NostrUserProfileZapsFeedViewModel, bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, reportsFeedViewModel: NostrUserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -448,6 +465,7 @@ private fun RenderSurface( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + galleryFeedViewModel, reportsFeedViewModel, accountViewModel, nav, @@ -470,6 +488,7 @@ private fun RenderScreen( followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, zapFeedViewModel: NostrUserProfileZapsFeedViewModel, bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, reportsFeedViewModel: NostrUserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -501,6 +520,7 @@ private fun RenderScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + galleryFeedViewModel, reportsFeedViewModel, accountViewModel, nav, @@ -519,6 +539,7 @@ private fun CreateAndRenderPages( followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, zapFeedViewModel: NostrUserProfileZapsFeedViewModel, bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, reportsFeedViewModel: NostrUserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -533,7 +554,7 @@ private fun CreateAndRenderPages( when (page) { 0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav) 1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav) - 2 -> Gallery(baseUser, followsFeedViewModel, accountViewModel, nav) + 2 -> TabGallery(galleryFeedViewModel, accountViewModel, nav) 3 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) 4 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav) 5 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) @@ -1537,7 +1558,36 @@ fun TabNotesConversations( } } +@OptIn(ExperimentalFoundationApi::class) @Composable +fun TabGallery( + feedViewModel: NostrUserProfileGalleryFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LaunchedEffect(Unit) { feedViewModel.invalidateData() } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + var state = LazyGridState() + + SaveableGridFeedState(feedViewModel, scrollStateKey = "gallery") { listState -> + RenderGalleryFeed( + feedViewModel, + null, + 0, + state, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +/*@Composable fun Gallery( baseUser: User, feedViewModel: UserFeedViewModel, @@ -1579,7 +1629,7 @@ fun Gallery( } } } -} +} */ @Composable fun TabFollowedTags( diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 95481cddd..3a4ad34ef 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -14,6 +14,7 @@ Nie można odszyfrować wiadomości Zdjęcie grupy Niedozwolona zawartość + Spam Liczba spamu z tego transmitera Podszywanie się Antyspołeczne zachowanie @@ -199,6 +200,8 @@ nigdy teraz godz. + m + d Nagość Wulgaryzmy / Mowa nienawiści Zgłoś nienawistną mowę @@ -301,6 +304,7 @@ Minimalny Zap Maksymalny Zap Konsensus + (0–100)% Zamknij po dni Nie można głosować @@ -383,6 +387,7 @@ Ostrzegaj, gdy posty zostały zgłoszone przez osoby które obserwujesz Nowy Symbol Odzewu Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić + Zapraiser Dodaje docelową liczbę satów do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn Docelowa kwota w Satach Zapraiser przy: %1$s. %2$s satach do celu @@ -414,6 +419,7 @@ Minimalny poW Autoryzacja Płatność + Cashu Token Wykup Wyślij do Zap Wallet Otwórz w Portfelu Cashu @@ -507,6 +513,7 @@ Głosy są oceniane na podstawie ilości zapperów. Możesz ustawić minimalną kwotę, aby uniknąć spamerów i maksymalną kwotę, aby uniknąć przejęcia ankiety przez dużych zapperów. Użyj tej samej kwoty w obu polach, aby upewnić się, że każdy głos ma taką samą wartość. Pozostaw to pole puste, aby zaakceptować dowolną kwotę. Nie można wysłać zapa Wyślij wiadomość użytkownikowi + OK Nieudane połączenie z %1$s: %2$s Nie udało się skompilować adresu URL NIP-11 dla %1$s: %2$s Nieudane połączenie z %1$s: %2$s @@ -527,6 +534,7 @@ Brakująca konfiguracja LN Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać saty Procentowo + 25 Podziel zapsy z Przesyłanie zapasów do Nie znaleziono portfeli Lightning @@ -593,9 +601,11 @@ Cześć, czy to jest nadal dostępne? Sprzedaj przedmiot Tytuł + iPhone 13 Stan Kategoria Cena (w Satach) + 1000 Lokalizacja Miasto, Województwo, Kraj Nowość @@ -614,6 +624,7 @@ Książki Zwierzęta domowe Sporty + Fitness Sztuka Rzemiosło AGD @@ -628,6 +639,8 @@ Serwer nie podał adresu URL po załadowaniu Nie można pobrać przesłanych plików z serwera Nie można przygotować pliku lokalnego do przesłania: %1$s + Nie udało się przesłać: %1$s + Nie udało się usunąć: %1$s Edytuj wersję roboczą Zaloguj się przy użyciu QR kodu Ścieżka @@ -647,6 +660,7 @@ Odpowiedz Zrepostuj lub Zacytuj Lubię + Zap Zmień odzew Zdjęcie profilowe %1$s Transmiter %1$s @@ -658,6 +672,7 @@ Faktura Bitcoin Anuluj fakturę Bitcoin Anuluj sprzedaż przedmiotu + Zapraiser Anuluj Zapraiser Lokalizacja Usuń lokalizację @@ -710,6 +725,7 @@ Repozytorium Git: %1$s Strona internetowa: Klonuj: + OTS: %1$s Potwierdzenie znacznika czasu Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie. Edytuj wpis @@ -737,4 +753,27 @@ Zapytanie o pracę z DVM Prośba o płatność została wysłana, oczekiwanie na potwierdzenie z Twojego portfela Oczekiwanie na potwierdzenie płatności lub wysłanie wyników DVM + Błędne żądanie-Serwer nie może lub nie chce przetworzyć żądania. + Nieautoryzowany - użytkownik nie ma prawidłowych danych uwierzytelniających + Wymagana płatność - serwer wymaga płatności, aby wypełnić żądanie + Zabronione - użytkownik nie ma praw dostępu, aby złożyć żądanie + Nie znaleziono - serwer nie może odnaleźć żądanego adresu + Metoda niedozwolona - serwer obsługuje metodę żądania, ale nie docelowy zasób + Niedopuszczalne - Serwer nie znalazł żadnej zawartości spełniającej żądanie. + Wymagane uwierzytelnianie proxy - użytkownik nie ma prawidłowych danych uwierzytelniania + Limit czasu żądania - serwer przekroczył limit czasu, oczekując na kogoś innego + Konflikt - serwer nie może spełnić żądania, ponieważ istnieje konflikt z zasobem + Przepadło - Żądana zawartość została trwale usunięta z serwera i nie zostanie przywrócona + Wymagana długość - serwer odrzuca żądanie, ponieważ wymaga zdefiniowanej + URI Zbyt długi - URL klienta jest zbyt długi, aby serwer mógł go przetworzyć. + Nieobsługiwany format plików - żądanie używa formatu multimediów, którego serwer nie obsługuje + Wymagana aktualizacja - serwer odmawia przetwarzania żądania przy użyciu bieżącego protokołu, chyba że klient uaktualni go do innego protokołu. + Wewnętrzny błąd serwera - serwer napotkał nieoczekiwany błąd i nie może zakończyć żądania + Nie zaimplementowano - serwer nie może spełnić żądania lub nie rozpoznaje metody żądania + Usługa niedostępna - Często zdarza się, gdy serwer jest przeciążony lub wyłączony w celu konserwacji + Wersja HTTP nie jest obsługiwana - serwer nie obsługuje wersji HTTP w zapytaniu + Wariant również Negocjacje - serwer ma błąd konfiguracji wewnętrznej + Niewystarczająca pojemność - serwer nie ma wystarczającej pojemność, aby pomyślnie przetworzyć żądanie + Wykryta pętla - serwer wykrywa nieskończoną pętlę podczas przetwarzania żądania + Wymagane uwierzytelnienie sieciowe - Klient musi być uwierzytelniony aby uzyskać dostęp do sieci diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index a83417094..0c0e685f1 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -103,6 +103,7 @@ 添加中继器 显示名称 我的显示名称 + Nostr 太酷了 欢迎! 用户名 我的用户名 @@ -386,6 +387,7 @@ 当帖子有你的关注的报告时警告 新回应符号 未选择回应类型。长按可更改 + Zapraiser 为这个帖子添加目标聪金额。支持的客户端可以将此显示为奖励捐赠的进度条 目标聪金额 Zapraiser 位于 %1$s。距离目标%2$s聪 @@ -671,6 +673,7 @@ 比特币发票 取消比特币发票 取消出售物品 + Zapraiser 取消 Zapraiser 地点 移除地点 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 37aa8a5e0..e24567d9a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -270,6 +270,8 @@ Delete Unfollow Follow + Delete from Gallery + Remove this media from your Gallery, you can readd it later Request Deletion Amethyst will request that your note be deleted from the relays you are currently connected to. There is no guarantee that your note will be permanently deleted from those relays, or from other relays where it may be stored. Block @@ -761,6 +763,8 @@ Server did not provide a URL after uploading Could not download uploaded media from the server Could not prepare local file to upload: %1$s + Failed to upload: %1$s + Failed to delete: %1$s Edit draft @@ -896,4 +900,35 @@ Requesting Job from DVM Payment request sent, waiting for confirmation from your wallet Waiting for DVM to confirm payment or send results + + Bad Request - The server can’t or won’t process the request. + Unauthorized - The user doesn’t have valid authentication credentials + Payment Required - The server requires payment to complete the request + Forbidden - The user doesn’t have access rights to make the request + Not Found - The server can’t find the requested address + Method Not Allowed - The server supports the request method, but not the target resource + Not Acceptable - The server doesn’t find any content that satisfies the request. + Proxy Authentication Required - The user doesn’t have valid authentication credentials + Request Timeout - The server timed out waiting for somebody else + Conflict - The server can’t fulfill the request because there’s a conflict with the resource + Gone - The content requested has been permanently deleted from the server and will not be reinstated + Length Required - The server rejects the request because it requires a defined + Precondition Failed - The request\'s preconditions in the header fields that the server fails to meet + Payload Too Large - The request is larger than the server’s defined limits, and the server refuses to process it + URI Too Long - The url requested by the client is too long for the server to process. + Unsupported Media Type - The request uses a media format the server does not support + Range Not Satisfiable - The server can’t fulfill the value indicated in the request’s Range header field. + Expectation Failed - The server can’t meet the requirements indicated by the Expect request header field + Upgrade Required - The server refuses to process the request using the current protocol unless the client upgrades to a different protocol. + + Internal Server Error - The server has encountered an unexpected error and cannot complete the request + Not Implemented - The server can’t fulfill the request or doesn’t recognize the request method + Bad Gateway - The server acts as a gateway and got an invalid response from the host + Service Unavailable - This often occurs when a server is overloaded or down for maintenance + Gateway Timeout - The server was acting as a gateway or proxy and timed out, waiting for a response + HTTP Version Not Supported - The server doesn’t support the HTTP version in the request + Variant Also Negotiates - The server has an internal configuration error + Insufficient Storage - The server doesn’t have enough storage to process the request successfully + Loop Detected - The server detects an infinite loop while processing the request + Network Authentication Required - The client must be authenticated to access the network diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt index 73a09f013..356e8ce47 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt @@ -72,3 +72,21 @@ data class EmojiUrl(val code: String, val url: String) { } } } + +@Immutable +data class GalleryUrl(val id: String, val url: String) { + fun encode(): String { + return ":$id:$url" + } + + companion object { + fun decode(encodedGallerySetup: String): EmojiUrl? { + val emojiParts = encodedGallerySetup.split(":", limit = 3) + return if (emojiParts.size > 2) { + EmojiUrl(emojiParts[1], emojiParts[2]) + } else { + null + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 19521379e..a921dfb44 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -119,6 +119,8 @@ open class Event( override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + override fun taggedGalleryEntries() = tags.filter { it.size > 2 && it[0] == "g" }.map { GalleryUrl(it[1], it[2]) } + override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } override fun firstTagFor(vararg key: String) = tags.firstOrNull { it.size > 1 && it[0] in key }?.let { it[1] } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index db11a3ea4..d444f4195 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -145,6 +145,8 @@ interface EventInterface { fun firstTaggedK(): Int? + fun taggedGalleryEntries(): List + fun taggedEmojis(): List fun matchTag1With(text: String): Boolean diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GalleryListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GalleryListEvent.kt index 4311407c8..1b65359c0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GalleryListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GalleryListEvent.kt @@ -60,7 +60,7 @@ class GalleryListEvent( ) { add( earlierVersion, - arrayOf(arrayOf(tagName, url, tagValue)), + arrayOf(arrayOf(tagName, tagValue, url)), signer, createdAt, onReady, @@ -76,7 +76,7 @@ class GalleryListEvent( ) { create( content = earlierVersion?.content ?: "", - tags = (earlierVersion?.tags ?: arrayOf(arrayOf("d", DEFAULT_D_TAG_GALLERY))).plus(listNewTags), + tags = listNewTags.plus(earlierVersion?.tags ?: arrayOf(arrayOf("d", DEFAULT_D_TAG_GALLERY))), signer = signer, createdAt = createdAt, onReady = onReady, @@ -86,23 +86,26 @@ class GalleryListEvent( fun removeEvent( earlierVersion: GalleryListEvent, eventId: HexKey, + url: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (GalleryListEvent) -> Unit, - ) = removeTag(earlierVersion, "e", eventId, signer, createdAt, onReady) + ) = removeTag(earlierVersion, "g", eventId, url, signer, createdAt, onReady) fun removeReplaceable( earlierVersion: GalleryListEvent, aTag: ATag, + url: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (GalleryListEvent) -> Unit, - ) = removeTag(earlierVersion, "a", aTag.toTag(), signer, createdAt, onReady) + ) = removeTag(earlierVersion, "g", aTag.toTag(), url, signer, createdAt, onReady) private fun removeTag( earlierVersion: GalleryListEvent, tagName: String, tagValue: HexKey, + url: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (GalleryListEvent) -> Unit, @@ -111,7 +114,7 @@ class GalleryListEvent( content = earlierVersion.content, tags = earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } + .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue && it[2] == url) } .toTypedArray(), signer = signer, createdAt = createdAt, @@ -135,30 +138,5 @@ class GalleryListEvent( signer.sign(createdAt, KIND, newTags, content, onReady) } - - fun create( - name: String = "", - images: List? = null, - videos: List? = null, - audios: List? = null, - privEvents: List? = null, - privUsers: List? = null, - privAddresses: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) { - val tags = mutableListOf>() - tags.add(arrayOf("d", name)) - - images?.forEach { tags.add(arrayOf("g", it)) } - videos?.forEach { tags.add(arrayOf("g", it)) } - audios?.forEach { tags.add(arrayOf("g", it)) } - tags.add(arrayOf("alt", ALT)) - - createPrivateTags(privEvents, privUsers, privAddresses, signer) { content -> - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - } } }