From 8525f2b930fe3762de4f9738b2e4a3ac2dab5950 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 02:50:46 +0000 Subject: [PATCH 01/13] feat: add poll type selector for single and multiple choice polls The poll creation UI now shows filter chips to choose between single choice and multiple choice poll types. Previously it was hardcoded to single choice only. https://claude.ai/code/session_01PEsEyGiNvNcNPSjREN4WzW --- .../note/creators/polls/PollOptionsField.kt | 32 +++++++++++++++++++ .../loggedIn/home/ShortNotePostViewModel.kt | 6 +++- amethyst/src/main/res/values/strings.xml | 2 ++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt index 5a973d304..a95acdcdf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt @@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.creators.polls import android.annotation.SuppressLint import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -32,6 +34,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -47,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType import com.vitorpamplona.quartz.utils.RandomInstance @Composable @@ -55,6 +59,13 @@ fun PollOptionsField(postViewModel: ShortNotePostViewModel) { Column( modifier = Modifier.fillMaxWidth(), ) { + PollTypeSelector( + selectedType = postViewModel.pollType, + onTypeSelected = { postViewModel.pollType = it }, + ) + + Spacer(Modifier.height(4.dp)) + optionsList.forEach { option -> OutlinedTextField( modifier = Modifier.fillMaxWidth(), @@ -118,6 +129,27 @@ fun PollOptionsField(postViewModel: ShortNotePostViewModel) { } } +@Composable +fun PollTypeSelector( + selectedType: PollType, + onTypeSelected: (PollType) -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = selectedType == PollType.SINGLE_CHOICE, + onClick = { onTypeSelected(PollType.SINGLE_CHOICE) }, + label = { Text(stringRes(R.string.poll_single_choice)) }, + ) + FilterChip( + selected = selectedType == PollType.MULTI_CHOICE, + onClick = { onTypeSelected(PollType.MULTI_CHOICE) }, + label = { Text(stringRes(R.string.poll_multiple_choice)) }, + ) + } +} + @SuppressLint("ViewModelConstructorInComposable") @Preview @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 2bbded69b..f5d07a8c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -117,6 +117,7 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip94FileMetadata.alt @@ -238,6 +239,7 @@ open class ShortNotePostViewModel : var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) var pollOptions: SnapshotStateMap = newStateMapPollOptions() + var pollType by mutableStateOf(PollType.SINGLE_CHOICE) var closedAt by mutableLongStateOf(TimeUtils.oneDayAhead()) // ZapPolls @@ -596,6 +598,7 @@ open class ShortNotePostViewModel : pollOptions[index] = tag } + pollType = draftEvent.pollType() ?: PollType.SINGLE_CHOICE closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead() message = TextFieldValue(draftEvent.content) @@ -828,7 +831,7 @@ open class ShortNotePostViewModel : accountViewModel.account.nip65RelayList.outboxFlow.value .toList() - PollEvent.build(tagger.message, options, closedAt, relays) { + PollEvent.build(tagger.message, options, closedAt, relays, pollType) { pTags(tagger.directMentionsUsers.map { it.toPTag() }) quotes(quotes) hashtags(findHashtags(tagger.message)) @@ -1054,6 +1057,7 @@ open class ShortNotePostViewModel : wantsPoll = false pollOptions = newStateMapPollOptions() + pollType = PollType.SINGLE_CHOICE closedAt = TimeUtils.oneDayAhead() wantsZapPoll = false diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b31cf4974..28f63ffea 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -483,6 +483,8 @@ Zap maximum Consensus (0–100)% + Single choice + Multiple choice Poll Closing Date & Time Poll closes in %1$s Close after From 6e0e8cf00f8e0a76617ce531686b45ba18e9608b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 04:01:27 +0000 Subject: [PATCH 02/13] feat: add drag-to-seek support on video progress indicator The progress scrubber can now be dragged to seek through the video, in addition to the existing tap-to-seek. During drag, the scrubber enlarges for visual feedback and tracks the finger position in real-time. Seeking is applied on drag end. https://claude.ai/code/session_01QvYAvNdC35zGgkdpnujFn2 --- .../HorizontalLinearProgressIndicator.kt | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt index 88628f69a..252c9e740 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.playback.composable.controls import androidx.annotation.OptIn import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -30,7 +31,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -103,21 +106,43 @@ private fun HorizontalLinearProgressIndicator( scrubberColor: Color = playedColor, rectHeightDp: Dp = 4.dp, ) { + var isDragging by remember { mutableStateOf(false) } + var dragProgress by remember { mutableFloatStateOf(0f) } + Canvas( Modifier .fillMaxWidth() .padding(horizontal = rectHeightDp * 2.5f) .pointerInput(Unit) { detectTapGestures { offset -> - // Capture the exact position onSeek(offset.x / this.size.width.toFloat()) } + }.pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + isDragging = true + dragProgress = (offset.x / this.size.width.toFloat()).coerceIn(0f, 1f) + }, + onDrag = { change, _ -> + change.consume() + dragProgress = (change.position.x / this.size.width.toFloat()).coerceIn(0f, 1f) + }, + onDragEnd = { + onSeek(dragProgress) + isDragging = false + }, + onDragCancel = { + isDragging = false + }, + ) }.padding(vertical = rectHeightDp * 2) .height(rectHeightDp) .onSizeChanged { (w, _) -> onLayoutWidthChanged(w) }, ) { - val positionX = (currentPositionProgress() * size.width).coerceAtLeast(0f) + val displayProgress = if (isDragging) dragProgress else currentPositionProgress() + val positionX = (displayProgress * size.width).coerceAtLeast(0f) val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f) + val scrubberRadius = if (isDragging) size.height * 3f else size.height * 2f drawRect(unplayedColor, size = Size(size.width, size.height)) drawRect(bufferedColor, size = Size(bufferX, size.height)) @@ -125,7 +150,7 @@ private fun HorizontalLinearProgressIndicator( drawCircle( color = scrubberColor, - radius = size.height * 2f, + radius = scrubberRadius, center = Offset(x = positionX, y = size.height / 2.0f), ) } From c67d66981c79692a256982c39ae5e85aa1a212c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 06:15:11 +0000 Subject: [PATCH 03/13] feat: hide video controls on playback start Video controls are now hidden by default when playback begins. Users can tap to reveal them. Previously, controls were shown for 2 seconds before auto-hiding (TwoSecondController) or shown by default (VideoViewInner). https://claude.ai/code/session_01WpAqbodB5w8oDJpN6MLBs6 --- .../service/playback/composable/VideoViewInner.kt | 2 +- .../amethyst/ui/components/ZoomableContentView.kt | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index cef95667c..f9e2ddb59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -47,7 +47,7 @@ fun VideoViewInner( authorName: String? = null, nostrUriCallback: String? = null, automaticallyStartPlayback: Boolean, - controllerVisible: MutableState = mutableStateOf(true), + controllerVisible: MutableState = mutableStateOf(false), onZoom: (() -> Unit)? = null, accountViewModel: AccountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 357f640ef..722bd77c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -124,7 +124,6 @@ import okhttp3.coroutines.executeAsync import okio.sink import java.io.File import java.io.IOException -import kotlin.time.Duration.Companion.seconds // Delay before cleaning up shared video temp files. // Allows time for receiving app to copy the file after user confirms share. @@ -224,12 +223,7 @@ fun TwoSecondController( content: BaseMediaContent, inner: @Composable (controllerVisible: MutableState) -> Unit, ) { - val controllerVisible = remember(content) { mutableStateOf(true) } - - LaunchedEffect(content) { - delay(2.seconds) - controllerVisible.value = false - } + val controllerVisible = remember(content) { mutableStateOf(false) } inner(controllerVisible) } From 514e90ff178aa80fd4a33c148773c08387726c75 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:13:48 -0400 Subject: [PATCH 04/13] Fixes non http uris in the references tag --- .../nip01Core/tags/references/ReferenceTag.kt | 8 ++++- .../quartz/nip10Notes/content/Urls.kt | 28 +++++++++++------ .../com/vitorpamplona/quartz/utils/Urls.kt | 23 -------------- .../nip10Notes/urls/UrlsDetectorTest.kt | 5 ++- .../vitorpamplona/quartz/utils/URLs.ios.kt | 31 ------------------- .../com/vitorpamplona/quartz/utils/Urls.kt | 25 --------------- 6 files changed, 28 insertions(+), 92 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt delete mode 100644 quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt delete mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt index 2446661ed..91d38f0ae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt @@ -52,6 +52,12 @@ class ReferenceTag { fun assemble(url: String) = arrayOf(TAG_NAME, HttpUrlFormatter.normalize(url)) - fun assemble(urls: List): List> = urls.mapTo(HashSet()) { HttpUrlFormatter.normalize(it) }.map { arrayOf(TAG_NAME, it) } + fun assemble(urls: List): List> = + urls + .mapTo(HashSet()) { + HttpUrlFormatter.normalize(it) + }.map { + arrayOf(TAG_NAME, it) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt index ad56ebffa..c4b864ec5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt @@ -20,15 +20,25 @@ */ package com.vitorpamplona.quartz.nip10Notes.content -import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter -import com.vitorpamplona.quartz.utils.fastFindURLs +import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.startsWithAny +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector -fun findURLs(text: String) = fastFindURLs(text) +val rejectSchemes = + listOf( + DualCase("ftp:"), + DualCase("ftps:"), + DualCase("ws:"), + DualCase("wss:"), + DualCase("nostr:"), + DualCase("blossom:"), + ) -fun buildUrlRefs(urls: List): List> = - urls - .mapTo(HashSet()) { url -> - HttpUrlFormatter.normalize(url) - }.map { - arrayOf("r", it) +fun findURLs(text: String) = + UrlDetector(text).detect().mapNotNull { + if (it.originalUrl.startsWithAny(rejectSchemes)) { + null + } else { + it.originalUrl } + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt deleted file mode 100644 index fe9785bb2..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.utils - -expect fun fastFindURLs(text: String): List diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt index aae685bf2..02fbdc7b4 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.nip10Notes.urls import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.utils.fastFindURLs import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals @@ -31,7 +30,7 @@ class UrlsDetectorTest { @Test fun detectUrlNumber() { - val detectedLinks = fastFindURLs(testSentence) + val detectedLinks = findURLs(testSentence) assertEquals(2, detectedLinks.size) } @@ -48,7 +47,7 @@ class UrlsDetectorTest { */ @Test fun doesNotCrashOnJapaneseText() { - val detectedLinks = fastFindURLs("今北産業") + val detectedLinks = findURLs("今北産業") assertEquals(0, detectedLinks.size) } } diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt deleted file mode 100644 index ad697c7f9..000000000 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.utils - -import kotlinx.cinterop.ExperimentalForeignApi -import swiftbridge.UrlDetector - -@Suppress("UNCHECKED_CAST") -@OptIn(ExperimentalForeignApi::class) -actual fun fastFindURLs(text: String): List { - val detectorInstance = UrlDetector() - return detectorInstance.findURLsWithText(text) as List -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt deleted file mode 100644 index bdb01fd24..000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.utils - -import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector - -actual fun fastFindURLs(text: String): List = UrlDetector(text).detect().map { it.originalUrl } From 8c4b2fe62b55834b0fb7ba7e7b6a3d7b4e0a402f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:30:07 -0400 Subject: [PATCH 05/13] Makes sure quotes only happen once in events. --- .../quartz/nip01Core/core/TagArrayBuilder.kt | 9 +++++++++ .../quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt | 12 ++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt index f388b1dcd..bafb76218 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt @@ -72,6 +72,15 @@ class TagArrayBuilder { return this } + fun addUniqueValueIfNew(tag: Array): TagArrayBuilder { + if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this + val list = tagList.getOrPut(tag[0], ::mutableListOf) + if (list.none { it.valueOrNull() == tag[1] }) { + list.add(tag) + } + return this + } + fun addAll(tag: List>): TagArrayBuilder { tag.forEach(::add) return this diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt index 7a5bf1d0f..8bd7e8756 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt @@ -37,12 +37,12 @@ fun TagArrayBuilder.quotes(tag: List) = addAll(tag.map { it fun TagArrayBuilder.quote(entity: Entity) = when (entity) { - is NNote -> add(entity.toQuoteTagArray()) - is NEvent -> add(entity.toQuoteTagArray()) - is NAddress -> add(entity.toQuoteTagArray()) - is NEmbed -> add(entity.toQuoteTagArray()) - is NPub -> add(entity.toQuoteTagArray()) - is NProfile -> add(entity.toQuoteTagArray()) + is NNote -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NEvent -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NAddress -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NEmbed -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NPub -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NProfile -> addUniqueValueIfNew(entity.toQuoteTagArray()) else -> this } From 8d81d6d57c9a6c1ac85b033606eaf24fe70c4447 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:30:40 -0400 Subject: [PATCH 06/13] Removes the clickable NIP-05 url in the @ tagging of users --- .../userSuggestions/ShowUserSuggestionList.kt | 60 ++++++++++++++++++- .../vitorpamplona/amethyst/ui/theme/Color.kt | 2 +- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index 37c1ad128..7a0ae4ef3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -30,23 +30,33 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.WatchAndDisplayNip05Row import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.nip05 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -145,3 +155,51 @@ fun UserLine( }, ) } + +@Composable +private fun WatchAndDisplayNip05Row( + user: User, + accountViewModel: AccountViewModel, +) { + val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle() + + when (val nip05State = nip05StateMetadata) { + is Nip05State.Exists -> { + NonClickableObserveAndDisplayNIP05(nip05State, accountViewModel) + } + + else -> { + Text( + text = user.pubkeyDisplayHex(), + fontSize = Font14SP, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun NonClickableObserveAndDisplayNIP05( + nip05State: Nip05State.Exists, + accountViewModel: AccountViewModel, +) { + if (nip05State.nip05.name != "_") { + Text( + text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) }, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.nip05, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + ObserveAndRenderNIP05VerifiedSymbol(nip05State, 1, NIP05IconSize, accountViewModel) + + Text( + text = nip05State.nip05.domain, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), + maxLines = 1, + overflow = TextOverflow.Visible, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt index d34ba4609..98c40b26b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt @@ -47,7 +47,7 @@ val FollowsFollow = Color.Yellow val NIP05Verified = Color.Blue val Nip05EmailColor = Color(0xFFb198ec) -val Nip05EmailColorDark = Color(0xFF6e5490) +val Nip05EmailColorDark = Color(0xFF765AA2) val Nip05EmailColorLight = Color(0xFFa770f3) val DarkerGreen = Color.Green.copy(alpha = 0.32f) From 94e708b5fc1157889bd1aee1aff636c1db51b5bc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:32:38 -0400 Subject: [PATCH 07/13] Reduces the need to compute naddr to index articles in bookmarks --- .../screen/loggedIn/bookmarkgroups/display/ArticleListView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt index 0c08afd98..c407c60d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt @@ -106,7 +106,7 @@ fun ArticleList( contentPadding = FeedPadding, state = listState, ) { - itemsIndexed(articles, key = { _, item -> item.toNAddr() }) { _, item -> + itemsIndexed(articles, key = { _, item -> item.address }) { _, item -> NoteCompose( baseNote = item, modifier = Modifier.animateContentSize(), From 11dabd46ba69156e2b38b3eaac20a64b955361df Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:33:37 -0400 Subject: [PATCH 08/13] Checks if p tags are user pubkeys when loading drafts. --- .../screen/loggedIn/home/ShortNotePostViewModel.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 2bbded69b..4f6b0b73e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -336,6 +336,7 @@ open class ShortNotePostViewModel : val currentMentions = (replyNote.event as? TextNoteEvent) ?.mentions() + ?.toSet() ?.map { LocalCache.getOrCreateUser(it.pubKey) } ?: emptyList() @@ -490,8 +491,8 @@ open class ShortNotePostViewModel : } pTags = - draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { - LocalCache.getOrCreateUser(it[1]) + draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { + LocalCache.checkGetOrCreateUser(it[1]) } draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "fork" }.forEach { @@ -576,8 +577,8 @@ open class ShortNotePostViewModel : } pTags = - draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { - LocalCache.getOrCreateUser(it[1]) + draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { + LocalCache.checkGetOrCreateUser(it[1]) } canUsePoll = originalNote == null @@ -647,8 +648,8 @@ open class ShortNotePostViewModel : } pTags = - draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { - LocalCache.getOrCreateUser(it[1]) + draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { + LocalCache.checkGetOrCreateUser(it[1]) } canUsePoll = originalNote == null From 3d9efa9938ac2ad4dd538d2deba47737fb657fc0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:33:50 -0400 Subject: [PATCH 09/13] Tries to solve a duplicate user in a lazy column bug --- .../screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt index f2c663598..d49c29101 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt @@ -125,6 +125,7 @@ fun DisplayFollowList( val contactsState by observeNoteEventAndMap?>(contactListNote, accountViewModel) { contactList -> contactList ?.unverifiedFollowKeySet() + ?.toSet() ?.mapNotNull { accountViewModel.checkGetOrCreateUser(it) }?.toPersistentList() From c39eca51728d88b2dff2178b13904fab312afdd8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:39:09 -0400 Subject: [PATCH 10/13] Add new utility methods --- .../quartz/nip01Core/core/TagArrayBuilder.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt index bafb76218..9241a884d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt @@ -91,6 +91,16 @@ class TagArrayBuilder { return this } + fun addAllUnique(tag: Array>): TagArrayBuilder { + tag.forEach(::addUnique) + return this + } + + fun addAllUniqueValueIfNew(tag: List>): TagArrayBuilder { + tag.forEach(::addUniqueValueIfNew) + return this + } + fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray() fun build() = toTypedArray() From d6d4bdc5e33d3bac61f2b6569d1e07d3e1fd1474 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:40:02 -0400 Subject: [PATCH 11/13] avoids duplicating quote tags by key + value --- .../quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt index 8bd7e8756..5fc0de6e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt @@ -31,9 +31,9 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -fun TagArrayBuilder.quote(tag: QTag) = add(tag.toTagArray()) +fun TagArrayBuilder.quote(tag: QTag) = addUniqueValueIfNew(tag.toTagArray()) -fun TagArrayBuilder.quotes(tag: List) = addAll(tag.map { it.toTagArray() }) +fun TagArrayBuilder.quotes(tag: List) = addAllUniqueValueIfNew(tag.map { it.toTagArray() }) fun TagArrayBuilder.quote(entity: Entity) = when (entity) { From 898e6ac677c50f80ac30b01c025e63fd8b157868 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:41:23 -0400 Subject: [PATCH 12/13] Added credits --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index abb588b58..a96915bac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -268,6 +268,7 @@ Updated translations: - Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t - Chinese by hypnotichemionus4 - Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 +- Russian by Anton Zhao # [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08 From 09054d6ac5740a77b3dfb592df7e587175ac5e5f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 10:58:59 -0400 Subject: [PATCH 13/13] Fixes pool rendering when one label is large and the other is small --- .../vitorpamplona/amethyst/ui/note/types/Poll.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 174a5f58d..5767a90dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -412,8 +412,15 @@ private fun RenderResults( resultContent: @Composable RowScope.(user: User) -> Unit, labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), ) { + val showGallery = + remember { + card.options.all { + it.label.length < 50 + } + } + card.options.forEach { pollItem -> - RenderClosedItem(pollItem, resultContent) { + RenderClosedItem(pollItem, showGallery, resultContent) { labelContent(pollItem.code, pollItem.label) } } @@ -422,18 +429,20 @@ private fun RenderResults( @Composable private fun RenderClosedItem( item: PollItemCard, + showGallery: Boolean, resultContent: @Composable RowScope.(user: User) -> Unit, labelContent: @Composable ColumnScope.() -> Unit, ) { val tally by item.results.collectAsStateWithLifecycle(item.currentResults()) - RenderClosedItem(tally, item.label, resultContent, labelContent) + RenderClosedItem(tally, item.label, showGallery, resultContent, labelContent) } @Composable private fun RenderClosedItem( tally: TallyResults, label: String, + showGallery: Boolean, resultContent: @Composable RowScope.(user: User) -> Unit, labelContent: @Composable ColumnScope.() -> Unit, ) { @@ -496,7 +505,7 @@ private fun RenderClosedItem( Row( verticalAlignment = Alignment.CenterVertically, ) { - if (label.length < 30) { + if (showGallery) { UserGallery(tally, resultContent) }