Normalizes NIP-22 viewmodel to its own entity

This commit is contained in:
Vitor Pamplona
2025-05-20 14:23:51 -04:00
parent bb1dfee35b
commit 195e268865
24 changed files with 519 additions and 922 deletions
@@ -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<Route.EventRedirect> { LoadRedirectScreen(it.id, accountViewModel, nav) }
composableFromBottomArgs<Route.GeoPost> {
GeoPostScreen(
GeoHashPostScreen(
geohash = it.geohash,
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
@@ -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)
}
}
}
}
}
@@ -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<HashtagId?>(null)
var externalIdentity by mutableStateOf<ExternalId?>(null)
var replyingTo: Note? by mutableStateOf<Note?>(null)
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(
emptyList(),
)
var notifying by mutableStateOf<List<User>?>(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<Event>() ?: 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<EmojiMedia>?,
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
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
}
@@ -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 -> {}
}
}
@@ -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,
)
}
}
}
@@ -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,
)
}
}
@@ -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()
@@ -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)
}
}
}
}
}
@@ -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<GeohashId?>(null)
var replyingTo: Note? by mutableStateOf<Note?>(null)
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var notifying by mutableStateOf<List<User>?>(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<MultiOrchestrator?>(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<User>>(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<Long?>(null)
var showRelaysDialog by mutableStateOf(false)
var relayList by mutableStateOf<ImmutableList<String>?>(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<out Event>? {
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<Event>() ?: 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<EmojiMedia>?,
): List<EmojiUrlTag> {
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<String> = (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<SelectedMedia>) {
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()
}
}
@@ -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)
}
}
}
}
}
@@ -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()
@@ -48,7 +48,9 @@ fun TagArrayBuilder<CommentEvent>.rootEvent(
pubkey: String?,
) = addUnique(RootEventTag.assemble(eventId, relayHint, pubkey))
fun TagArrayBuilder<CommentEvent>.rootExternalIdentity(id: ExternalId) = addAll(RootIdentifierTag.assemble(id))
fun TagArrayBuilder<CommentEvent>.rootExternalIdentity(id: ExternalId) = add(RootIdentifierTag.assemble(id))
fun TagArrayBuilder<CommentEvent>.rootExternalIdentities(ids: List<ExternalId>) = addAll(ids.map { RootIdentifierTag.assemble(it) })
fun TagArrayBuilder<CommentEvent>.rootKind(kind: String) = addUnique(RootKindTag.assemble(kind))
@@ -72,7 +74,9 @@ fun TagArrayBuilder<CommentEvent>.replyEvent(
pubkey: String?,
) = addUnique(ReplyEventTag.assemble(eventId, relayHint, pubkey))
fun TagArrayBuilder<CommentEvent>.replyExternalIdentity(id: ExternalId) = addAll(ReplyIdentifierTag.assemble(id))
fun TagArrayBuilder<CommentEvent>.replyExternalIdentity(id: ExternalId) = add(ReplyIdentifierTag.assemble(id))
fun TagArrayBuilder<CommentEvent>.replyExternalIdentities(ids: List<ExternalId>) = addAll(ids.map { ReplyIdentifierTag.assemble(it) })
fun TagArrayBuilder<CommentEvent>.replyKind(kind: String) = addUnique(ReplyKindTag.assemble(kind))
@@ -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<Array<String>> =
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<String> = assemble(id.toScope(), id.hint())
}
}
@@ -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<T : ExternalId> {
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<T : ExternalId> {
) = arrayOfNotNull(TAG_NAME, identity, hint)
@JvmStatic
fun assemble(id: ExternalId): List<Array<String>> =
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<String> = assemble(id.toScope(), id.hint())
}
}
@@ -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()
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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
}
}
}
}
@@ -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)
}
}
}