From 195e2688650e384c7e730d53c122cc76ff81596d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 20 May 2025 14:23:51 -0400 Subject: [PATCH] Normalizes NIP-22 viewmodel to its own entity --- .../amethyst/ui/navigation/AppNavigation.kt | 4 +- .../note/creators/previews/DisplayPreviews.kt | 64 ++ .../nip22Comments/CommentPostViewModel.kt} | 61 +- .../note/nip22Comments/DisplayExternalId.kt | 39 ++ .../nip22Comments/DisplayGeoHashExternalId.kt | 83 +++ .../nip22Comments/DisplayHashtagExternalId.kt | 80 +++ .../nip99Classifieds/NewProductScreen.kt | 40 +- ...{GeoPostScreen.kt => GeoHashPostScreen.kt} | 102 +-- .../loggedIn/geohash/GeoPostViewModel.kt | 663 ------------------ .../loggedIn/hashtag/HashtagPostScreen.kt | 97 +-- .../quartz/nip22Comments/CommentEvent.kt | 14 +- .../nip22Comments/TagArrayBuilderExt.kt | 8 +- .../nip22Comments/tags/ReplyIdentifierTag.kt | 36 +- .../nip22Comments/tags/RootIdentifierTag.kt | 36 +- .../nip73ExternalIds/CommentEventExt.kt | 39 ++ .../quartz/nip73ExternalIds/books/BookId.kt | 8 + .../nip73ExternalIds/location/GeohashId.kt | 8 + .../quartz/nip73ExternalIds/movies/MovieId.kt | 8 + .../quartz/nip73ExternalIds/papers/PaperId.kt | 8 + .../podcasts/PodcastEpisodeId.kt | 8 + .../podcasts/PodcastFeedId.kt | 8 + .../podcasts/PodcastPublisherId.kt | 8 + .../nip73ExternalIds/topics/HashtagId.kt | 11 + .../quartz/nip73ExternalIds/urls/UrlId.kt | 8 + 24 files changed, 519 insertions(+), 922 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{screen/loggedIn/hashtag/HashtagPostViewModel.kt => note/nip22Comments/CommentPostViewModel.kt} (93%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/{GeoPostScreen.kt => GeoHashPostScreen.kt} (83%) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostViewModel.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/CommentEventExt.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index a4ee0fef9..1df366a33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -69,8 +69,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen @@ -143,7 +143,7 @@ fun AppNavigation( composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { - GeoPostScreen( + GeoHashPostScreen( geohash = it.geohash, message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt new file mode 100644 index 000000000..b624e5459 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2025 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.note.creators.previews + +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.FillWidthQuoteBorderModifier +import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding +import com.vitorpamplona.amethyst.ui.theme.Height100Modifier +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SquaredQuoteBorderModifier + +@Composable +fun DisplayPreviews( + state: PreviewState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val urlPreviews by state.results.collectAsStateWithLifecycle(emptyList()) + + if (urlPreviews.isNotEmpty()) { + Row(HalfHorzPadding) { + if (urlPreviews.size > 1) { + LazyRow(Height100Modifier, horizontalArrangement = spacedBy(Size5dp)) { + items(urlPreviews) { + Box(SquaredQuoteBorderModifier) { + PreviewUrl(it, accountViewModel, nav) + } + } + } + } else { + Box(FillWidthQuoteBorderModifier) { + PreviewUrlFillWidth(urlPreviews[0], accountViewModel, nav) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index d87c9bf6b..43e11b8c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -18,7 +18,7 @@ * 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.screen.loggedIn.hashtag +package com.vitorpamplona.amethyst.ui.note.nip22Comments import android.content.Context import androidx.compose.runtime.Stable @@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator @@ -85,8 +85,8 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId -import com.vitorpamplona.quartz.nip73ExternalIds.topics.hashtagScope +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.scope import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip94FileMetadata.alt @@ -104,9 +104,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlin.collections.plus @Stable -open class HashtagPostViewModel : +open class CommentPostViewModel : ViewModel(), ILocationGrabber, IMessageField, @@ -125,11 +126,13 @@ open class HashtagPostViewModel : var accountViewModel: AccountViewModel? = null var account: Account? = null - var externalIdentity by mutableStateOf(null) + var externalIdentity by mutableStateOf(null) var replyingTo: Note? by mutableStateOf(null) val iMetaAttachments = IMetaAttachments() - var nip95attachments by mutableStateOf>>(emptyList()) + var nip95attachments by mutableStateOf>>( + emptyList(), + ) var notifying by mutableStateOf?>(null) @@ -192,7 +195,7 @@ open class HashtagPostViewModel : this.emojiSuggestions = EmojiSuggestionState(accountVM) } - fun newPostFor(externalIdentity: HashtagId) { + fun newPostFor(externalIdentity: ExternalId) { this.externalIdentity = externalIdentity } @@ -220,10 +223,10 @@ open class HashtagPostViewModel : open fun reply(post: Note) { val accountViewModel = accountViewModel ?: return val noteEvent = post.event as? CommentEvent ?: return - val hashtag = noteEvent.hashtagScope() ?: return + val scope = noteEvent.scope() ?: return this.replyingTo = post - this.externalIdentity = HashtagId(hashtag) + this.externalIdentity = scope } open fun quote(quote: Note) { @@ -260,8 +263,8 @@ open class HashtagPostViewModel : } private fun loadFromDraft(draftEvent: CommentEvent) { - val hashtag = draftEvent.hashtagScope() ?: return - this.externalIdentity = HashtagId(hashtag) + val scope = draftEvent.scope() ?: return + this.externalIdentity = scope canAddInvoice = accountViewModel?.userProfile()?.info?.lnAddress() != null canAddZapRaiser = accountViewModel?.userProfile()?.info?.lnAddress() != null @@ -371,7 +374,7 @@ open class HashtagPostViewModel : if (replyingTo != null) { val eventHint = replyingTo.toEventHint() ?: return null - CommentEvent.replyBuilder( + CommentEvent.Companion.replyBuilder( msg = tagger.message, replyingTo = eventHint, ) { @@ -391,7 +394,7 @@ open class HashtagPostViewModel : } } else { val externalIdentity = externalIdentity ?: return null - CommentEvent.replyExternalIdentity( + CommentEvent.Companion.replyExternalIdentity( msg = tagger.message, extId = externalIdentity, ) { @@ -416,11 +419,16 @@ open class HashtagPostViewModel : fun findEmoji( message: String, - myEmojiSet: List?, + myEmojiSet: List?, ): List { if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) } + return CustomEmoji.Companion.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { + EmojiUrlTag( + it.code, + it.link.url, + ) + } } } @@ -443,7 +451,7 @@ open class HashtagPostViewModel : viewModelScope, alt, contentWarningReason, - MediaCompressor.intToCompressorQuality(mediaQuality), + MediaCompressor.Companion.intToCompressorQuality(mediaQuality), server, myAccount, context, @@ -489,7 +497,14 @@ open class HashtagPostViewModel : multiOrchestrator = null } else { - val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + val errorMessages = + results.errors.map { + stringRes( + context, + it.errorResource, + *it.params, + ) + }.distinct() onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } @@ -604,7 +619,7 @@ open class HashtagPostViewModel : draftTag.newVersion() } - open fun autocompleteWithEmoji(item: EmojiMedia) { + open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" message = message.replaceCurrentWord(wordToInsert) @@ -615,13 +630,13 @@ open class HashtagPostViewModel : draftTag.newVersion() } - open fun autocompleteWithEmojiUrl(item: EmojiMedia) { + open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) { val wordToInsert = item.link.url + " " viewModelScope.launch(Dispatchers.IO) { iMetaAttachments.downloadAndPrepare( item.link.url, - { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload(item.link.url) ?: false) }, + { Amethyst.Companion.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload(item.link.url) ?: false) }, ) } @@ -686,5 +701,5 @@ open class HashtagPostViewModel : return location!! } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): LocationState = Amethyst.Companion.instance.locationManager } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt new file mode 100644 index 000000000..c285b862d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2025 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.note.nip22Comments + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId + +@Composable +fun DisplayExternalId( + externalId: ExternalId, + nav: INav, +) { + when (externalId) { + is GeohashId -> DisplayGeohashExternalId(externalId, nav) + is HashtagId -> DisplayHashtagExternalId(externalId, nav) + else -> {} + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt new file mode 100644 index 000000000..4f6a96383 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2025 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.note.nip22Comments + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId + +@Composable +fun DisplayGeohashExternalId( + externalId: GeohashId, + nav: INav, +) { + Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + LoadCityName(externalId.geohash) { cityName -> + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = stringRes(id = R.string.geohash_exclusive), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(StdHorzSpacer) + + Text( + text = + buildAnnotatedString { + withLink( + LinkAnnotation.Clickable("cityname") { nav.nav(Route.Geohash(externalId.geohash)) }, + ) { + append(cityName) + } + }, + style = + LocalTextStyle.current.copy( + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt new file mode 100644 index 000000000..8105c2b29 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2025 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.note.nip22Comments + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId + +@Composable +fun DisplayHashtagExternalId( + externalId: HashtagId, + nav: INav, +) { + Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Tag, + contentDescription = stringRes(id = R.string.hashtag_exclusive), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(StdHorzSpacer) + + Text( + text = + buildAnnotatedString { + withLink( + LinkAnnotation.Clickable("hashtag") { nav.nav(Route.Hashtag(externalId.topic)) }, + ) { + append(externalId.topic) + } + }, + style = + LocalTextStyle.current.copy( + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 27798439c..b4d4fc8ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds import android.net.Uri import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -35,8 +34,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api @@ -60,7 +57,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -81,8 +77,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField -import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrl -import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrlFillWidth +import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription @@ -96,13 +91,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.FillWidthQuoteBorderModifier -import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding -import com.vitorpamplona.amethyst.ui.theme.Height100Modifier import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.SquaredQuoteBorderModifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -283,7 +274,7 @@ private fun NewProductBody( MessageField(R.string.description, postViewModel) } - DisplayPreviews(postViewModel, accountViewModel, nav) + DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) if (postViewModel.wantsToMarkAsSensitive) { Row( @@ -403,33 +394,6 @@ private fun NewProductBody( } } -@Composable -fun DisplayPreviews( - postViewModel: NewProductViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val urlPreviews by postViewModel.urlPreviews.results.collectAsStateWithLifecycle(emptyList()) - - if (urlPreviews.isNotEmpty()) { - Row(HalfHorzPadding) { - if (urlPreviews.size > 1) { - LazyRow(Height100Modifier, horizontalArrangement = spacedBy(Size5dp)) { - items(urlPreviews) { - Box(SquaredQuoteBorderModifier) { - PreviewUrl(it, accountViewModel, nav) - } - } - } - } else { - Box(FillWidthQuoteBorderModifier) { - PreviewUrlFillWidth(urlPreviews[0], accountViewModel, nav) - } - } - } - } -} - @Composable private fun BottomRowActions(postViewModel: NewProductViewModel) { val scrollState = rememberScrollState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt similarity index 83% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt index d132f35c8..af09d7acb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash import android.net.Uri import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -35,21 +34,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -59,13 +51,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -75,9 +62,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton -import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Nav -import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer @@ -86,10 +71,8 @@ import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSug import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest -import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField -import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrl -import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrlFillWidth +import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription @@ -98,19 +81,17 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel +import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.Notifying import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.FillWidthQuoteBorderModifier -import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding -import com.vitorpamplona.amethyst.ui.theme.Height100Modifier import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.SquaredQuoteBorderModifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -122,7 +103,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable -fun GeoPostScreen( +fun GeoHashPostScreen( geohash: String? = null, message: String? = null, attachment: Uri? = null, @@ -132,7 +113,7 @@ fun GeoPostScreen( accountViewModel: AccountViewModel, nav: Nav, ) { - val postViewModel: GeoPostViewModel = viewModel() + val postViewModel: CommentPostViewModel = viewModel() postViewModel.init(accountViewModel) val context = LocalContext.current @@ -168,7 +149,7 @@ fun GeoPostScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun NewGeoPostScreenInner( - postViewModel: GeoPostViewModel, + postViewModel: CommentPostViewModel, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -261,7 +242,7 @@ fun NewGeoPostScreenInner( @Composable private fun NewGeoPostBody( - postViewModel: GeoPostViewModel, + postViewModel: CommentPostViewModel, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -280,7 +261,7 @@ private fun NewGeoPostBody( Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { postViewModel.externalIdentity?.let { Row { - DisplayExternalId(it, accountViewModel, nav) + DisplayExternalId(it, nav) Spacer(modifier = StdVertSpacer) } } @@ -321,7 +302,7 @@ private fun NewGeoPostBody( ) } - DisplayPreviews(postViewModel, accountViewModel, nav) + DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) if (postViewModel.wantsToMarkAsSensitive) { Row( @@ -432,43 +413,7 @@ private fun NewGeoPostBody( } @Composable -fun DisplayExternalId( - externalId: GeohashId, - accountViewModel: AccountViewModel, - nav: INav, -) { - Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { - LoadCityName(externalId.geohash) { cityName -> - Icon( - imageVector = Icons.Default.LocationOn, - contentDescription = stringRes(id = R.string.geohash_exclusive), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - - Spacer(StdHorzSpacer) - - Text( - text = - buildAnnotatedString { - withLink( - LinkAnnotation.Clickable("cityname") { nav.nav(Route.Geohash(externalId.geohash)) }, - ) { - append(cityName) - } - }, - style = - LocalTextStyle.current.copy( - fontWeight = FontWeight.Bold, - ), - maxLines = 1, - ) - } - } -} - -@Composable -private fun BottomRowActions(postViewModel: GeoPostViewModel) { +private fun BottomRowActions(postViewModel: CommentPostViewModel) { val scrollState = rememberScrollState() Row( modifier = @@ -517,30 +462,3 @@ private fun BottomRowActions(postViewModel: GeoPostViewModel) { } } } - -@Composable -fun DisplayPreviews( - postViewModel: GeoPostViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val urlPreviews by postViewModel.urlPreviews.results.collectAsStateWithLifecycle(emptyList()) - - if (urlPreviews.isNotEmpty()) { - Row(HalfHorzPadding) { - if (urlPreviews.size > 1) { - LazyRow(Height100Modifier, horizontalArrangement = spacedBy(Size5dp)) { - items(urlPreviews) { - Box(SquaredQuoteBorderModifier) { - PreviewUrl(it, accountViewModel, nav) - } - } - } - } else { - Box(FillWidthQuoteBorderModifier) { - PreviewUrlFillWidth(urlPreviews[0], accountViewModel, nav) - } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostViewModel.kt deleted file mode 100644 index 106eb7d50..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostViewModel.kt +++ /dev/null @@ -1,663 +0,0 @@ -/** - * Copyright (c) 2025 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.screen.loggedIn.geohash - -import android.content.Context -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.compose.currentWord -import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor -import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia -import com.vitorpamplona.amethyst.service.uploads.MediaCompressor -import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator -import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger -import com.vitorpamplona.amethyst.ui.actions.UserSuggestionAnchor -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing -import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState -import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField -import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState -import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent -import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris -import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes -import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip22Comments.notify -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag -import com.vitorpamplona.quartz.nip30CustomEmoji.emojis -import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning -import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId -import com.vitorpamplona.quartz.nip73ExternalIds.location.geohashedScope -import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder -import com.vitorpamplona.quartz.nip92IMeta.imetas -import com.vitorpamplona.quartz.nip94FileMetadata.alt -import com.vitorpamplona.quartz.nip94FileMetadata.blurhash -import com.vitorpamplona.quartz.nip94FileMetadata.dims -import com.vitorpamplona.quartz.nip94FileMetadata.hash -import com.vitorpamplona.quartz.nip94FileMetadata.magnet -import com.vitorpamplona.quartz.nip94FileMetadata.mimeType -import com.vitorpamplona.quartz.nip94FileMetadata.originalHash -import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent -import com.vitorpamplona.quartz.nip94FileMetadata.size -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -@Stable -open class GeoPostViewModel : - ViewModel(), - IMessageField, - IZapField, - IZapRaiser { - val draftTag = DraftTagState() - - init { - viewModelScope.launch(Dispatchers.IO) { - draftTag.versions.collectLatest { - sendDraftSync() - } - } - } - - var accountViewModel: AccountViewModel? = null - var account: Account? = null - - var externalIdentity by mutableStateOf(null) - var replyingTo: Note? by mutableStateOf(null) - - val iMetaAttachments = IMetaAttachments() - var nip95attachments by mutableStateOf>>(emptyList()) - - var notifying by mutableStateOf?>(null) - - override var message by mutableStateOf(TextFieldValue("")) - - val urlPreviews = PreviewState() - - var isUploadingImage by mutableStateOf(false) - - var userSuggestions: UserSuggestionState? = null - var userSuggestionsMainMessage: UserSuggestionAnchor? = null - - var emojiSuggestions: EmojiSuggestionState? = null - - // Images and Videos - var multiOrchestrator by mutableStateOf(null) - - // Invoices - var canAddInvoice by mutableStateOf(false) - var wantsInvoice by mutableStateOf(false) - - var wantsSecretEmoji by mutableStateOf(false) - - // Forward Zap to - var wantsForwardZapTo by mutableStateOf(false) - override var forwardZapTo = mutableStateOf>(SplitBuilder()) - override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) - - // NSFW, Sensitive - var wantsToMarkAsSensitive by mutableStateOf(false) - - // ZapRaiser - var canAddZapRaiser by mutableStateOf(false) - var wantsZapraiser by mutableStateOf(false) - override val zapRaiserAmount = mutableStateOf(null) - - var showRelaysDialog by mutableStateOf(false) - var relayList by mutableStateOf?>(null) - - fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() - - fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null - - fun user(): User? = account?.userProfile() - - open fun init(accountVM: AccountViewModel) { - this.accountViewModel = accountVM - this.account = accountVM.account - this.canAddInvoice = hasLnAddress() - this.canAddZapRaiser = hasLnAddress() - - this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM) - - this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM) - } - - fun newPostFor(externalIdentity: GeohashId) { - this.externalIdentity = externalIdentity - } - - fun editFromDraft(draft: Note) { - val accountViewModel = accountViewModel ?: return - - val noteEvent = draft.event - val noteAuthor = draft.author - - if (noteEvent is DraftEvent && noteAuthor != null) { - viewModelScope.launch(Dispatchers.IO) { - accountViewModel.createTempDraftNote(noteEvent) { innerNote -> - if (innerNote != null) { - val oldTag = (draft.event as? AddressableEvent)?.dTag() - if (oldTag != null) { - draftTag.set(oldTag) - } - loadFromDraft(innerNote) - } - } - } - } - } - - open fun reply(post: Note) { - val accountViewModel = accountViewModel ?: return - val noteEvent = post.event as? CommentEvent ?: return - val geohash = noteEvent.geohashedScope() ?: return - - this.replyingTo = post - this.externalIdentity = GeohashId(geohash) - } - - open fun quote(quote: Note) { - val accountViewModel = accountViewModel ?: return - - message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") - - quote.author?.let { quotedUser -> - if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { - if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) { - forwardZapTo.value.addItem(quotedUser) - } - if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { - forwardZapTo.value.addItem(accountViewModel.userProfile()) - } - - val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex } - forwardZapTo.value.updatePercentage(pos, 0.9f) - } - } - - if (!forwardZapTo.value.items.isEmpty()) { - wantsForwardZapTo = true - } - - urlPreviews.update(message) - } - - private fun loadFromDraft(draft: Note) { - val draftEvent = draft.event ?: return - if (draftEvent !is CommentEvent) return - - loadFromDraft(draftEvent) - } - - private fun loadFromDraft(draftEvent: CommentEvent) { - val geohash = draftEvent.geohashedScope() ?: return - this.externalIdentity = GeohashId(geohash) - - canAddInvoice = accountViewModel?.userProfile()?.info?.lnAddress() != null - canAddZapRaiser = accountViewModel?.userProfile()?.info?.lnAddress() != null - multiOrchestrator = null - - val localfowardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } - forwardZapTo.value = SplitBuilder() - localfowardZapTo.forEach { - val user = LocalCache.getOrCreateUser(it[1]) - val value = it.last().toFloatOrNull() ?: 0f - forwardZapTo.value.addItem(user, value) - } - forwardZapToEditting.value = TextFieldValue("") - wantsForwardZapTo = localfowardZapTo.isNotEmpty() - - wantsToMarkAsSensitive = draftEvent.isSensitive() - - val zapraiser = draftEvent.zapraiserAmount() - wantsZapraiser = zapraiser != null - zapRaiserAmount.value = null - if (zapraiser != null) { - zapRaiserAmount.value = zapraiser - } - - draftEvent.replyingTo()?.let { - replyingTo = LocalCache.getOrCreateNote(it) - } - - notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + - draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } - - if (forwardZapTo.value.items.isNotEmpty()) { - wantsForwardZapTo = true - } - - message = TextFieldValue(draftEvent.content) - - iMetaAttachments.addAll(draftEvent.imetas()) - - urlPreviews.update(message) - } - - suspend fun sendPostSync() { - val template = createTemplate() ?: return - val relayList = relayList - - if (nip95attachments.isNotEmpty() && relayList != null) { - val usedImages = template.tags.taggedQuoteIds().toSet() - nip95attachments.forEach { - if (usedImages.contains(it.second.id) == true) { - account?.sendNip95Privately(it.first, it.second, relayList) - } - } - } - - accountViewModel?.account?.signAndSendPrivatelyOrBroadcast( - template, - relayList = { relayList }, - ) - - accountViewModel?.deleteDraft(draftTag.current) - - cancel() - } - - suspend fun sendDraftSync() { - val accountViewModel = accountViewModel ?: return - - if (message.text.isBlank()) { - accountViewModel.account.deleteDraft(draftTag.current) - } else { - val template = createTemplate() ?: return - accountViewModel.account.createAndSendDraft(draftTag.current, template) - - nip95attachments.forEach { - account?.sendToPrivateOutboxAndLocal(it.first) - account?.sendToPrivateOutboxAndLocal(it.second) - } - } - } - - private suspend fun createTemplate(): EventTemplate? { - val accountViewModel = accountViewModel ?: return null - - val tagger = - NewMessageTagger( - message = message.text, - dao = accountViewModel, - ) - tagger.run() - - val emojis = findEmoji(tagger.message, account?.emoji?.myEmojis?.value) - val urls = findURLs(tagger.message) - val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - - val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null - val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null - val contentWarningReason = if (wantsToMarkAsSensitive) "" else null - - val replyingTo = replyingTo - - val template = - if (replyingTo != null) { - val eventHint = replyingTo.toEventHint() ?: return null - - CommentEvent.replyBuilder( - msg = tagger.message, - replyingTo = eventHint, - ) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } else { - val externalIdentity = externalIdentity ?: return null - CommentEvent.replyExternalIdentity( - msg = tagger.message, - extId = externalIdentity, - ) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } - - return template - } - - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) } - } - } - - fun upload( - alt: String?, - contentWarningReason: String?, - mediaQuality: Int, - server: ServerName, - onError: (title: String, message: String) -> Unit, - context: Context, - ) { - viewModelScope.launch(Dispatchers.Default) { - val myAccount = account ?: return@launch - val myMultiOrchestrator = multiOrchestrator ?: return@launch - - isUploadingImage = true - - val results = - myMultiOrchestrator.upload( - viewModelScope, - alt, - contentWarningReason, - MediaCompressor.intToCompressorQuality(mediaQuality), - server, - myAccount, - context, - ) - - if (results.allGood) { - results.successful.forEach { - if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, contentWarningReason) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } - - note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - urlPreviews.update(message) - } - } - } else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - val iMeta = - IMetaTagBuilder(it.result.url) - .apply { - hash(it.result.fileHeader.hash) - size(it.result.fileHeader.size) - it.result.fileHeader.mimeType - ?.let { mimeType(it) } - it.result.fileHeader.dim - ?.let { dims(it) } - it.result.fileHeader.blurHash - ?.let { blurhash(it.blurhash) } - it.result.magnet?.let { magnet(it) } - it.result.uploadedHash?.let { originalHash(it) } - - alt?.let { alt(it) } - contentWarningReason?.let { sensitiveContent(contentWarningReason) } - }.build() - - iMetaAttachments.replace(iMeta.url, iMeta) - - message = message.insertUrlAtCursor(it.result.url) - urlPreviews.update(message) - } - } - - multiOrchestrator = null - } else { - val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() - - onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) - } - - isUploadingImage = false - } - } - - open fun cancel() { - message = TextFieldValue("") - - replyingTo = null - externalIdentity = null - - multiOrchestrator = null - isUploadingImage = false - - notifying = null - - wantsInvoice = false - wantsZapraiser = false - zapRaiserAmount.value = null - - wantsForwardZapTo = false - wantsToMarkAsSensitive = false - wantsSecretEmoji = false - - forwardZapTo.value = SplitBuilder() - forwardZapToEditting.value = TextFieldValue("") - - urlPreviews.reset() - - userSuggestions?.reset() - userSuggestionsMainMessage = null - - iMetaAttachments.reset() - - emojiSuggestions?.reset() - - showRelaysDialog = false - - reloadRelaySet() - - draftTag.rotate() - } - - fun reloadRelaySet() { - val account = accountViewModel?.account ?: return - - val nip65 = account.normalizedNIP65WriteRelayList.value - val private = account.normalizedPrivateOutBoxRelaySet.value - val local = account.settings.localRelayServers - - relayList = - if (nip65.isEmpty()) { - account.activeWriteRelays().map { it.url }.toImmutableList() - } else { - val combined: Set = (nip65 + private + local) - combined.toImmutableList() - } - } - - fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) - } - - open fun removeFromReplyList(userToRemove: User) { - notifying = notifying?.filter { it != userToRemove } - } - - override fun updateMessage(it: TextFieldValue) { - message = it - urlPreviews.update(message) - - if (message.selection.collapsed) { - val lastWord = message.currentWord() - - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE - userSuggestions?.processCurrentWord(lastWord) - - emojiSuggestions?.processCurrentWord(lastWord) - } - - draftTag.newVersion() - } - - override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { - forwardZapToEditting.value = newZapForwardTo - if (newZapForwardTo.selection.collapsed) { - val lastWord = newZapForwardTo.text - userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS - userSuggestions?.processCurrentWord(lastWord) - } - } - - open fun autocompleteWithUser(item: User) { - userSuggestions?.let { userSuggestions -> - if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { - val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) - } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { - forwardZapTo.value.addItem(item) - forwardZapToEditting.value = TextFieldValue("") - } - - userSuggestionsMainMessage = null - userSuggestions.reset() - } - - draftTag.newVersion() - } - - open fun autocompleteWithEmoji(item: EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) - - emojiSuggestions?.reset() - - draftTag.newVersion() - } - - open fun autocompleteWithEmojiUrl(item: EmojiMedia) { - val wordToInsert = item.link.url + " " - - viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare( - item.link.url, - { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload(item.link.url) ?: false) }, - ) - } - - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) - - emojiSuggestions?.reset() - - draftTag.newVersion() - } - - fun canPost(): Boolean = - message.text.isNotBlank() && - !isUploadingImage && - !wantsInvoice && - (!wantsZapraiser || zapRaiserAmount.value != null) && - multiOrchestrator == null - - fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) - } - - fun selectImage(uris: ImmutableList) { - multiOrchestrator = MultiOrchestrator(uris) - } - - override fun updateZapPercentage( - index: Int, - sliderValue: Float, - ) { - forwardZapTo.value.updatePercentage(index, sliderValue) - } - - override fun updateZapFromText() { - viewModelScope.launch(Dispatchers.Default) { - val tagger = - NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) - tagger.run() - tagger.pTags?.forEach { taggedUser -> - if (!forwardZapTo.value.items.any { it.key == taggedUser }) { - forwardZapTo.value.addItem(taggedUser) - } - } - } - } - - override fun updateZapRaiserAmount(newAmount: Long?) { - zapRaiserAmount.value = newAmount - draftTag.newVersion() - } - - fun toggleMarkAsSensitive() { - wantsToMarkAsSensitive = !wantsToMarkAsSensitive - draftTag.newVersion() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt index 0c2c893b4..e788dc5d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag import android.net.Uri import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -35,21 +34,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Tag import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -59,13 +51,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -75,9 +62,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton -import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Nav -import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer @@ -89,8 +74,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField -import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrl -import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrlFillWidth +import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription @@ -99,19 +83,17 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel +import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.Notifying import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.FillWidthQuoteBorderModifier -import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding -import com.vitorpamplona.amethyst.ui.theme.Height100Modifier import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.SquaredQuoteBorderModifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -133,7 +115,7 @@ fun HashtagPostScreen( accountViewModel: AccountViewModel, nav: Nav, ) { - val postViewModel: HashtagPostViewModel = viewModel() + val postViewModel: CommentPostViewModel = viewModel() postViewModel.init(accountViewModel) val context = LocalContext.current @@ -169,7 +151,7 @@ fun HashtagPostScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun HashtagPostScreenInner( - postViewModel: HashtagPostViewModel, + postViewModel: CommentPostViewModel, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -262,7 +244,7 @@ fun HashtagPostScreenInner( @Composable private fun HashtagPostBody( - postViewModel: HashtagPostViewModel, + postViewModel: CommentPostViewModel, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -281,7 +263,7 @@ private fun HashtagPostBody( Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { postViewModel.externalIdentity?.let { Row { - DisplayExternalId(it, accountViewModel, nav) + DisplayExternalId(it, nav) Spacer(modifier = StdVertSpacer) } } @@ -322,7 +304,7 @@ private fun HashtagPostBody( ) } - DisplayPreviews(postViewModel, accountViewModel, nav) + DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) if (postViewModel.wantsToMarkAsSensitive) { Row( @@ -442,41 +424,7 @@ private fun HashtagPostBody( } @Composable -fun DisplayExternalId( - externalId: HashtagId, - accountViewModel: AccountViewModel, - nav: INav, -) { - Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.Tag, - contentDescription = stringRes(id = R.string.hashtag_exclusive), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - - Spacer(StdHorzSpacer) - - Text( - text = - buildAnnotatedString { - withLink( - LinkAnnotation.Clickable("hashtag") { nav.nav(Route.Hashtag(externalId.topic)) }, - ) { - append(externalId.topic) - } - }, - style = - LocalTextStyle.current.copy( - fontWeight = FontWeight.Bold, - ), - maxLines = 1, - ) - } -} - -@Composable -private fun BottomRowActions(postViewModel: HashtagPostViewModel) { +private fun BottomRowActions(postViewModel: CommentPostViewModel) { val scrollState = rememberScrollState() Row( modifier = @@ -529,30 +477,3 @@ private fun BottomRowActions(postViewModel: HashtagPostViewModel) { } } } - -@Composable -fun DisplayPreviews( - postViewModel: HashtagPostViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val urlPreviews by postViewModel.urlPreviews.results.collectAsStateWithLifecycle(emptyList()) - - if (urlPreviews.isNotEmpty()) { - Row(HalfHorzPadding) { - if (urlPreviews.size > 1) { - LazyRow(Height100Modifier, horizontalArrangement = spacedBy(Size5dp)) { - items(urlPreviews) { - Box(SquaredQuoteBorderModifier) { - PreviewUrl(it, accountViewModel, nav) - } - } - } - } else { - Box(FillWidthQuoteBorderModifier) { - PreviewUrlFillWidth(urlPreviews[0], accountViewModel, nav) - } - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt index 54104b2c6..0aaf57f23 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip22Comments.tags.ReplyAddressTag @@ -44,6 +45,7 @@ import com.vitorpamplona.quartz.nip22Comments.tags.RootIdentifierTag import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @@ -172,10 +174,18 @@ class CommentEvent( ) = eventTemplate(KIND, msg, createdAt) { alt(ALT + extId.toScope()) - rootExternalIdentity(extId) + if (extId is GeohashId) { + GeoHashTag.geoMipMap(extId.geohash).forEach { rootExternalIdentity(GeohashId(it, extId.hint)) } + } else { + rootExternalIdentity(extId) + } rootKind(extId) - replyExternalIdentity(extId) + if (extId is GeohashId) { + GeoHashTag.geoMipMap(extId.geohash).forEach { replyExternalIdentity(GeohashId(it, extId.hint)) } + } else { + replyExternalIdentity(extId) + } replyKind(extId) initializer() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt index 8a82bf113..ddc263e61 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt @@ -48,7 +48,9 @@ fun TagArrayBuilder.rootEvent( pubkey: String?, ) = addUnique(RootEventTag.assemble(eventId, relayHint, pubkey)) -fun TagArrayBuilder.rootExternalIdentity(id: ExternalId) = addAll(RootIdentifierTag.assemble(id)) +fun TagArrayBuilder.rootExternalIdentity(id: ExternalId) = add(RootIdentifierTag.assemble(id)) + +fun TagArrayBuilder.rootExternalIdentities(ids: List) = addAll(ids.map { RootIdentifierTag.assemble(it) }) fun TagArrayBuilder.rootKind(kind: String) = addUnique(RootKindTag.assemble(kind)) @@ -72,7 +74,9 @@ fun TagArrayBuilder.replyEvent( pubkey: String?, ) = addUnique(ReplyEventTag.assemble(eventId, relayHint, pubkey)) -fun TagArrayBuilder.replyExternalIdentity(id: ExternalId) = addAll(ReplyIdentifierTag.assemble(id)) +fun TagArrayBuilder.replyExternalIdentity(id: ExternalId) = add(ReplyIdentifierTag.assemble(id)) + +fun TagArrayBuilder.replyExternalIdentities(ids: List) = addAll(ids.map { ReplyIdentifierTag.assemble(it) }) fun TagArrayBuilder.replyKind(kind: String) = addUnique(ReplyKindTag.assemble(kind)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt index 77c94fcba..2c06d9faa 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt @@ -23,9 +23,17 @@ package com.vitorpamplona.quartz.nip22Comments.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip46RemoteSigner.getOrNull import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.books.BookId import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.movies.MovieId +import com.vitorpamplona.quartz.nip73ExternalIds.papers.PaperId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastEpisodeId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastFeedId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastPublisherId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -82,6 +90,26 @@ class ReplyIdentifierTag { return tag[1] } + @JvmStatic + fun parseExternalId(tag: Tag): ExternalId? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == RootIdentifierTag.Companion.TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val value = tag[1] + val hint = tag.getOrNull(2) + + return BookId.parse(value, hint) + ?: HashtagId.parse(value, hint) + ?: GeohashId.parse(value, hint) + ?: MovieId.parse(value, hint) + ?: PaperId.parse(value, hint) + ?: PodcastEpisodeId.parse(value, hint) + ?: PodcastFeedId.parse(value, hint) + ?: PodcastPublisherId.parse(value, hint) + ?: UrlId.parse(value, hint) + } + @JvmStatic fun assemble( identity: String, @@ -89,10 +117,6 @@ class ReplyIdentifierTag { ) = arrayOfNotNull(TAG_NAME, identity, hint) @JvmStatic - fun assemble(id: ExternalId): List> = - when (id) { - is GeohashId -> GeoHashTag.geoMipMap(id.geohash).map { assemble(GeohashId.toScope(it), id.hint) } - else -> listOf(assemble(id.toScope(), id.hint())) - } + fun assemble(id: ExternalId): Array = assemble(id.toScope(), id.hint()) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt index 86cd6e5c6..db5d43c61 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt @@ -23,9 +23,17 @@ package com.vitorpamplona.quartz.nip22Comments.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip46RemoteSigner.getOrNull import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.books.BookId import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.movies.MovieId +import com.vitorpamplona.quartz.nip73ExternalIds.papers.PaperId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastEpisodeId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastFeedId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastPublisherId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -81,6 +89,26 @@ class RootIdentifierTag { return tag[1] } + @JvmStatic + fun parseExternalId(tag: Tag): ExternalId? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val value = tag[1] + val hint = tag.getOrNull(2) + + return BookId.parse(value, hint) + ?: HashtagId.parse(value, hint) + ?: GeohashId.parse(value, hint) + ?: MovieId.parse(value, hint) + ?: PaperId.parse(value, hint) + ?: PodcastEpisodeId.parse(value, hint) + ?: PodcastFeedId.parse(value, hint) + ?: PodcastPublisherId.parse(value, hint) + ?: UrlId.parse(value, hint) + } + @JvmStatic fun assemble( identity: String, @@ -88,10 +116,6 @@ class RootIdentifierTag { ) = arrayOfNotNull(TAG_NAME, identity, hint) @JvmStatic - fun assemble(id: ExternalId): List> = - when (id) { - is GeohashId -> GeoHashTag.geoMipMap(id.geohash).map { assemble(GeohashId.toScope(it), id.hint) } - else -> listOf(assemble(id.toScope(), id.hint())) - } + fun assemble(id: ExternalId): Array = assemble(id.toScope(), id.hint()) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/CommentEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/CommentEventExt.kt new file mode 100644 index 000000000..77167578f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/CommentEventExt.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2025 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.quartz.nip73ExternalIds + +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip22Comments.tags.RootIdentifierTag +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId + +fun CommentEvent.scopes() = tags.mapNotNull { RootIdentifierTag.parseExternalId(it) } + +fun CommentEvent.scope(): ExternalId? { + val scopes = scopes() + + if (scopes.isEmpty()) return null + + return if (scopes.first() is GeohashId) { + scopes.maxByOrNull { if (it is GeohashId) it.geohash else "" } + } else { + scopes().first() + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/BookId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/BookId.kt index cad727838..f0fdf4f13 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/BookId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/BookId.kt @@ -54,5 +54,13 @@ class BookId( ensure(encoded.startsWith(PREFIX_COLON)) { return null } return encoded.substring(PREFIX_COLON.length) } + + fun parse( + encoded: String, + hint: String?, + ): BookId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return BookId(encoded.substring(PREFIX_COLON.length), hint) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt index fa666c723..10a354623 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt @@ -53,5 +53,13 @@ class GeohashId( ensure(encoded.startsWith(PREFIX_COLON)) { return null } return encoded.substring(PREFIX_COLON.length) } + + fun parse( + encoded: String, + hint: String?, + ): GeohashId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return GeohashId(encoded.substring(PREFIX_COLON.length), hint) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt index 91843e388..04babfa41 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt @@ -80,5 +80,13 @@ class MovieId( ensure(encoded.startsWith(PREFIX_COLON)) { return null } return encoded.substring(PREFIX_COLON.length) } + + fun parse( + encoded: String, + hint: String?, + ): MovieId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return MovieId(encoded.substring(PREFIX_COLON.length), hint) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt index 810353689..633bcb7bf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt @@ -54,5 +54,13 @@ class PaperId( ensure(encoded.startsWith(PREFIX_COLON)) { return null } return encoded.substring(PREFIX_COLON.length) } + + fun parse( + encoded: String, + hint: String?, + ): PaperId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return PaperId(encoded.substring(PREFIX_COLON.length), hint) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt index 7f019c121..df0b00014 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt @@ -53,5 +53,13 @@ class PodcastEpisodeId( ensure(encoded.startsWith(PREFIX_COLON)) { return null } return encoded.substring(PREFIX_COLON.length) } + + fun parse( + encoded: String, + hint: String?, + ): PodcastEpisodeId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return PodcastEpisodeId(encoded.substring(PREFIX_COLON.length), hint) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt index 39c6e03cf..a63c794f3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt @@ -54,5 +54,13 @@ class PodcastFeedId( ensure(encoded.startsWith(PREFIX_COLON)) { return null } return encoded.substring(PREFIX_COLON.length) } + + fun parse( + encoded: String, + hint: String?, + ): PodcastFeedId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return PodcastFeedId(encoded.substring(PREFIX_COLON.length), hint) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt index 907b290ac..e2ebc1b01 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt @@ -54,5 +54,13 @@ class PodcastPublisherId( ensure(encoded.startsWith(PREFIX_COLON)) { return null } return encoded.substring(PREFIX_COLON.length) } + + fun parse( + encoded: String, + hint: String?, + ): PodcastPublisherId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return PodcastPublisherId(encoded.substring(PREFIX_COLON.length), hint) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt index 38897df8e..50b4be0a3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt @@ -77,5 +77,16 @@ class HashtagId( null } } + + fun parse( + encoded: String, + hint: String?, + ): HashtagId? { + return if (encoded.length > 1 && encoded[0] == KIND_CHR) { + HashtagId(encoded.substring(1), hint) + } else { + null + } + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt index 8d1393cad..9b51ac785 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt @@ -56,5 +56,13 @@ class UrlId( ensure(encoded.startsWith(PREFIX1) || encoded.startsWith(PREFIX2)) { return null } return encoded } + + fun parse( + encoded: String, + hint: String?, + ): UrlId? { + ensure(encoded.startsWith(PREFIX1) || encoded.startsWith(PREFIX2)) { return null } + return UrlId(encoded, hint) + } } }