Merge branch 'vitorpamplona:main' into profiles-list-management

This commit is contained in:
KotlinGeekDev
2025-03-14 23:18:09 +00:00
committed by GitHub
214 changed files with 2620 additions and 948 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+4
View File
@@ -65,6 +65,10 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
@@ -142,6 +142,7 @@ class Amethyst : Application() {
RECEIVER_EXPORTED,
)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(
pokeyReceiver,
IntentFilter(PokeyReceiver.POKEY_ACTION),
@@ -150,6 +150,7 @@ import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
@@ -1618,18 +1619,18 @@ class Account(
suspend fun report(
note: Note,
type: ReportEvent.ReportType,
type: ReportType,
content: String = "",
) {
if (!isWriteable()) return
if (note.hasReacted(userProfile(), "⚠️")) {
// has already liked this note
if (note.hasReport(userProfile(), type)) {
// has already reported this note
return
}
note.event?.let {
ReportEvent.create(it, type, signer, content) {
signer.sign(ReportEvent.build(it, type)) {
Amethyst.instance.client.send(it)
LocalCache.justConsume(it, null)
}
@@ -1638,7 +1639,7 @@ class Account(
suspend fun report(
user: User,
type: ReportEvent.ReportType,
type: ReportType,
) {
if (!isWriteable()) return
@@ -1647,7 +1648,8 @@ class Account(
return
}
ReportEvent.create(user.pubkeyHex, type, signer) {
val template = ReportEvent.build(user.pubkeyHex, type)
signer.sign(template) {
Amethyst.instance.client.send(it)
LocalCache.justConsume(it, null)
}
@@ -695,8 +695,8 @@ object LocalCache {
event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
is ReportEvent ->
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } +
event.reportedAddresses().map { getOrCreateAddressableNote(it.address) }
is ChannelMessageEvent ->
event
.tagsWithoutCitations()
@@ -1342,7 +1342,7 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) }
val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) }
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
@@ -2231,7 +2231,7 @@ object LocalCache {
}
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().mapNotNull {
val author = getUserIfExists(it.key)
val author = getUserIfExists(it.pubkey)
author?.removeReport(note)
author?.clearEOSE()
}
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import android.util.LruCache
object MediaAspectRatioCache {
val mediaAspectRatioCacheByUrl = LruCache<String, Float>(1000)
fun get(url: String) = mediaAspectRatioCacheByUrl.get(url)
fun add(
url: String,
width: Int,
height: Int,
) {
if (height > 1) {
mediaAspectRatioCacheByUrl.put(url, width.toFloat() / height.toFloat())
}
}
}
@@ -68,6 +68,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
@@ -717,6 +719,15 @@ open class Note(
)
}
fun hasReport(
loggedIn: User,
type: ReportType,
): Boolean =
reports[loggedIn]?.firstOrNull {
it.event is ReportEvent &&
(it.event as ReportEvent).reportedAuthor().any { it.type == type }
} != null
fun hasPledgeBy(user: User): Boolean =
replies
.filter { it.event?.hasAdditionalReward() ?: false }
@@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.DualCase
@@ -363,11 +364,11 @@ class User(
fun hasReport(
loggedIn: User,
type: ReportEvent.ReportType,
type: ReportType,
): Boolean =
reports[loggedIn]?.firstOrNull {
it.event is ReportEvent &&
(it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
(it.event as ReportEvent).reportedAuthor().any { it.type == type }
} != null
fun containsAny(hiddenWordsCase: List<DualCase>): Boolean {
@@ -41,6 +41,12 @@ object CachedRichTextParser {
return result
}
fun cachedText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri)]
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.lnurl
import android.content.Context
import android.util.Log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
@@ -166,12 +167,24 @@ class LightningAddressResolver {
response: Response,
context: Context,
): String {
val body = response.body.string()
val errorMessage =
runCatching {
jacksonObjectMapper().readTree(response.body.string())
jacksonObjectMapper().readTree(body)
}.getOrNull()?.let { tree ->
val status = tree.get("status")?.asText()
val message = tree.get("message")?.asText()
val errorNode = tree.get("error")
val messageNode = tree.get("message")
val statusNode = tree.get("status")
if (tree.get("error").isBoolean && messageNode != null) {
if (errorNode.asBoolean() == true) {
return messageNode.asText()
}
}
val status = statusNode?.asText()
val message = messageNode?.asText()
if (status == "error" && message != null) {
message
@@ -180,6 +193,10 @@ class LightningAddressResolver {
}
}
if (errorMessage == null) {
Log.d("LightningAddressResolver", "Error parsing LNResponse: $body")
}
return errorMessage
?: HttpStatusMessages.resourceIdFor(response.code)?.let { stringRes(context, it) }
?: response.message.ifBlank { null }
@@ -30,9 +30,11 @@ import androidx.media3.common.Player
import androidx.media3.common.Player.PositionInfo
import androidx.media3.common.Player.STATE_IDLE
import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.MainActivity
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -113,6 +115,10 @@ class MultiPlayerPlaybackManager(
// avoids saving positions for live streams otherwise caching goes crazy
val mustCachePositions = !uri.contains(".m3u8", true)
override fun onVideoSizeChanged(videoSize: VideoSize) {
MediaAspectRatioCache.add(uri, videoSize.width, videoSize.height)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
player.setWakeMode(C.WAKE_MODE_NETWORK)
@@ -331,7 +331,7 @@ fun EditPostView(
it,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context)
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
}
@@ -365,7 +365,7 @@ fun EditPostView(
postViewModel.wantsInvoice = false
},
onClose = { postViewModel.wantsInvoice = false },
onError = { title, message -> accountViewModel.toast(title, message) },
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
}
}
@@ -140,7 +140,7 @@ fun NewMediaView(
PostButton(
onPost = {
postViewModel.upload(context, relayList, onClose, accountViewModel::toast)
postViewModel.upload(context, relayList, onClose, accountViewModel.toastManager::toast)
postViewModel.selectedServer?.let {
if (it.type != ServerType.NIP95) {
account.settings.changeDefaultFileServer(it)
@@ -198,7 +198,7 @@ fun NewUserMetadataScreen(
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 5.dp),
) {
postViewModel.uploadForPicture(it, context, onError = accountViewModel::toast)
postViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast)
}
},
singleLine = true,
@@ -223,7 +223,7 @@ fun NewUserMetadataScreen(
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 5.dp),
) {
postViewModel.uploadForBanner(it, context, onError = accountViewModel::toast)
postViewModel.uploadForBanner(it, context, onError = accountViewModel.toastManager::toast)
}
},
singleLine = true,
@@ -218,7 +218,7 @@ fun RelaySelectionDialog(
)
}
accountViewModel.toast(
accountViewModel.toastManager.toast(
stringRes(context, R.string.unable_to_download_relay_document),
msg,
)
@@ -103,7 +103,7 @@ fun BasicRelaySetupInfoDialog(
)
}
accountViewModel.toast(
accountViewModel.toastManager.toast(
stringRes(context, R.string.unable_to_download_relay_document),
msg,
)
@@ -280,7 +280,7 @@ fun LoadRelayInfo(
)
}
accountViewModel.toast(
accountViewModel.toastManager.toast(
stringRes(context, R.string.unable_to_download_relay_document),
msg,
)
@@ -105,7 +105,7 @@ fun Kind3RelaySetupInfoProposalDialog(
)
}
accountViewModel.toast(
accountViewModel.toastManager.toast(
stringRes(context, R.string.unable_to_download_relay_document),
msg,
)
@@ -64,7 +64,7 @@ fun RelayStatusRow(
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_from_relay,
R.string.read_from_relay_description,
)
@@ -91,7 +91,7 @@ fun RelayStatusRow(
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.write_to_relay,
R.string.write_to_relay_description,
)
@@ -118,7 +118,7 @@ fun RelayStatusRow(
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.errors,
R.string.errors_description,
)
@@ -150,7 +150,7 @@ fun RelayStatusRow(
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.spam,
R.string.spam_description,
)
@@ -116,7 +116,7 @@ fun CashuPreview(
CashuPreviewNew(
it,
accountViewModel::meltCashu,
accountViewModel::toast,
accountViewModel.toastManager::toast,
)
}
}
@@ -59,6 +59,7 @@ val DefaultAnimationColors =
@Composable
fun LoadingAnimation(
indicatorSize: Dp = 20.dp,
circleWidth: Dp = 4.dp,
circleColors: ImmutableList<Color> = DefaultAnimationColors,
animationDuration: Int = 1000,
) {
@@ -86,7 +87,7 @@ fun LoadingAnimation(
.size(size = indicatorSize)
.rotate(degrees = rotateAnimation)
.border(
width = 4.dp,
width = circleWidth,
brush = Brush.sweepGradient(circleColors),
shape = CircleShape,
),
@@ -530,20 +530,22 @@ fun DisplaySecretEmoji(
) {
if (canPreview && quotesLeft > 0) {
var secretContent by remember {
mutableStateOf<RichTextViewerState?>(null)
mutableStateOf(CachedRichTextParser.cachedText(EmojiCoder.decode(segment.segmentText), state.tags))
}
var showPopup by remember {
mutableStateOf(false)
}
LaunchedEffect(segment) {
launch(Dispatchers.Default) {
secretContent =
CachedRichTextParser.parseText(
EmojiCoder.decode(segment.segmentText),
state.tags,
)
if (secretContent == null) {
LaunchedEffect(segment) {
launch(Dispatchers.Default) {
secretContent =
CachedRichTextParser.parseText(
EmojiCoder.decode(segment.segmentText),
state.tags,
)
}
}
}
@@ -98,6 +98,7 @@ import com.linc.audiowaveform.infiniteLinearGradient
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
@@ -261,7 +262,8 @@ fun VideoView(
}
if (blurhash == null) {
val ratio = dimensions?.aspectRatio()
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
val modifier =
if (ratio != null && automaticallyStartPlayback.value) {
Modifier.aspectRatio(ratio)
@@ -295,7 +297,7 @@ fun VideoView(
}
}
} else {
val ratio = dimensions?.aspectRatio()
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
val modifier =
if (ratio != null) {
@@ -1233,10 +1235,10 @@ private fun saveMediaToGallery(
mimeType = mimeType,
localContext = localContext,
onSuccess = {
accountViewModel.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery)
accountViewModel.toastManager.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery)
},
onError = {
accountViewModel.toast(R.string.failed_to_save_the_video, null, it)
accountViewModel.toastManager.toast(R.string.failed_to_save_the_video, null, it)
},
)
}
@@ -312,10 +312,10 @@ private fun saveMediaToGallery(
forceProxy = useTor,
localContext,
onSuccess = {
accountViewModel.toast(success, success)
accountViewModel.toastManager.toast(success, success)
},
onError = {
accountViewModel.toast(failure, null, it)
accountViewModel.toastManager.toast(failure, null, it)
},
)
} else if (content is MediaPreloadedContent) {
@@ -325,10 +325,10 @@ private fun saveMediaToGallery(
content.mimeType,
localContext,
onSuccess = {
accountViewModel.toast(success, success)
accountViewModel.toastManager.toast(success, success)
},
onError = { innerIt ->
accountViewModel.toast(failure, null, innerIt)
accountViewModel.toastManager.toast(failure, null, innerIt)
},
)
}
@@ -43,6 +43,7 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -77,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.Blurhash
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
@@ -87,10 +89,13 @@ import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size6dp
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark
import com.vitorpamplona.amethyst.ui.theme.imageModifier
@@ -132,7 +137,6 @@ fun ZoomableContentView(
.fillMaxWidth()
.clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
}
}
@@ -235,7 +239,7 @@ fun LocalImageView(
)
}
val ratio = remember(content) { aspectRatio(content.dim) }
val ratio = remember(content) { content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.localFile.toString()) }
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
if (it) {
SubcomposeAsyncImage(
@@ -265,8 +269,14 @@ fun LocalImageView(
)
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content)
if (ratio != null) {
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
LoadingAnimation(Size40dp, Size6dp)
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content)
}
}
}
}
@@ -276,6 +286,11 @@ fun LocalImageView(
is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent(loadedImageModifier)
SideEffect {
val drawable = (state as AsyncImagePainter.State.Success).result.image
MediaAspectRatioCache.add(content.localFile.toString(), drawable.width, drawable.height)
}
content.isVerified?.let {
AnimatedVisibility(
visible = controllerVisible.value,
@@ -328,7 +343,7 @@ fun UrlImageView(
accountViewModel: AccountViewModel,
alwayShowImage: Boolean = false,
) {
val ratio = content.dim?.aspectRatio()
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
val showImage =
remember {
@@ -373,8 +388,14 @@ fun UrlImageView(
)
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content)
if (ratio != null) {
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
LoadingAnimation(Size40dp, Size6dp)
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content)
}
}
}
}
@@ -385,6 +406,11 @@ fun UrlImageView(
SubcomposeAsyncImageContent(loadedImageModifier)
ShowHashAnimated(content, controllerVisible, Modifier.align(Alignment.TopEnd))
SideEffect {
val drawable = (state as AsyncImagePainter.State.Success).result.image
MediaAspectRatioCache.add(content.url, drawable.width, drawable.height)
}
}
else -> {}
}
@@ -519,12 +545,6 @@ fun ShowHash(content: MediaUrlContent) {
verifiedHash?.let { HashVerificationSymbol(it) }
}
fun aspectRatio(dim: DimensionTag?): Float? {
if (dim == null) return null
return dim.width.toFloat() / dim.height.toFloat()
}
@Composable
fun WaitAndDisplay(
content:
@@ -706,7 +726,7 @@ fun ShareImageAction(
val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent
if (n19 != null) {
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType) // TODO Whole list or first?
accountViewModel.toast(R.string.media_added, R.string.media_added_to_profile_gallery)
accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery)
}
}
@@ -18,20 +18,24 @@
* 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.components
package com.vitorpamplona.amethyst.ui.components.toasts
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.MultiErrorToastMsg
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.MultiUserErrorMessageDialog
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ResourceToastMsg
import com.vitorpamplona.amethyst.ui.screen.loggedIn.StringToastMsg
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThrowableToastMsg
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun DisplayErrorMessages(accountViewModel: AccountViewModel) {
val openDialogMsg = accountViewModel.toasts.collectAsStateWithLifecycle(null)
fun DisplayErrorMessages(
toastManager: ToastManager,
accountViewModel: AccountViewModel,
nav: INav,
) {
val openDialogMsg = toastManager.toasts.collectAsStateWithLifecycle(null)
openDialogMsg.value?.let { obj ->
when (obj) {
@@ -41,14 +45,14 @@ fun DisplayErrorMessages(accountViewModel: AccountViewModel) {
stringRes(obj.titleResId),
stringRes(obj.resourceId, *obj.params),
) {
accountViewModel.clearToasts()
toastManager.clearToasts()
}
} else {
InformationDialog(
stringRes(obj.titleResId),
stringRes(obj.resourceId),
) {
accountViewModel.clearToasts()
toastManager.clearToasts()
}
}
@@ -57,7 +61,7 @@ fun DisplayErrorMessages(accountViewModel: AccountViewModel) {
obj.title,
obj.msg,
) {
accountViewModel.clearToasts()
toastManager.clearToasts()
}
is ThrowableToastMsg ->
@@ -66,8 +70,10 @@ fun DisplayErrorMessages(accountViewModel: AccountViewModel) {
obj.msg,
obj.throwable,
) {
accountViewModel.clearToasts()
toastManager.clearToasts()
}
is MultiErrorToastMsg -> MultiUserErrorMessageDialog(obj, accountViewModel, nav)
}
}
}
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.toasts
import androidx.compose.runtime.Immutable
@Immutable
class ResourceToastMsg(
val titleResId: Int,
val resourceId: Int,
val params: Array<out String>? = null,
) : ToastMsg()
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.toasts
import androidx.compose.runtime.Immutable
@Immutable
class StringToastMsg(
val title: String,
val msg: String,
) : ToastMsg()
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.toasts
import androidx.compose.runtime.Immutable
@Immutable
class ThrowableToastMsg(
val titleResId: Int,
val msg: String? = null,
val throwable: Throwable,
) : ToastMsg()
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.toasts
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.MultiErrorToastMsg
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import kotlinx.coroutines.flow.MutableStateFlow
class ToastManager {
val toasts = MutableStateFlow<ToastMsg?>(null)
fun clearToasts() {
toasts.tryEmit(null)
}
fun toast(
title: String,
message: String,
) {
toasts.tryEmit(StringToastMsg(title, message))
}
fun toast(
titleResId: Int,
resourceId: Int,
) {
toasts.tryEmit(ResourceToastMsg(titleResId, resourceId))
}
fun toast(
titleResId: Int,
message: String?,
throwable: Throwable,
) {
toasts.tryEmit(ThrowableToastMsg(titleResId, message, throwable))
}
fun toast(
titleResId: Int,
resourceId: Int,
vararg params: String,
) {
toasts.tryEmit(ResourceToastMsg(titleResId, resourceId, params))
}
fun toast(
titleResId: Int,
message: String,
user: User?,
) {
val current = toasts.value
if (current is MultiErrorToastMsg && current.titleResId == titleResId) {
current.add(message, user)
} else {
toasts.tryEmit(MultiErrorToastMsg(titleResId).also { it.add(message, user) })
}
}
fun toast(
titleResId: Int,
data: UserBasedErrorMessage,
) {
val current = toasts.value
if (current is MultiErrorToastMsg && current.titleResId == titleResId) {
current.add(data)
} else {
toasts.tryEmit(MultiErrorToastMsg(titleResId).also { it.add(data) })
}
}
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.toasts
import androidx.compose.runtime.Immutable
@Immutable
open class ToastMsg
@@ -18,11 +18,10 @@
* 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
package com.vitorpamplona.amethyst.ui.components.toasts.multiline
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -31,56 +30,44 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@Composable
@Preview
fun MultiUserErrorMessageContentPreview() {
fun ErrorListPreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
var user1: User? = null
var user2: User? = null
@@ -88,107 +75,43 @@ fun MultiUserErrorMessageContentPreview() {
runBlocking {
withContext(Dispatchers.IO) {
user1 = LocalCache.getOrCreateUser("aaabccaabbccaabbcc")
user2 = LocalCache.getOrCreateUser("bbbccabbbccabbbcca")
user3 = LocalCache.getOrCreateUser("ccaadaccaadaccaada")
user1 = LocalCache.getOrCreateUser("aaabccaabbccaabbccabdd")
user2 = LocalCache.getOrCreateUser("bbbccabbbccabbbccaabdd")
user3 = LocalCache.getOrCreateUser("ccaadaccaadaccaadaabdd")
}
}
val model: UserBasedErrorMessageViewModel = viewModel()
val model = MultiErrorToastMsg(R.string.error_dialog_zap_error)
model.add("Could not fetch invoice from https://minibits.cash/.well-known/lnurlp/victorieeman: There are too many unpaid invoices for this name.", user1)
model.add("No Wallets found to pay a lightning invoice. Please install a Lightning wallet to use zaps.", user2)
model.add("Could not fetch invoice", user3)
ThemeComparisonColumn {
MultiUserErrorMessageDialogInner(
title = "Couldn't not zap",
ErrorList(
model = model,
accountViewModel = accountViewModel,
nav = nav,
nav = EmptyNav,
)
}
}
@Stable
class UserBasedErrorMessageViewModel : ViewModel() {
val errors = MutableStateFlow<List<UserBasedErrorMessage>>(emptyList())
val hasErrors = errors.map { it.isNotEmpty() }
fun add(
message: String,
user: User?,
) {
add(UserBasedErrorMessage(message, user))
}
fun add(newError: UserBasedErrorMessage) {
errors.update {
it + newError
}
}
fun clearErrors() {
errors.update {
emptyList()
}
}
}
class UserBasedErrorMessage(
val error: String,
val user: User?,
)
@Composable
fun MultiUserErrorMessageDialog(
title: String,
model: UserBasedErrorMessageViewModel,
fun ErrorList(
model: MultiErrorToastMsg,
accountViewModel: AccountViewModel,
nav: INav,
) {
val hasErrors by model.hasErrors.collectAsStateWithLifecycle(false)
if (hasErrors) {
MultiUserErrorMessageDialogInner(title, model, accountViewModel, nav)
val errorState by model.errors.collectAsStateWithLifecycle()
LazyColumn {
itemsIndexed(errorState) { index, it ->
ErrorRow(it, accountViewModel, nav)
if (index < errorState.size - 1) {
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
@Composable
fun MultiUserErrorMessageDialogInner(
title: String,
model: UserBasedErrorMessageViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
AlertDialog(
onDismissRequest = model::clearErrors,
title = { Text(title) },
text = {
val errorState by model.errors.collectAsStateWithLifecycle(emptyList())
LazyColumn {
itemsIndexed(errorState) { index, it ->
ErrorRow(it, accountViewModel, nav)
if (index < errorState.size - 1) {
HorizontalDivider(thickness = DividerThickness)
}
}
}
},
confirmButton = {
Button(
onClick = model::clearErrors,
contentPadding = PaddingValues(horizontal = Size16dp),
) {
Icon(
imageVector = Icons.Outlined.Done,
contentDescription = null,
)
Spacer(StdHorzSpacer)
Text(stringRes(R.string.error_dialog_button_ok))
}
},
)
}
@Composable
fun ErrorRow(
errorState: UserBasedErrorMessage,
@@ -201,7 +124,7 @@ fun ErrorRow(
) {
errorState.user?.let {
Column(Modifier.width(Size40dp), horizontalAlignment = Alignment.Start) {
UserPicture(errorState.user, Size30dp, Modifier, accountViewModel, nav)
UserPicture(it, Size30dp, Modifier, accountViewModel, nav)
Spacer(StdVertSpacer)
IconButton(
modifier = Size30Modifier,
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.toasts.multiline
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.toasts.ToastMsg
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@Immutable
class MultiErrorToastMsg(
val titleResId: Int,
) : ToastMsg() {
val errors = MutableStateFlow<List<UserBasedErrorMessage>>(emptyList())
fun add(
message: String,
user: User?,
) {
add(UserBasedErrorMessage(message, user))
}
fun add(newError: UserBasedErrorMessage) {
errors.update {
it + newError
}
}
}
class UserBasedErrorMessage(
val error: String,
val user: User?,
)
@@ -0,0 +1,107 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.toasts.multiline
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@Composable
@Preview
fun MultiUserErrorMessageContentPreview() {
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
var user1: User? = null
var user2: User? = null
var user3: User? = null
runBlocking {
withContext(Dispatchers.IO) {
user1 = LocalCache.getOrCreateUser("aaabccaabbccaabbccabdd")
user2 = LocalCache.getOrCreateUser("bbbccabbbccabbbccaabdd")
user3 = LocalCache.getOrCreateUser("ccaadaccaadaccaadaabdd")
}
}
val model = MultiErrorToastMsg(R.string.error_dialog_zap_error)
model.add("Could not fetch invoice from https://minibits.cash/.well-known/lnurlp/victorieeman: There are too many unpaid invoices for this name.", user1)
model.add("No Wallets found to pay a lightning invoice. Please install a Lightning wallet to use zaps.", user2)
model.add("Could not fetch invoice", user3)
ThemeComparisonColumn {
MultiUserErrorMessageDialog(
model = model,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
fun MultiUserErrorMessageDialog(
model: MultiErrorToastMsg,
accountViewModel: AccountViewModel,
nav: INav,
) {
AlertDialog(
onDismissRequest = accountViewModel.toastManager::clearToasts,
title = { Text(stringRes(model.titleResId)) },
text = {
ErrorList(model, accountViewModel, nav)
},
confirmButton = {
Button(
onClick = accountViewModel.toastManager::clearToasts,
contentPadding = PaddingValues(horizontal = Size16dp),
) {
Icon(
imageVector = Icons.Outlined.Done,
contentDescription = null,
)
Spacer(StdHorzSpacer)
Text(stringRes(R.string.error_dialog_button_ok))
}
},
)
}
@@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
open class DiscoverNIP89FeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
val lastAnnounced = 90 * 24 * 60 * 60 // 90 Days ago
val lastAnnounced = 365 * 24 * 60 * 60 // 365 Days ago
// TODO better than announced would be last active, as this requires the DVM provider to regularly update the NIP89 announcement
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList()
@@ -43,8 +43,6 @@ open class DiscoverNIP89FeedFilter(
followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet { _, it ->
acceptDVM(it)
@@ -51,8 +51,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.relays.AllRelayListView
import com.vitorpamplona.amethyst.ui.components.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.components.DisplayNotifyMessages
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
@@ -363,7 +363,7 @@ fun AppNavigation(
NavigateIfIntentRequested(nav, accountViewModel, accountStateViewModel)
DisplayErrorMessages(accountViewModel)
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
DisplayNotifyMessages(accountViewModel, nav)
}
@@ -446,7 +446,7 @@ private fun NavigateIfIntentRequested(
actionableNextPage = null
} else {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.invalid_nip19_uri,
R.string.invalid_nip19_uri_description,
intentNextPage,
@@ -501,7 +501,7 @@ private fun NavigateIfIntentRequested(
} else {
scope.launch {
delay(1000)
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.invalid_nip19_uri,
R.string.invalid_nip19_uri_description,
uri,
@@ -559,7 +559,7 @@ fun ListContent(
accountViewModel.setTorSettings(torSettings)
},
onError = {
accountViewModel.toast(
accountViewModel.toastManager.toast(
stringRes(context, R.string.could_not_connect_to_tor),
it,
)
@@ -64,7 +64,6 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
@@ -251,21 +250,23 @@ fun DisplaySecretEmojiAsReaction(
accountViewModel: AccountViewModel,
nav: INav,
) {
var secretContent by remember {
mutableStateOf<RichTextViewerState?>(null)
var secretContent by remember(reaction) {
mutableStateOf(CachedRichTextParser.cachedText(EmojiCoder.decode(reaction), EmptyTagList))
}
var showPopup by remember {
mutableStateOf(false)
}
LaunchedEffect(reaction) {
launch(Dispatchers.Default) {
secretContent =
CachedRichTextParser.parseText(
EmojiCoder.decode(reaction),
EmptyTagList,
)
if (secretContent == null) {
LaunchedEffect(reaction) {
launch(Dispatchers.Default) {
secretContent =
CachedRichTextParser.parseText(
EmojiCoder.decode(reaction),
EmptyTagList,
)
}
}
}
@@ -76,11 +76,11 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.toasts.StringToastMsg
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.StringToastMsg
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockVitorAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -530,23 +530,23 @@ fun ZapVote(
indication = ripple24dp,
onClick = {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_send_zaps,
)
} else if (pollViewModel.isPollClosed()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.poll_unable_to_vote,
R.string.poll_is_closed_explainer,
)
} else if (isLoggedUser) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.poll_unable_to_vote,
R.string.poll_author_no_vote,
)
} else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn.value) {
// only allow one vote per option when min==max, i.e. atomic vote amount specified
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.poll_unable_to_vote,
R.string.one_vote_per_user_on_atomic_votes,
)
@@ -99,12 +99,12 @@ import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
@@ -662,7 +662,7 @@ fun ReplyReaction(
modifier = iconSizeModifier,
onClick = {
if (baseNote.isDraft()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.draft_note,
R.string.it_s_not_possible_to_reply_to_a_draft_note,
)
@@ -670,7 +670,7 @@ fun ReplyReaction(
if (accountViewModel.isWriteable()) {
onPress()
} else {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_reply,
)
@@ -986,7 +986,7 @@ private fun likeClick(
onWantsToSignReaction: () -> Unit,
) {
if (baseNote.isDraft()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.draft_note,
R.string.it_s_not_possible_to_react_to_a_draft_note,
)
@@ -995,12 +995,12 @@ private fun likeClick(
val choices = accountViewModel.reactionChoices()
if (choices.isEmpty()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.no_reactions_setup,
R.string.no_reaction_type_setup_long_press_to_change,
)
} else if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_like_posts,
)
@@ -1025,7 +1025,6 @@ fun ZapReaction(
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
val errorViewModel: UserBasedErrorMessageViewModel = viewModel()
var wantsToPay by
remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
@@ -1060,7 +1059,7 @@ fun ZapReaction(
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
errorViewModel.add(message, user)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onPayViaIntent = { wantsToPay = it },
@@ -1089,7 +1088,7 @@ fun ZapReaction(
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
errorViewModel.add(message, user)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
@@ -1097,13 +1096,6 @@ fun ZapReaction(
)
}
MultiUserErrorMessageDialog(
title = stringRes(id = R.string.error_dialog_zap_error),
model = errorViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
if (wantsToChangeZapAmount) {
UpdateZapAmountDialog(
onClose = { wantsToChangeZapAmount = false },
@@ -1120,12 +1112,12 @@ fun ZapReaction(
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
errorViewModel.add(it)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
justShowError = {
scope.launch {
errorViewModel.add(it)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
)
@@ -1137,7 +1129,7 @@ fun ZapReaction(
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
errorViewModel.add(message, user)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
@@ -1191,7 +1183,7 @@ fun zapClick(
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
) {
if (baseNote.isDraft()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.draft_note,
R.string.it_s_not_possible_to_zap_to_a_draft_note,
)
@@ -1201,12 +1193,12 @@ fun zapClick(
val choices = accountViewModel.zapAmountChoices()
if (choices.isEmpty()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
R.string.no_zap_amount_setup_long_press_to_change,
)
} else if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
R.string.login_with_a_private_key_to_be_able_to_send_zaps,
)
@@ -169,7 +169,7 @@ fun RenderRelay(
openRelayDialog = true
},
onError = { url, errorCode, exceptionMessage ->
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.unable_to_download_relay_document,
when (errorCode) {
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL ->
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageVie
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import kotlinx.collections.immutable.toImmutableList
@@ -108,11 +109,11 @@ fun ShowEmojiSuggestionList(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(Size10dp),
) {
Box(Modifier.size(40.dp)) {
Box(Size40Modifier) {
UrlImageView(it.url, accountViewModel)
}
Text(it.code, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
Box(Size40Modifier, contentAlignment = Alignment.Center) {
IconButton(
onClick = {
onFullSize(it)
@@ -53,7 +53,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -79,13 +78,10 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.navigation.INav
@@ -380,53 +376,6 @@ private fun RenderReactionOption(
}
}
@Composable
fun DisplaySecretEmoji(
text: String,
state: RichTextViewerState,
callbackUri: String?,
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (canPreview && quotesLeft > 0) {
var secretContent by remember {
mutableStateOf<RichTextViewerState?>(null)
}
var showPopup by remember {
mutableStateOf(false)
}
LaunchedEffect(text) {
launch(Dispatchers.Default) {
secretContent =
CachedRichTextParser.parseText(
EmojiCoder.decode(text),
state.tags,
)
}
}
val localSecretContent = secretContent
AnimatedBorderTextCornerRadius(
text,
Modifier.clickable {
showPopup = !showPopup
},
)
if (localSecretContent != null && showPopup) {
CoreSecretMessage(localSecretContent, callbackUri, quotesLeft, backgroundColor, accountViewModel, nav)
}
} else {
Text(text)
}
}
@Composable
private fun EmojiSelector(
accountViewModel: AccountViewModel,
@@ -337,12 +337,12 @@ fun UpdateZapAmountContent(
postViewModel.updateNIP47(nip47uri)
} catch (e: IllegalArgumentException) {
if (e.message != null) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47, nip47uri, e.message!!),
)
} else {
accountViewModel.toast(
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47_no_error, nip47uri),
)
@@ -510,12 +510,12 @@ fun UpdateZapAmountContent(
postViewModel.updateNIP47(it)
} catch (e: IllegalArgumentException) {
if (e.message != null) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47, it, e.message!!),
)
} else {
accountViewModel.toast(
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47_no_error, it),
)
@@ -592,7 +592,7 @@ fun UpdateZapAmountContent(
onValueChange = { postViewModel.walletConnectSecret = it },
keyboardOptions =
KeyboardOptions(
autoCorrect = false,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
@@ -611,7 +611,7 @@ fun UpdateZapAmountContent(
context = context,
keyguardLauncher = keyguardLauncher,
onApproved = { showPassword = true },
onError = { title, message -> accountViewModel.toast(title, message) },
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
} else {
showPassword = false
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
@@ -201,7 +201,7 @@ fun ShowFollowingOrUnfollowingButton(
if (isFollowing) {
UnfollowButton {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_unfollow,
)
@@ -212,7 +212,7 @@ fun ShowFollowingOrUnfollowingButton(
} else {
FollowButton {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_follow,
)
@@ -75,7 +75,7 @@ fun DisplayOts(
val fullDateTime =
SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000))
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.ots_info_title,
R.string.ots_info_description,
fullDateTime,
@@ -58,7 +58,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
@@ -72,10 +71,8 @@ import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.note.MultiUserErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon
import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog
import com.vitorpamplona.amethyst.ui.note.UserBasedErrorMessageViewModel
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
@@ -293,7 +290,6 @@ fun ZapDonationButton(
nav: INav,
) {
var wantsToZap by remember { mutableStateOf<ImmutableList<Long>?>(null) }
val errorViewModel: UserBasedErrorMessageViewModel = viewModel()
var wantsToPay by
remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
@@ -320,7 +316,7 @@ fun ZapDonationButton(
onError = { _, message, toUser ->
scope.launch {
zappingProgress = 0f
errorViewModel.add(message, toUser)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, toUser)
}
},
onPayViaIntent = { wantsToPay = it },
@@ -344,7 +340,7 @@ fun ZapDonationButton(
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
errorViewModel.add(message, user)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onProgress = {
@@ -354,13 +350,6 @@ fun ZapDonationButton(
)
}
MultiUserErrorMessageDialog(
title = stringRes(id = R.string.error_dialog_zap_error),
model = errorViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(
payingInvoices = wantsToPay,
@@ -370,12 +359,12 @@ fun ZapDonationButton(
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
errorViewModel.add(it)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
justShowError = {
scope.launch {
errorViewModel.add(it)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
)
@@ -441,7 +430,7 @@ fun customZapClick(
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
) {
if (baseNote.isDraft()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.draft_note,
R.string.it_s_not_possible_to_zap_to_a_draft_note,
)
@@ -451,12 +440,12 @@ fun customZapClick(
val choices = accountViewModel.zapAmountChoices()
if (choices.isEmpty()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_dialog_zap_error),
stringRes(context, R.string.no_zap_amount_setup_long_press_to_change),
)
} else if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_dialog_zap_error),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_send_zaps),
)
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
@Composable
fun RenderReport(
@@ -52,16 +53,18 @@ fun RenderReport(
val reportType =
base
.map {
when (it.reportType) {
ReportEvent.ReportType.EXPLICIT -> stringRes(R.string.explicit_content)
ReportEvent.ReportType.NUDITY -> stringRes(R.string.nudity)
ReportEvent.ReportType.PROFANITY -> stringRes(R.string.profanity_hateful_speech)
ReportEvent.ReportType.SPAM -> stringRes(R.string.spam)
ReportEvent.ReportType.IMPERSONATION -> stringRes(R.string.impersonation)
ReportEvent.ReportType.ILLEGAL -> stringRes(R.string.illegal_behavior)
ReportEvent.ReportType.MALWARE -> stringRes(R.string.malware)
ReportEvent.ReportType.MOD -> stringRes(R.string.mod)
ReportEvent.ReportType.OTHER -> stringRes(R.string.other)
when (it.type) {
ReportType.EXPLICIT -> stringRes(R.string.explicit_content)
ReportType.NUDITY -> stringRes(R.string.nudity)
ReportType.PROFANITY -> stringRes(R.string.profanity_hateful_speech)
ReportType.SPAM -> stringRes(R.string.spam)
ReportType.IMPERSONATION -> stringRes(R.string.impersonation)
ReportType.ILLEGAL -> stringRes(R.string.illegal_behavior)
ReportType.MALWARE -> stringRes(R.string.malware)
ReportType.MOD -> stringRes(R.string.mod)
ReportType.OTHER -> stringRes(R.string.other)
ReportType.HARASSMENT -> stringRes(R.string.harassment)
null -> stringRes(R.string.other)
}
}.toSet()
.joinToString(", ")
@@ -221,7 +221,7 @@ fun DisplayFileList(
ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
if (e is CancellationException) throw e
accountViewModel.toast(R.string.torrent_failure, R.string.torrent_no_apps)
accountViewModel.toastManager.toast(R.string.torrent_failure, R.string.torrent_no_apps)
}
}, Modifier.size(Size30dp)) {
DownloadForOfflineIcon(Size20dp, MaterialTheme.colorScheme.onBackground)
@@ -156,7 +156,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
contract = ActivityResultContracts.StartActivityForResult(),
onResult = { result ->
if (result.resultCode != Activity.RESULT_OK) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.sign_request_rejected,
R.string.sign_request_rejected_description,
)
@@ -183,7 +183,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Signer", "Error opening Signer app", e)
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.error_opening_external_signer,
R.string.error_opening_external_signer_description,
)
@@ -203,7 +203,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Signer", "Error opening Signer app", e)
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.error_opening_external_signer,
R.string.error_opening_external_signer_description,
)
@@ -321,7 +321,7 @@ private fun NSecCopyButton(accountViewModel: AccountViewModel) {
context = context,
keyguardLauncher = keyguardLauncher,
onApproved = { copyNSec(context, scope, accountViewModel.account, clipboardManager) },
onError = { title, message -> accountViewModel.toast(title, message) },
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
},
shape = ButtonBorder,
@@ -371,7 +371,7 @@ private fun EncryptNSecCopyButton(
context = context,
keyguardLauncher = keyguardLauncher,
onApproved = { encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager) },
onError = { title, message -> accountViewModel.toast(title, message) },
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
},
shape = ButtonBorder,
@@ -496,7 +496,7 @@ private fun QrCodeButtonBase(
context = context,
keyguardLauncher = keyguardLauncher,
onApproved = { dialogOpen = true },
onError = { title, message -> accountViewModel.toast(title, message) },
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
},
) {
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
@@ -102,7 +103,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
@@ -123,8 +124,6 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -137,25 +136,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Immutable open class ToastMsg
@Immutable class StringToastMsg(
val title: String,
val msg: String,
) : ToastMsg()
@Immutable class ResourceToastMsg(
val titleResId: Int,
val resourceId: Int,
val params: Array<out String>? = null,
) : ToastMsg()
@Immutable class ThrowableToastMsg(
val titleResId: Int,
val msg: String? = null,
val throwable: Throwable,
) : ToastMsg()
@Stable
class AccountViewModel(
accountSettings: AccountSettings,
@@ -189,7 +169,7 @@ class AccountViewModel(
val dmRelays: StateFlow<ChatMessageRelayListEvent?> = observeByAuthor(ChatMessageRelayListEvent.KIND, account.signer.pubKey)
val searchRelays: StateFlow<SearchRelayListEvent?> = observeByAuthor(SearchRelayListEvent.KIND, account.signer.pubKey)
val toasts = MutableSharedFlow<ToastMsg?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val toastManager = ToastManager()
val feedStates = AccountFeedContentStates(this)
@@ -269,40 +249,6 @@ class AccountViewModel(
Route.Notification to notificationHasNewItemsFlow,
)
fun clearToasts() {
viewModelScope.launch { toasts.emit(null) }
}
fun toast(
title: String,
message: String,
) {
viewModelScope.launch { toasts.emit(StringToastMsg(title, message)) }
}
fun toast(
titleResId: Int,
resourceId: Int,
) {
viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId)) }
}
fun toast(
titleResId: Int,
message: String?,
throwable: Throwable,
) {
viewModelScope.launch { toasts.emit(ThrowableToastMsg(titleResId, message, throwable)) }
}
fun toast(
titleResId: Int,
resourceId: Int,
vararg params: String,
) {
viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId, params)) }
}
fun isWriteable(): Boolean = account.isWriteable()
fun userProfile(): User = account.userProfile()
@@ -741,7 +687,7 @@ class AccountViewModel(
fun report(
note: Note,
type: ReportEvent.ReportType,
type: ReportType,
content: String = "",
) {
viewModelScope.launch(Dispatchers.IO) { account.report(note, type, content) }
@@ -749,7 +695,7 @@ class AccountViewModel(
fun report(
user: User,
type: ReportEvent.ReportType,
type: ReportType,
) {
viewModelScope.launch(Dispatchers.IO) {
account.report(user, type)
@@ -1406,7 +1352,7 @@ class AccountViewModel(
onMore: () -> Unit,
) {
if (baseNote.isDraft()) {
toast(
toastManager.toast(
R.string.draft_note,
R.string.it_s_not_possible_to_quote_to_a_draft_note,
)
@@ -1420,7 +1366,7 @@ class AccountViewModel(
onMore()
}
} else {
toast(
toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_boost_posts,
)
@@ -1766,6 +1712,7 @@ fun mockAccountViewModel(): AccountViewModel {
KeyPair(
privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"),
pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"),
forceReplacePubkey = false,
),
),
sharedPreferencesViewModel.sharedPrefs,
@@ -527,7 +527,7 @@ fun NewPostScreen(
it,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, false, server, accountViewModel::toast, context)
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, false, server, accountViewModel.toastManager::toast, context)
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
}
@@ -558,7 +558,7 @@ fun NewPostScreen(
postViewModel.wantsInvoice = false
},
onClose = { postViewModel.wantsInvoice = false },
onError = { title, message -> accountViewModel.toast(title, message) },
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
}
}
@@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@@ -75,13 +75,13 @@ fun ReportNoteDialog(
) {
val reportTypes =
listOf(
Pair(ReportEvent.ReportType.SPAM, stringRes(R.string.report_dialog_spam)),
Pair(ReportEvent.ReportType.PROFANITY, stringRes(R.string.report_dialog_profanity)),
Pair(ReportEvent.ReportType.IMPERSONATION, stringRes(R.string.report_dialog_impersonation)),
Pair(ReportEvent.ReportType.NUDITY, stringRes(R.string.report_dialog_nudity)),
Pair(ReportEvent.ReportType.ILLEGAL, stringRes(R.string.report_dialog_illegal)),
Pair(ReportEvent.ReportType.MALWARE, stringRes(R.string.report_malware)),
Pair(ReportEvent.ReportType.MOD, stringRes(R.string.report_mod)),
Pair(ReportType.SPAM, stringRes(R.string.report_dialog_spam)),
Pair(ReportType.PROFANITY, stringRes(R.string.report_dialog_profanity)),
Pair(ReportType.IMPERSONATION, stringRes(R.string.report_dialog_impersonation)),
Pair(ReportType.NUDITY, stringRes(R.string.report_dialog_nudity)),
Pair(ReportType.ILLEGAL, stringRes(R.string.report_dialog_illegal)),
Pair(ReportType.MALWARE, stringRes(R.string.report_malware)),
Pair(ReportType.MOD, stringRes(R.string.report_mod)),
)
val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() }
@@ -65,7 +65,7 @@ fun RoomChatFileUploadDialog(
},
upload = {
channelScreenModel.upload(
onError = accountViewModel::toast,
onError = accountViewModel.toastManager::toast,
context = context,
onceUploaded = onUpload,
)
@@ -126,7 +126,7 @@ fun ChannelFileUploadDialog(
},
upload = {
channelScreenModel.upload(
onError = accountViewModel::toast,
onError = accountViewModel.toastManager::toast,
context = context,
onceUploaded = onUpload,
)
@@ -47,12 +47,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.launch
@@ -93,7 +93,7 @@ fun MessagesTabHeader(
IconButton(
modifier =
Modifier
.size(40.dp)
.size(Size40dp)
.align(Alignment.CenterEnd),
onClick = { moreActionsExpanded = true },
) {
@@ -70,11 +70,9 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.DVMCard
import com.vitorpamplona.amethyst.ui.note.MultiUserErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon
import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog
import com.vitorpamplona.amethyst.ui.note.UserBasedErrorMessageViewModel
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup
import com.vitorpamplona.amethyst.ui.note.ZapIcon
@@ -442,7 +440,6 @@ fun ZapDVMButton(
val noteAuthor = baseNote.author ?: return
var wantsToZap by remember { mutableStateOf<List<Long>?>(null) }
val errorViewModel: UserBasedErrorMessageViewModel = viewModel()
var wantsToPay by
remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
@@ -472,7 +469,7 @@ fun ZapDVMButton(
onError = { _, message, toUser ->
scope.launch {
zappingProgress = 0f
errorViewModel.add(message, toUser)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, toUser)
}
},
onPayViaIntent = { wantsToPay = it },
@@ -496,7 +493,7 @@ fun ZapDVMButton(
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
errorViewModel.add(message, user)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onProgress = {
@@ -506,13 +503,6 @@ fun ZapDVMButton(
)
}
MultiUserErrorMessageDialog(
title = stringRes(id = R.string.error_dialog_zap_error),
model = errorViewModel,
accountViewModel,
nav,
)
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(
payingInvoices = wantsToPay,
@@ -522,12 +512,12 @@ fun ZapDVMButton(
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
errorViewModel.add(it)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
justShowError = {
scope.launch {
errorViewModel.add(it)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
)
@@ -208,7 +208,7 @@ fun GeoHashActionOptions(
if (isFollowingTag) {
UnfollowButton {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_unfollow,
)
@@ -219,7 +219,7 @@ fun GeoHashActionOptions(
} else {
FollowButton {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_follow,
)
@@ -187,7 +187,7 @@ fun HashtagActionOptions(
if (isFollowingTag) {
UnfollowButton {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_unfollow,
)
@@ -198,7 +198,7 @@ fun HashtagActionOptions(
} else {
FollowButton {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_follow,
)
@@ -56,12 +56,11 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
import com.vitorpamplona.amethyst.ui.components.DisplayUrlWithLoadingSymbol
import com.vitorpamplona.amethyst.ui.components.GetMediaItem
import com.vitorpamplona.amethyst.ui.components.GetVideoController
import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.WatchAuthor
@@ -217,8 +216,8 @@ fun UrlImageView(
defaultModifier,
)
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content)
Box(defaultModifier, contentAlignment = Alignment.Center) {
LoadingAnimation()
}
}
}
@@ -56,7 +56,7 @@ fun DisplayFollowUnfollowButton(
if (isLoggedInFollowingUser) {
UnfollowButton {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_unfollow,
)
@@ -68,7 +68,7 @@ fun DisplayFollowUnfollowButton(
if (isUserFollowingLoggedIn) {
FollowButton(R.string.follow_back) {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_follow,
)
@@ -79,7 +79,7 @@ fun DisplayFollowUnfollowButton(
} else {
FollowButton(R.string.follow) {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_follow,
)
@@ -130,7 +130,7 @@ fun DisplayLNAddress(
}
},
onClose = { zapExpanded = false },
onError = { title, message -> accountViewModel.toast(title, message) },
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
}
}
@@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.ui.note.externalLinkForUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
@Composable
fun UserProfileDropDownMenu(
@@ -108,42 +108,42 @@ fun UserProfileDropDownMenu(
DropdownMenuItem(
text = { Text(stringRes(id = R.string.report_spam_scam)) },
onClick = {
accountViewModel.report(user, ReportEvent.ReportType.SPAM)
accountViewModel.report(user, ReportType.SPAM)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.report_hateful_speech)) },
onClick = {
accountViewModel.report(user, ReportEvent.ReportType.PROFANITY)
accountViewModel.report(user, ReportType.PROFANITY)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(id = R.string.report_impersonation)) },
onClick = {
accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION)
accountViewModel.report(user, ReportType.IMPERSONATION)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.report_nudity_porn)) },
onClick = {
accountViewModel.report(user, ReportEvent.ReportType.NUDITY)
accountViewModel.report(user, ReportType.NUDITY)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(id = R.string.report_illegal_behaviour)) },
onClick = {
accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL)
accountViewModel.report(user, ReportType.ILLEGAL)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(id = R.string.report_malware)) },
onClick = {
accountViewModel.report(user, ReportEvent.ReportType.MALWARE)
accountViewModel.report(user, ReportType.MALWARE)
onDismiss()
},
)
@@ -331,7 +331,7 @@ fun MutedWordActionOptions(
if (isMutedWord == true) {
ShowWordButton {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_show_word,
)
@@ -342,7 +342,7 @@ fun MutedWordActionOptions(
} else {
HideWordButton {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_hide_word,
)
@@ -393,7 +393,7 @@ private fun hideIfWritable(
currentWordToAdd: MutableState<String>,
) {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_hide_word,
)
@@ -99,9 +99,12 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size0dp
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size50dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.tor.TorSettings
@@ -200,7 +203,7 @@ fun LoginPage(
contentScale = ContentScale.Inside,
)
Spacer(modifier = Modifier.height(40.dp))
Spacer(modifier = Modifier.height(Size40dp))
KeyTextField(
value = key.value,
@@ -334,9 +337,9 @@ fun LoginPage(
}
}
Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = Modifier.height(Size10dp))
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
LoginButton(
enabled = acceptedTerms.value,
processingLogin = processingLogin,
@@ -371,7 +374,7 @@ fun LoginPage(
}
if (PackageUtils.isExternalSignerInstalled(context)) {
Box(modifier = Modifier.padding(40.dp, 20.dp, 40.dp, 0.dp)) {
Box(modifier = Modifier.padding(Size40dp, Size20dp, Size40dp, Size0dp)) {
LoginWithAmberButton(
enabled = acceptedTerms.value,
onClick = {
@@ -551,7 +554,7 @@ fun KeyTextField(
onValueChange = { onValueChange(it, false) },
keyboardOptions =
KeyboardOptions(
autoCorrect = false,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
@@ -777,11 +780,11 @@ fun LoginWithAmberButton(
enabled = enabled,
onClick = onClick,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(50.dp),
modifier = Modifier.height(Size50dp),
) {
Text(
text = stringRes(R.string.login_with_external_signer),
modifier = Modifier.padding(horizontal = 40.dp),
modifier = Modifier.padding(horizontal = Size40dp),
)
}
}
@@ -796,9 +799,9 @@ fun LoginButton(
enabled = enabled,
onClick = onClick,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(50.dp),
modifier = Modifier.height(Size50dp),
) {
Row(modifier = Modifier.padding(horizontal = 40.dp)) {
Row(modifier = Modifier.padding(horizontal = Size40dp)) {
if (processingLogin) {
LoadingAnimation()
Spacer(modifier = DoubleHorzSpacer)
@@ -86,6 +86,7 @@ val TopBarSize = 50.dp - 64.dp
val Size0dp = 0.dp
val Size5dp = 5.dp
val Size6dp = 6.dp
val Size10dp = 10.dp
val Size12dp = 12.dp
val Size13dp = 13.dp
+1
View File
@@ -21,6 +21,7 @@
<string name="impersonation">Impersonation</string>
<string name="illegal_behavior">Illegal Behavior</string>
<string name="other">Other</string>
<string name="harassment">Harassment</string>
<string name="unknown">Unknown</string>
<string name="relay_icon">Relay Icon</string>
<string name="unknown_author">Unknown Author</string>
+7 -7
View File
@@ -1,7 +1,7 @@
[versions]
accompanistAdaptive = "0.37.2"
activityCompose = "1.10.1"
agp = "8.8.2"
agp = "8.9.0"
android-compileSdk = "35"
android-minSdk = "26"
android-targetSdk = "35"
@@ -13,10 +13,10 @@ benchmark = "1.3.3"
benchmarkJunit4 = "1.3.3"
biometricKtx = "1.2.0-alpha05"
coil = "3.1.0"
composeBom = "2025.02.00"
composeBom = "2025.03.00"
coreKtx = "1.15.0"
espressoCore = "3.6.1"
firebaseBom = "33.9.0"
firebaseBom = "33.10.0"
fragmentKtx = "1.8.6"
gms = "4.4.2"
jacksonModuleKotlin = "2.18.2"
@@ -33,9 +33,9 @@ lifecycleRuntimeKtx = "2.8.7"
lightcompressor = "1.3.2"
markdown = "077a2cde64"
media3 = "1.5.1"
mockk = "1.13.16"
mockk = "1.13.17"
kotlinx-coroutines-test = "1.10.1"
navigationCompose = "2.8.8"
navigationCompose = "2.8.9"
okhttp = "5.0.0-alpha.14"
runner = "1.6.2"
rfc3986 = "0.1.2"
@@ -46,9 +46,9 @@ torAndroid = "0.4.8.12"
translate = "17.0.3"
unifiedpush = "2.3.1"
urlDetector = "0.1.23"
vico-charts = "2.0.1"
vico-charts = "2.0.3"
zelory = "3.0.1"
zoomable = "2.1.0"
zoomable = "2.3.0"
zxing = "3.5.3"
zxingAndroidEmbedded = "4.3.0"
windowCoreAndroid = "1.3.0"
+1 -1
View File
@@ -1,6 +1,6 @@
#Wed Jan 04 09:23:50 EST 2023
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.audio.header.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class DownloadUrlTag {
companion object {
const val TAG_NAME = "download_url"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.audio.header.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class StreamUrlTag {
companion object {
const val TAG_NAME = "stream_url"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -21,7 +21,9 @@
package com.vitorpamplona.quartz.experimental.audio.header.tags
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper
import com.vitorpamplona.quartz.utils.ensure
class WaveformTag(
val wave: List<Int>,
@@ -30,12 +32,12 @@ class WaveformTag(
companion object {
const val TAG_NAME = "waveform"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): WaveformTag? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
if (tag[1].isEmpty()) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val wave = runCatching { EventMapper.mapper.readValue<List<Int>>(tag[1]) }.getOrNull()
if (wave.isNullOrEmpty()) return null
return WaveformTag(wave)
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.audio.track.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class CoverTag {
companion object {
const val TAG_NAME = "cover"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.audio.track.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class MediaTag {
companion object {
const val TAG_NAME = "media"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -23,9 +23,10 @@ package com.vitorpamplona.quartz.experimental.audio.track.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.isNotName
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@Immutable
data class ParticipantTag(
@@ -36,26 +37,27 @@ data class ParticipantTag(
companion object {
const val TAG_NAME = "p"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Tag): ParticipantTag? {
if (tag.isNotName(TAG_NAME, TAG_SIZE)) return null
if (tag[1].length != 64) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return ParticipantTag(tag[1], tag.getOrNull(2))
}
@JvmStatic
fun parseKey(tag: Tag): String? {
if (tag.isNotName(TAG_NAME, TAG_SIZE)) return null
if (tag[1].length != 64) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
@JvmStatic
fun assemble(
pubkey: HexKey,
relayHint: String?,
relayHint: String? = null,
) = arrayOfNotNull(TAG_NAME, pubkey, relayHint)
}
}
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.audio.track.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class PriceTag {
companion object {
const val TAG_NAME = "price"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.audio.track.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class TypeTag {
companion object {
const val TAG_NAME = "c"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -20,16 +20,19 @@
*/
package com.vitorpamplona.quartz.experimental.bounties
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
import java.math.BigDecimal
class RewardTag {
companion object {
const val TAG_NAME = "reward"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): BigDecimal? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return runCatching { BigDecimal(tag[1]) }.getOrNull()
}
@@ -0,0 +1,112 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decoupling
import com.vitorpamplona.quartz.experimental.decoupling.setup.EncryptionKeyListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip44Encryption.Nip44
class DecoupledCipher {
fun innerEncrypt(
content: String,
privKey: ByteArray,
toPublicKey: HexKey,
) = Nip44
.encrypt(content, privKey, toPublicKey.hexToByteArray())
.encodePayload()
fun innerDecrypt(
ciphertext: String,
privKey: ByteArray,
fromPublicKey: HexKey,
) = Nip44.decrypt(
payload = ciphertext,
privateKey = privKey,
pubKey = fromPublicKey.hexToByteArray(),
)
fun encrypt(
decryptedContent: String,
toPublicKey: HexKey,
fromKeyList: EncryptionKeyListEvent,
toKeyList: EncryptionKeyListEvent,
signer: NostrSigner,
onReady: (String) -> Unit,
) {
val toKeys = toKeyList.keys()
val sendToKey = if (toKeys.isEmpty()) toKeyList.pubKey else toKeys.random().pubkey
val fromKeys = fromKeyList.keys()
// uses the main key
if (fromKeys.isEmpty()) {
signer.nip44Encrypt(decryptedContent, sendToKey, onReady)
} else {
val keyToUse = fromKeys.random()
EncryptionKeyCache.getOrLoad(
deriveFromPubKey = signer.pubKey,
nonce = keyToUse.nonce,
load = { onLoaded ->
signer.deriveKey(keyToUse.nonce) { newPrivKey ->
onLoaded(newPrivKey.hexToByteArray())
}
},
) { derivedPrivKey ->
onReady(innerEncrypt(decryptedContent, derivedPrivKey, sendToKey))
}
}
}
fun decrypt(
encryptedContent: String,
fromPublicKey: HexKey,
toPublicKey: HexKey,
fromKeyList: EncryptionKeyListEvent,
toEncryptedKeyList: EncryptionKeyListEvent,
signer: NostrSigner,
onReady: (String) -> Unit,
) {
val fromKeys = fromKeyList.keys()
val sentFromKey = if (fromKeys.isEmpty()) fromKeyList.pubKey else fromKeys.random().pubkey
val keyToUse = toEncryptedKeyList.keys().firstOrNull { it.pubkey == toPublicKey }
// uses the main key
if (signer.pubKey == toPublicKey) {
signer.nip44Decrypt(encryptedContent, sentFromKey, onReady)
} else if (keyToUse != null) {
EncryptionKeyCache.getOrLoad(
deriveFromPubKey = signer.pubKey,
nonce = keyToUse.nonce,
load = { onLoaded ->
signer.deriveKey(keyToUse.nonce) { newPrivKey ->
onLoaded(newPrivKey.hexToByteArray())
}
},
) { derivedPrivKey ->
innerDecrypt(encryptedContent, derivedPrivKey, sentFromKey)?.let { onReady(it) }
}
}
}
}
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decoupling
import android.util.LruCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
object EncryptionKeyCache {
private val sharedNonceKeyCache = LruCache<EncryptionKeyCacheKey, ByteArray>(50)
private fun idx(
key: HexKey,
nonce: HexKey,
) = EncryptionKeyCacheKey(key, nonce)
fun put(
deriveFromPubKey: HexKey,
nonce: HexKey,
privKey: ByteArray,
) = sharedNonceKeyCache.put(idx(deriveFromPubKey, nonce), privKey)
fun get(
deriveFromPubKey: HexKey,
nonce: HexKey,
) = sharedNonceKeyCache.get(idx(deriveFromPubKey, nonce))
inline fun getOrLoad(
deriveFromPubKey: HexKey,
nonce: HexKey,
load: (onLoaded: (privKey: ByteArray) -> Unit) -> Unit,
crossinline whenReady: (privKey: ByteArray) -> Unit,
) {
val cachedPrivKey = get(deriveFromPubKey, nonce)
if (cachedPrivKey != null) {
whenReady(cachedPrivKey)
return
}
load { newPrivKey ->
put(deriveFromPubKey, nonce, newPrivKey)
whenReady(newPrivKey)
}
}
}
// keeps a cache per signed-in user
private data class EncryptionKeyCacheKey(
val pubkey: HexKey,
val nonce: HexKey,
)
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decoupling
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf
import com.vitorpamplona.quartz.utils.sha256.sha256
class EncryptionKeyDerivation {
companion object {
private val saltPrefix = "nip44kd".toByteArray(Charsets.UTF_8)
fun derivePrivateKey(
privKey: ByteArray,
nonce: ByteArray?,
): ByteArray {
if (nonce == null) {
return privKey
}
return sha256(Hkdf().extract(privKey, saltPrefix + nonce))
}
fun deriveKeyPair(
privKey: ByteArray,
nonce: ByteArray?,
): KeyPair = KeyPair(privKey = derivePrivateKey(privKey, nonce))
}
}
@@ -0,0 +1,80 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decoupling.setup
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decoupling.setup.tags.KeyTag
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventUpdate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class EncryptionKeyListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun keys() = tags.mapNotNull(KeyTag::parse)
companion object {
const val KIND = 10044
const val ALT = "Encryption keys"
fun add(
key: KeyTag,
currentEvent: EncryptionKeyListEvent,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EncryptionKeyListEvent>.() -> Unit = {},
) = eventUpdate(currentEvent, createdAt) {
alt(ALT)
key(key)
initializer()
}
fun remove(
key: KeyTag,
currentEvent: EncryptionKeyListEvent,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EncryptionKeyListEvent>.() -> Unit = {},
) = eventUpdate(currentEvent, createdAt) {
alt(ALT)
removeIf(KeyTag::isSameKey, key.toTagArray())
initializer()
}
fun build(
keys: List<KeyTag>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EncryptionKeyListEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT)
keys(keys)
initializer()
}
}
}
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decoupling.setup
import com.vitorpamplona.quartz.experimental.decoupling.setup.tags.KeyTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun TagArrayBuilder<EncryptionKeyListEvent>.key(
key: HexKey,
nonce: HexKey,
) = add(KeyTag.assemble(key, nonce))
fun TagArrayBuilder<EncryptionKeyListEvent>.key(key: KeyTag) = add(key.toTagArray())
fun TagArrayBuilder<EncryptionKeyListEvent>.keys(keys: List<KeyTag>) = addAll(keys.map { it.toTagArray() })
@@ -0,0 +1,62 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decoupling.setup.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class KeyTag(
val pubkey: HexKey,
val nonce: HexKey,
) {
fun toTagArray() = assemble(pubkey, nonce)
companion object {
const val TAG_NAME = "n"
fun isSameKey(
tag1: Array<String>,
tag2: Array<String>,
): Boolean {
ensure(tag1.has(1)) { return false }
ensure(tag2.has(1)) { return false }
ensure(tag1[0] == tag2[0]) { return false }
ensure(tag1[1] == tag2[1]) { return false }
return true
}
@JvmStatic
fun parse(tag: Array<String>): KeyTag? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[2].isNotEmpty()) { return null }
return KeyTag(tag[1], tag[2])
}
@JvmStatic
fun assemble(
key: HexKey,
nonce: HexKey,
) = arrayOf(TAG_NAME, key, nonce)
}
}
@@ -47,6 +47,8 @@ class PrivateOutboxRelayListEvent(
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var privateTagsCache: Array<Array<String>>? = null
override fun isContentEncoded() = true
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0)
@@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag.MARKER
fun MarkedETag.Companion.parseFork(tag: Array<String>): MarkedETag? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
if (tag.size < 4 || tag[0] != "e") return null
if (tag[ORDER_MARKER] != MARKER.FORK.code) return null
// ["e", id hex, relay hint, marker, pubkey]
return MarkedETag(
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.quartz.experimental.interactiveStories.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class ReadStatusTag {
enum class STATUS(
val value: String,
@@ -34,11 +37,12 @@ class ReadStatusTag {
companion object {
const val TAG_NAME = "status"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -60,7 +60,6 @@ data class RootSceneTag(
companion object {
const val TAG_NAME = "A"
const val TAG_SIZE = 2
fun assembleATagId(
kind: Int,
@@ -35,7 +35,6 @@ class StoryOptionTag(
companion object {
const val TAG_NAME = "option"
const val TAG_SIZE = 3
@JvmStatic
fun parse(tag: Array<String>): StoryOptionTag? {
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.nns.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class IPv4Tag {
companion object {
const val TAG_NAME = "ip4"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.nns.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class IPv6Tag {
companion object {
const val TAG_NAME = "ip6"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.nns.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class VersionTag {
companion object {
const val TAG_NAME = "version"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.relationshipStatus.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class PetnameTag {
companion object {
const val TAG_NAME = "petname"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.relationshipStatus.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class RankTag {
companion object {
const val TAG_NAME = "rank"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): Int? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toIntOrNull()
}
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.relationshipStatus.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class SummaryTag {
companion object {
const val TAG_NAME = "summary"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): String? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.zapPolls.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class ClosedAtTag {
companion object {
const val TAG_NAME = "closed_at"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): Long? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
@@ -20,16 +20,19 @@
*/
package com.vitorpamplona.quartz.experimental.zapPolls.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
import kotlin.math.round
class ConsensusThresholdTag {
companion object {
const val TAG_NAME = "consensus_threshold"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): Double? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()?.toDouble()?.div(100)
}
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.experimental.zapPolls.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class MaximumTag {
companion object {
const val TAG_NAME = "value_maximum"
const val TAG_SIZE = 2
@JvmStatic
fun parse(tag: Array<String>): Long? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}

Some files were not shown because too many files have changed in this diff Show More