diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index e8153954ce..fc9693e924 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -745,8 +745,8 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = 26 // Android 8.0 (Oreo) targetSdk = 36 // Android 15 - versionCode = 434 - versionName = "1.06.1" + versionCode = 435 + versionName = "1.06.3" vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md index 36bf02c91c..fe0163c5de 100644 --- a/.claude/skills/find-missing-translations/SKILL.md +++ b/.claude/skills/find-missing-translations/SKILL.md @@ -7,7 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans ## Overview -Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs a table ready for translation. +Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them. ## When to Use @@ -15,6 +15,17 @@ Extract string resource keys from the default `values/strings.xml` that are abse - Preparing a batch of strings for a translator - Checking translation coverage after adding new features +## Target Locales + +The default set of locales (unless the user specifies otherwise): + +| Locale | Language | Directory | +|--------|----------|-----------| +| `cs-rCZ` | Czech | `values-cs-rCZ` | +| `pt-rBR` | Brazilian Portuguese | `values-pt-rBR` | +| `sv-rSE` | Swedish | `values-sv-rSE` | +| `de-rDE` | German | `values-de-rDE` | + ## Technique ### 1. Identify files @@ -24,11 +35,9 @@ Default: amethyst/src/main/res/values/strings.xml Target: amethyst/src/main/res/values-/strings.xml ``` -Default locale: `cs-rCZ` if none specified. User may override (e.g., `pt-rBR`, `ja`). +### 2. Find missing keys using cs-rCZ as reference -### 2. Extract and diff keys - -Use a single bash pipeline to extract translatable keys from both files and diff them: +Always diff against `cs-rCZ` first — it is the most complete locale and serves as the reference. Any keys missing in `cs-rCZ` will also be missing in the other target locales. ```bash # Extract translatable keys from default (exclude translatable="false") @@ -36,11 +45,11 @@ comm -23 \ <(grep 'Valid @@ -70,8 +79,18 @@ Output the missing entries as raw XML resource lines (copy-paste ready for the l Also check `` and `` tags using the same approach if the project uses them. +**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?" + +### 5. Adding translations (if approved) + +When adding translated strings to locale files: + +- **Append new strings at the bottom** of the file, just before the closing `` tag. +- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering. + ## Common Mistakes - **Forgetting `translatable="false"`** — these should never appear in locale files - **Not checking string-arrays/plurals** — only checking `` misses other resource types -- **Modifying files** — this is a read-only research task unless the user asks to add entries \ No newline at end of file +- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere +- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately \ No newline at end of file diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index ab9d4293fb..e0d193b0f8 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. -**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.1` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.3` (Maven Central) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **License**: MIT @@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr ```toml [versions] -quartz = "1.06.1" +quartz = "1.06.3" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") } ``` diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index faeca63146..eae28c616f 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -3,7 +3,7 @@ ## Current version ``` -com.vitorpamplona.quartz:quartz:1.06.1 +com.vitorpamplona.quartz:quartz:1.06.3 ``` Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz @@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua ```toml [versions] -quartz = "1.06.1" +quartz = "1.06.3" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -55,7 +55,7 @@ kotlin { ```kotlin // build.gradle.kts (app module) dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md index e373efdf7d..3ebfdd9b70 100644 --- a/.claude/skills/quartz-kmp.md +++ b/.claude/skills/quartz-kmp.md @@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp ## Current artifact ``` -com.vitorpamplona.quartz:quartz:1.06.1 +com.vitorpamplona.quartz:quartz:1.06.3 ``` See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index abb588b584..a96915bac3 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 diff --git a/amethyst/build.gradle b/amethyst/build.gradle index e44777a88b..24b923d5d6 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -54,8 +54,8 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = libs.versions.android.minSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger() - versionCode = 433 - versionName = generateVersionName("1.06.1") + versionCode = 435 + versionName = generateVersionName("1.06.3") buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 70735fdd83..32bb83c9bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -139,6 +139,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references @@ -1292,6 +1293,30 @@ class Account( return event } + suspend fun signAnonymouslyAndBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + ): T { + val anonymousSigner = NostrSignerInternal(KeyPair()) + val event = anonymousSigner.sign(template) + + cache.justConsumeMyOwnEvent(event) + val note = + if (event is AddressableEvent) { + cache.getOrCreateAddressableNote(event.address()) + } else { + cache.getOrCreateNote(event.id) + } + + val relayList = computeRelayListToBroadcast(note) + + client.send(event, relayList) + + broadcast.forEach { client.send(it, relayList) } + + return event + } + /** * Creates a post event without sending it. * Returns the event, target relays, and extra events to broadcast. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt index b03a03fafd..91f6517ad7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt @@ -47,7 +47,7 @@ class ConnectivityManager( val isMobileOrNull: StateFlow = status .map { - (status.value as? ConnectivityStatus.Active)?.isMobile + (it as? ConnectivityStatus.Active)?.isMobile }.stateIn( scope, SharingStarted.WhileSubscribed(2000), @@ -57,7 +57,7 @@ class ConnectivityManager( val isMobileOrFalse: StateFlow = status .map { - (status.value as? ConnectivityStatus.Active)?.isMobile ?: false + (it as? ConnectivityStatus.Active)?.isMobile ?: false }.stateIn( scope, SharingStarted.WhileSubscribed(2000), 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 cef95667c7..f9e2ddb595 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/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt index 88628f69a2..252c9e7406 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), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 735920c6e3..34381f2de2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.sample @@ -426,41 +425,6 @@ fun observeUserIsFollowingChannel( return flow.collectAsStateWithLifecycle(channel.roomId in account.ephemeralChatList.liveEphemeralChatList.value) } -@Composable -fun observeUserReports( - user: User, - accountViewModel: AccountViewModel, - onUpdate: () -> Unit, -) { - // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) - - // Subscribe in the LocalCache for changes that arrive in the device - val flow = - remember(user, onUpdate) { - user - .reports() - .receivedReportsByAuthor - .onEach { onUpdate() } - .onStart { onUpdate() } - }.collectAsStateWithLifecycle(emptyMap()) -} - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -@Composable -fun observeUserReportCount( - user: User, - accountViewModel: AccountViewModel, -): State { - // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) - - // Subscribe in the LocalCache for changes that arrive in the device - val flow = remember(user) { user.reports().countFlow() } - - return flow.collectAsStateWithLifecycle(0) -} - @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeUserContactCardsScore( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 9eaa1a5dae..0154ca0499 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -50,7 +50,7 @@ fun RecordAudioBox( modifier: Modifier, onRecordTaken: (RecordingResult) -> Unit, maxDurationSeconds: Int? = null, - content: @Composable (Boolean, Int) -> Unit, + content: @Composable (Boolean, Int, () -> Unit) -> Unit, ) { val mediaRecorder = remember { mutableStateOf(null) } val context = LocalContext.current @@ -79,7 +79,8 @@ fun RecordAudioBox( } fun stopRecording() { - val result = mediaRecorder.value?.stop() + val recorder = mediaRecorder.value ?: return + val result = recorder.stop() mediaRecorder.value = null if (result != null) { onRecordTaken(result) @@ -136,6 +137,10 @@ fun RecordAudioBox( } } }, - content = { active -> content(active, elapsedSeconds) }, + content = { active -> + content(active, elapsedSeconds) { + stopRecording() + } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index 32613496f9..c4ae7a504f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -50,15 +50,17 @@ fun RecordVoiceButton( ) { var isRecording by remember { mutableStateOf(false) } var elapsedSeconds by remember { mutableIntStateOf(0) } + var onStopRecording: (() -> Unit)? by remember { mutableStateOf(null) } Column( verticalArrangement = Arrangement.Center, ) { - // Floating recording indicator at the top + // Floating recording indicator at the top (outside ToggleableBox to avoid scale/circle) FloatingRecordingIndicator( modifier = Modifier.height(50.dp), isRecording = isRecording, elapsedSeconds = elapsedSeconds, + onClick = onStopRecording, ) RecordAudioBox( @@ -69,15 +71,11 @@ fun RecordVoiceButton( onVoiceTaken(recording) }, maxDurationSeconds = maxDurationSeconds, - ) { recordingState, elapsed -> - // Update parent state after composition completes + ) { recordingState, elapsed, onStop -> SideEffect { - if (isRecording != recordingState) { - isRecording = recordingState - } - if (elapsedSeconds != elapsed) { - elapsedSeconds = elapsed - } + isRecording = recordingState + elapsedSeconds = elapsed + onStopRecording = onStop } Box( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index 8981e320f0..1fb909cc35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -27,6 +27,7 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -176,6 +177,7 @@ fun FloatingRecordingIndicator( isRecording: Boolean, elapsedSeconds: Int, isCompact: Boolean = false, + onClick: (() -> Unit)? = null, ) { if (!isRecording) return @@ -199,6 +201,12 @@ fun FloatingRecordingIndicator( .background( color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(12.dp), + ).then( + if (onClick != null) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + }, ), contentAlignment = Alignment.Center, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index 198cf07113..c87d34b751 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -215,7 +215,7 @@ private fun ReRecordButton( modifier = Modifier, onRecordTaken = onRecordTaken, maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, - ) { isRecording, elapsedSeconds -> + ) { isRecording, elapsedSeconds, _ -> val contentColor = if (isRecording) { MaterialTheme.colorScheme.onPrimary 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 357f640ef7..722bd77c5a 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 057a8c6157..889b5058d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -130,10 +130,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.uriToRoute -import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -209,11 +206,7 @@ fun AppNavigation( composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { - ImportFollowListPickFollowsScreen( - accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(it.userHex)), - accountViewModel, - nav, - ) + ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) } composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } @@ -238,35 +231,28 @@ fun AppNavigation( composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } composableFromEndArgs { - PublicChatChannelScreen( - it.id, - it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) }, - accountViewModel, - nav, - ) + PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) } composableFromEndArgs { LiveActivityChannelScreen( Address(it.kind, it.pubKeyHex, it.dTag), - draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - replyTo = it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) }, + draftId = it.draftId, + replyToId = it.replyTo, accountViewModel, nav, ) } composableFromEndArgs { - RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.let { relay -> - EphemeralChatScreen( - channelId = RoomId(it.id, relay), - draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - replyTo = it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) }, - accountViewModel = accountViewModel, - nav = nav, - ) - } + EphemeralChatScreen( + id = it.id, + relayUrl = it.relayUrl, + draftId = it.draftId, + replyToId = it.replyTo, + accountViewModel = accountViewModel, + nav = nav, + ) } composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } @@ -280,9 +266,9 @@ fun AppNavigation( geohash = it.geohash, message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), - reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + replyId = it.replyTo, + quoteId = it.quote, + draftId = it.draft, accountViewModel, nav, ) @@ -291,8 +277,8 @@ fun AppNavigation( composableFromBottomArgs { NewPublicMessageScreen( to = it.toKey(), - reply = it.replyId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + replyId = it.replyId, + draftId = it.draftId, accountViewModel = accountViewModel, nav = nav, ) @@ -303,9 +289,9 @@ fun AppNavigation( hashtag = it.hashtag, message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), - reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + replyId = it.replyTo, + quoteId = it.quote, + draftId = it.draft, accountViewModel, nav, ) @@ -313,11 +299,11 @@ fun AppNavigation( composableFromBottomArgs { ReplyCommentPostScreen( - reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + replyId = it.replyTo, message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), - quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + quoteId = it.quote, + draftId = it.draft, accountViewModel, nav, ) @@ -327,8 +313,8 @@ fun AppNavigation( NewProductScreen( message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), - quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + quoteId = it.quote, + draftId = it.draft, accountViewModel, nav, ) @@ -336,8 +322,8 @@ fun AppNavigation( composableFromBottomArgs { LongFormPostScreen( - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + draftId = it.draft, + versionId = it.version, accountViewModel = accountViewModel, nav = nav, ) @@ -347,11 +333,11 @@ fun AppNavigation( ShortNotePostScreen( message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), - baseReplyTo = it.baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - fork = it.fork?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + baseReplyToId = it.baseReplyTo, + quoteId = it.quote, + forkId = it.fork, + versionId = it.version, + draftId = it.draft, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 26dad5aaaf..905b203bd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -86,7 +86,11 @@ fun routeForInner( ): Route? = when (noteEvent) { is AppDefinitionEvent -> { - Route.ContentDiscovery(noteEvent.id) + if (noteEvent.includeKind(5300)) { + Route.ContentDiscovery(noteEvent.id) + } else { + Route.Note(noteEvent.id) + } } is IsInPublicChatChannel -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index f6e92f3c44..76b943fa7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -672,7 +672,7 @@ fun ReplyViaVoiceReaction( } }, maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, - ) { isRecording, elapsedSeconds -> + ) { isRecording, elapsedSeconds, onStop -> if (voiceRecordingState != null) { SideEffect { if (voiceRecordingState.value != isRecording) { @@ -689,6 +689,7 @@ fun ReplyViaVoiceReaction( isRecording = true, elapsedSeconds = elapsedSeconds, isCompact = true, + onClick = onStop, ) } else { VoiceReplyIcon(iconSizeModifier, grayTint) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index b8ef582696..26e539624a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -59,11 +60,12 @@ fun RelayCompose( accountViewModel: AccountViewModel, onAddRelay: () -> Unit, onRemoveRelay: () -> Unit, + onClick: (() -> Unit)? = null, ) { val context = LocalContext.current Row( - modifier = StdPadding, + modifier = StdPadding.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), verticalAlignment = Alignment.CenterVertically, ) { Column( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/anonymous/AnonymousPostButton.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/anonymous/AnonymousPostButton.kt index f5f4eb2432..29a0f43ea7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/anonymous/AnonymousPostButton.kt @@ -18,19 +18,41 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports +package com.vitorpamplona.amethyst.ui.note.creators.anonymous +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PersonOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReports -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size19Modifier @Composable -fun WatchReportsAndUpdateFeed( - baseUser: User, - feedViewModel: UserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, +fun AnonymousPostButton( + isActive: Boolean, + onClick: () -> Unit, ) { - observeUserReports(baseUser, accountViewModel) { feedViewModel.invalidateData() } + IconButton( + onClick = { onClick() }, + ) { + if (!isActive) { + Icon( + imageVector = Icons.Default.PersonOff, + contentDescription = stringRes(R.string.post_anonymously), + modifier = Size19Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.PersonOff, + contentDescription = stringRes(R.string.post_anonymously), + modifier = Size19Modifier, + tint = Color.Red, + ) + } + } } 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 5a973d3047..a95acdcdf8 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/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index 37c1ad1281..7a0ae4ef37 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/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 10051c2945..b150efe393 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -194,6 +194,8 @@ open class CommentPostViewModel : var wantsZapraiser by mutableStateOf(false) override val zapRaiserAmount = mutableStateOf(null) + var wantsAnonymousPost by mutableStateOf(false) + fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -342,10 +344,15 @@ open class CommentPostViewModel : } val version = draftTag.current - + val anonymous = wantsAnonymousPost cancel() - accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + if (anonymous) { + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) + } else { + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + } + accountViewModel.viewModelScope.launch(Dispatchers.IO) { accountViewModel.account.deleteDraftIgnoreErrors(version) } @@ -588,6 +595,7 @@ open class CommentPostViewModel : contentWarningDescription = "" wantsToAddGeoHash = false wantsSecretEmoji = false + wantsAnonymousPost = false forwardZapTo.value = SplitBuilder() forwardZapToEditting.value = TextFieldValue("") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index aba7a01200..3bee7dfe07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments import android.net.Uri import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -35,6 +37,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -47,7 +51,6 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -80,13 +83,17 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size30Modifier +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -94,11 +101,11 @@ import kotlinx.coroutines.withContext @Composable fun ReplyCommentPostScreen( - reply: Note? = null, + replyId: HexKey? = null, message: String? = null, attachment: Uri? = null, - quote: Note? = null, - draft: Note? = null, + quoteId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -108,13 +115,13 @@ fun ReplyCommentPostScreen( val context = LocalContext.current LaunchedEffect(postViewModel, accountViewModel) { - reply?.let { + replyId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.reply(it) } - draft?.let { + draftId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.editFromDraft(it) } - quote?.let { + quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.quote(it) } message?.ifBlank { null }?.let { @@ -241,11 +248,32 @@ private fun GenericCommentPostBody( Row( modifier = Modifier.padding(vertical = Size10dp), ) { - BaseUserPicture( - accountViewModel.userProfile(), - Size35dp, - accountViewModel = accountViewModel, - ) + if (postViewModel.wantsAnonymousPost) { + IconButton( + modifier = Size35Modifier, + onClick = { postViewModel.wantsAnonymousPost = false }, + ) { + Icon( + painter = painterRes(resourceId = R.drawable.incognito, 1), + contentDescription = stringRes(R.string.post_anonymously), + modifier = Size30Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } else { + Box( + modifier = + Modifier.clickable { + postViewModel.wantsAnonymousPost = true + }, + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + } + } MessageField( R.string.what_s_on_your_mind, postViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt index 6eb4dae2e7..49dfbc4401 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt @@ -34,13 +34,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Approval import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.filled.HourglassTop import androidx.compose.material.icons.filled.Recommend +import androidx.compose.material.icons.filled.RemoveDone import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -591,13 +595,13 @@ private fun attestationColor( validity: Validity?, ): Color = when { - status == AttestationStatus.REVOKED -> Color(0xFFB71C1C) - status == AttestationStatus.REJECTED -> Color(0xFFB71C1C) validity == Validity.INVALID -> Color(0xFFB71C1C) - status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32) validity == Validity.VALID -> Color(0xFF2E7D32) - status == AttestationStatus.VERIFYING -> Color(0xFFF57F17) - status == AttestationStatus.ACCEPTED -> Color(0xFF1565C0) + status == AttestationStatus.REVOKED -> Color(0xFFB21CB7) + status == AttestationStatus.REJECTED -> Color(0xFFB71C1C) + status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32) + status == AttestationStatus.VERIFYING -> Color(0xFF173CF5) + status == AttestationStatus.ACCEPTED -> Color(0xFF15C0C0) else -> Color(0xFF757575) } @@ -606,14 +610,14 @@ private fun attestationIcon( validity: Validity?, ): ImageVector = when { - status == AttestationStatus.REVOKED -> Icons.Default.Close - status == AttestationStatus.REJECTED -> Icons.Default.Close validity == Validity.INVALID -> Icons.Default.Close - status == AttestationStatus.VERIFIED -> Icons.Default.VerifiedUser validity == Validity.VALID -> Icons.Default.CheckCircle + status == AttestationStatus.REVOKED -> Icons.Default.RemoveDone + status == AttestationStatus.REJECTED -> Icons.Default.Delete + status == AttestationStatus.VERIFIED -> Icons.Default.Approval status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop - status == AttestationStatus.ACCEPTED -> Icons.Default.CheckCircle - else -> Icons.Default.VerifiedUser + status == AttestationStatus.ACCEPTED -> Icons.Default.HourglassEmpty + else -> Icons.Default.ErrorOutline } @Composable @@ -622,11 +626,11 @@ private fun attestationStatusLabel( validity: Validity?, ): String = when { + validity == Validity.INVALID -> stringRes(R.string.attestation_invalid) + validity == Validity.VALID -> stringRes(R.string.attestation_valid) status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked) status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected) - validity == Validity.INVALID -> stringRes(R.string.attestation_invalid) status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified) - validity == Validity.VALID -> stringRes(R.string.attestation_valid) status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying) status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted) else -> stringRes(R.string.attestation) 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 174a5f58d8..5767a90dc4 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) } 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 0c08afd98c..c407c60d2a 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(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 7c9846dd18..9fc8f3aa96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -28,6 +28,9 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState @@ -43,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import kotlinx.coroutines.launch @Composable fun RefreshingChatroomFeedView( @@ -131,6 +135,18 @@ fun ChatFeedLoaded( } } + val scope = rememberCoroutineScope() + val highlightedNoteId = remember { mutableStateOf(null) } + val onScrollToNote: (Note) -> Unit = { note -> + val index = items.list.indexOfFirst { it.idHex == note.idHex } + if (index >= 0) { + scope.launch { + listState.animateScrollToItem(index) + highlightedNoteId.value = note.idHex + } + } + } + LazyColumn( contentPadding = FeedPadding, modifier = Modifier.fillMaxSize(), @@ -147,6 +163,9 @@ fun ChatFeedLoaded( nav = nav, onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, + onScrollToNote = onScrollToNote, + shouldHighlight = highlightedNoteId.value == item.idHex, + onHighlightFinished = { highlightedNoteId.value = null }, ) NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 3aeb9d13ab..27a811b3ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -95,6 +95,9 @@ fun ChatroomMessageCompose( nav: INav, onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, + onScrollToNote: ((Note) -> Unit)? = null, + shouldHighlight: Boolean = false, + onHighlightFinished: (() -> Unit)? = null, ) { WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) { WatchBlockAndReport( @@ -114,6 +117,9 @@ fun ChatroomMessageCompose( nav, onWantsToReply, onWantsToEditDraft, + onScrollToNote, + shouldHighlight, + onHighlightFinished, ) } } @@ -130,6 +136,9 @@ fun NormalChatNote( nav: INav, onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, + onScrollToNote: ((Note) -> Unit)? = null, + shouldHighlight: Boolean = false, + onHighlightFinished: (() -> Unit)? = null, ) { val isLoggedInUser = remember(note.author) { @@ -167,10 +176,15 @@ fun NormalChatNote( hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(), drawAuthorInfo = drawAuthorInfo, parentBackgroundColor = parentBackgroundColor, + shouldHighlight = shouldHighlight, + onHighlightFinished = onHighlightFinished, onClick = { if (note.event is ChannelCreateEvent) { nav.nav(Route.PublicChatChannel(note.idHex)) true + } else if (innerQuote && onScrollToNote != null) { + onScrollToNote(note) + true } else { false } @@ -253,6 +267,7 @@ fun NormalChatNote( canPreview, accountViewModel, nav, + onScrollToNote, ) } } @@ -288,6 +303,7 @@ private fun MessageBubbleLines( canPreview: Boolean, accountViewModel: AccountViewModel, nav: INav, + onScrollToNote: ((Note) -> Unit)? = null, ) { if (baseNote.event !is DraftWrapEvent) { RenderReplyRow( @@ -298,6 +314,7 @@ private fun MessageBubbleLines( nav = nav, onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, + onScrollToNote = onScrollToNote, ) } @@ -334,9 +351,10 @@ fun RenderReplyRow( nav: INav, onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, + onScrollToNote: ((Note) -> Unit)? = null, ) { if (!innerQuote && note.replyTo?.lastOrNull() != null) { - RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft) + RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft, onScrollToNote) } } @@ -348,6 +366,7 @@ private fun RenderReply( nav: INav, onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, + onScrollToNote: ((Note) -> Unit)? = null, ) { Row(verticalAlignment = Alignment.CenterVertically) { @Suppress("ProduceStateDoesNotAssignValue") @@ -368,6 +387,7 @@ private fun RenderReply( nav = nav, onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, + onScrollToNote = onScrollToNote, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt index 4fa4ac9136..3734873309 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -36,7 +38,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -58,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.theme.chatBackground import com.vitorpamplona.amethyst.ui.theme.chatDraftBackground import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits +import kotlinx.coroutines.delay private const val RELAYS_AND_ACTIONS_TEXT = "Relays and Actions" @@ -71,6 +76,8 @@ fun ChatBubbleLayout( hasDetailsToShow: Boolean, drawAuthorInfo: Boolean, parentBackgroundColor: MutableState? = null, + shouldHighlight: Boolean = false, + onHighlightFinished: (() -> Unit)? = null, onClick: () -> Boolean, onAuthorClick: () -> Unit, actionMenu: @Composable (onDismiss: () -> Unit) -> Unit, @@ -100,6 +107,24 @@ fun ChatBubbleLayout( } } + val highlightActive = remember { mutableStateOf(false) } + + LaunchedEffect(shouldHighlight) { + if (shouldHighlight) { + highlightActive.value = true + delay(1500) + highlightActive.value = false + onHighlightFinished?.invoke() + } + } + + val highlightColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) + val animatedColor by animateColorAsState( + targetValue = if (highlightActive.value) highlightColor.compositeOver(bgColor.value) else bgColor.value, + animationSpec = tween(durationMillis = if (highlightActive.value) 300 else 800), + label = "highlightAnimation", + ) + Row( modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier, horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, @@ -136,7 +161,7 @@ fun ChatBubbleLayout( modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier, ) { Surface( - color = bgColor.value, + color = animatedColor, shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem, modifier = clickableModifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt index bd7a9bb5b5..6fe892d32c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt @@ -23,22 +23,30 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephem import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @Composable fun EphemeralChatScreen( - channelId: RoomId, - draft: Note? = null, - replyTo: Note? = null, + id: HexKey, + relayUrl: String, + draftId: HexKey? = null, + replyToId: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { + val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return + val channelId = remember(id, relay) { RoomId(id, relay) } + val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } } + val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } } + DisappearingScaffold( isInvertedLayout = true, topBar = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt index f0f273dc5c..1132fd56f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28 import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel @@ -35,13 +35,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey @Composable fun PublicChatChannelScreen( channelId: HexKey?, - draft: Note?, - replyTo: Note? = null, + draftId: HexKey? = null, + replyToId: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { if (channelId == null) return + val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } } + val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } } + DisappearingScaffold( isInvertedLayout = true, topBar = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt index 3b39ca4cb6..d04ad5cb38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt @@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -33,17 +33,21 @@ import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey @Composable fun LiveActivityChannelScreen( channelId: Address?, - draft: Note? = null, - replyTo: Note? = null, + draftId: HexKey? = null, + replyToId: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { if (channelId == null) return + val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } } + val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } } + DisappearingScaffold( isInvertedLayout = true, topBar = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 91678e062c..664331698b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -61,7 +61,6 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles @@ -98,12 +97,13 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.core.HexKey @OptIn(ExperimentalMaterial3Api::class) @Composable fun LongFormPostScreen( - draft: Note? = null, - version: Note? = null, + draftId: HexKey? = null, + versionId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -111,6 +111,8 @@ fun LongFormPostScreen( postViewModel.init(accountViewModel) LaunchedEffect(postViewModel, accountViewModel) { + val draft = draftId?.let { accountViewModel.getNoteIfExists(it) } + val version = versionId?.let { accountViewModel.getNoteIfExists(it) } postViewModel.load(draft, version) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index b148a0f174..5f39bccc99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -83,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -93,8 +93,8 @@ import kotlinx.coroutines.withContext fun NewProductScreen( message: String? = null, attachment: Uri? = null, - quote: Note? = null, - draft: Note? = null, + quoteId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -105,10 +105,10 @@ fun NewProductScreen( LaunchedEffect(postViewModel, accountViewModel) { postViewModel.reloadRelaySet() - draft?.let { + draftId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.editFromDraft(it) } - quote?.let { + quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.quote(it) } message?.ifBlank { null }?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt index de1140b04f..8eb16a3965 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt @@ -26,12 +26,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -42,9 +42,9 @@ fun GeoHashPostScreen( geohash: String? = null, message: String? = null, attachment: Uri? = null, - reply: Note? = null, - quote: Note? = null, - draft: Note? = null, + replyId: HexKey? = null, + quoteId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -57,13 +57,13 @@ fun GeoHashPostScreen( geohash?.let { postViewModel.newPostFor(GeohashId(it)) } - reply?.let { + replyId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.reply(it) } - draft?.let { + draftId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.editFromDraft(it) } - quote?.let { + quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.quote(it) } message?.ifBlank { null }?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt index e8e17100d1..c547347d18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -26,12 +26,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -42,9 +42,9 @@ fun HashtagPostScreen( hashtag: String? = null, message: String? = null, attachment: Uri? = null, - reply: Note? = null, - quote: Note? = null, - draft: Note? = null, + replyId: HexKey? = null, + quoteId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -57,13 +57,13 @@ fun HashtagPostScreen( hashtag?.let { postViewModel.newPostFor(HashtagId(it)) } - reply?.let { + replyId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.reply(it) } - draft?.let { + draftId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.editFromDraft(it) } - quote?.let { + quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.quote(it) } message?.ifBlank { null }?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 277c5b8439..3f0e0d79d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -25,7 +25,9 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -59,7 +61,6 @@ import androidx.compose.ui.unit.dp import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS @@ -107,11 +108,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size19Modifier +import com.vitorpamplona.amethyst.ui.theme.Size30Modifier +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -123,11 +127,11 @@ import kotlinx.coroutines.withContext fun ShortNotePostScreen( message: String? = null, attachment: Uri? = null, - baseReplyTo: Note? = null, - quote: Note? = null, - fork: Note? = null, - version: Note? = null, - draft: Note? = null, + baseReplyToId: HexKey? = null, + quoteId: HexKey? = null, + forkId: HexKey? = null, + versionId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -138,6 +142,11 @@ fun ShortNotePostScreen( val activity = context.getActivity() LaunchedEffect(postViewModel, accountViewModel) { + val baseReplyTo = baseReplyToId?.let { accountViewModel.getNoteIfExists(it) } + val quote = quoteId?.let { accountViewModel.getNoteIfExists(it) } + val fork = forkId?.let { accountViewModel.getNoteIfExists(it) } + val version = versionId?.let { accountViewModel.getNoteIfExists(it) } + val draft = draftId?.let { accountViewModel.getNoteIfExists(it) } postViewModel.load(baseReplyTo, quote, fork, version, draft) message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) @@ -283,11 +292,32 @@ private fun NewPostScreenBody( Row( modifier = Modifier.padding(vertical = Size10dp), ) { - BaseUserPicture( - accountViewModel.userProfile(), - Size35dp, - accountViewModel = accountViewModel, - ) + if (postViewModel.wantsAnonymousPost) { + IconButton( + modifier = Size35Modifier, + onClick = { postViewModel.wantsAnonymousPost = false }, + ) { + Icon( + painter = painterRes(resourceId = R.drawable.incognito, 1), + contentDescription = stringRes(R.string.post_anonymously), + modifier = Size30Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } else { + Box( + modifier = + Modifier.clickable { + postViewModel.wantsAnonymousPost = true + }, + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + } + } MessageField( R.string.what_s_on_your_mind, postViewModel, 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 2bbded69bd..9b41af0b59 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 @@ -283,6 +285,9 @@ open class ShortNotePostViewModel : var wantsZapRaiser by mutableStateOf(false) override val zapRaiserAmount = mutableStateOf(null) + // Anonymous Reply + var wantsAnonymousPost by mutableStateOf(false) + fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -336,6 +341,7 @@ open class ShortNotePostViewModel : val currentMentions = (replyNote.event as? TextNoteEvent) ?.mentions() + ?.toSet() ?.map { LocalCache.getOrCreateUser(it.pubKey) } ?: emptyList() @@ -490,8 +496,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 +582,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 @@ -596,6 +602,7 @@ open class ShortNotePostViewModel : pollOptions[index] = tag } + pollType = draftEvent.pollType() ?: PollType.SINGLE_CHOICE closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead() message = TextFieldValue(draftEvent.content) @@ -647,8 +654,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 @@ -712,9 +719,12 @@ open class ShortNotePostViewModel : } val version = draftTag.current + val anonymous = wantsAnonymousPost cancel() - if (accountViewModel.settings.isCompleteUIMode()) { + if (anonymous) { + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) + } else if (accountViewModel.settings.isCompleteUIMode()) { // Tracked broadcasting with progress feedback (non-blocking) val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) @@ -828,7 +838,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 +1064,7 @@ open class ShortNotePostViewModel : wantsPoll = false pollOptions = newStateMapPollOptions() + pollType = PollType.SINGLE_CHOICE closedAt = TimeUtils.oneDayAhead() wantsZapPoll = false @@ -1073,6 +1084,7 @@ open class ShortNotePostViewModel : wantsToAddGeoHash = false wantsExclusiveGeoPost = false wantsSecretEmoji = false + wantsAnonymousPost = false forwardZapTo.value = SplitBuilder() forwardZapToEditting.value = TextFieldValue("") 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 f2c6635985..48d130c0d2 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 @@ -68,16 +68,22 @@ import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList @Composable fun ImportFollowListPickFollowsScreen( - contactListNote: AddressableNote, + userHex: HexKey, accountViewModel: AccountViewModel, nav: INav, ) { + val contactListNote = + remember(userHex) { + accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(userHex)) + } + Scaffold( modifier = Modifier.fillMaxSize(), topBar = { @@ -125,6 +131,7 @@ fun DisplayFollowList( val contactsState by observeNoteEventAndMap?>(contactListNote, accountViewModel) { contactList -> contactList ?.unverifiedFollowKeySet() + ?.toSet() ?.mapNotNull { accountViewModel.checkGetOrCreateUser(it) }?.toPersistentList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 3a0e7ae936..f6ab68d8a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -55,7 +55,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles @@ -108,8 +107,8 @@ import kotlinx.coroutines.withContext @Composable fun NewPublicMessageScreen( to: Set? = null, - reply: Note? = null, - draft: Note? = null, + replyId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -121,10 +120,10 @@ fun NewPublicMessageScreen( to?.let { postViewModel.load(it) } - reply?.let { + replyId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.reply(it) } - draft?.let { + draftId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.editFromDraft(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index f2568cecf1..0fd7328768 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -229,6 +229,7 @@ fun PrepareViewModels( factory = UserProfileReportFeedViewModel.Factory( baseUser, + accountViewModel.account, ), ) @@ -273,7 +274,6 @@ fun ProfileScreen( WatchLifecycleAndUpdateModel(appRecommendations) WatchLifecycleAndUpdateModel(bookmarksFeedViewModel) WatchLifecycleAndUpdateModel(galleryFeedViewModel) - WatchLifecycleAndUpdateModel(reportsFeedViewModel) UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile) @@ -521,7 +521,7 @@ private fun CreateAndRenderTabs( { ZapTabHeader(zapFeedViewModel, accountViewModel) }, { BookmarkTabHeader(baseUser, accountViewModel) }, { FollowedTagsTabHeader(baseUser, accountViewModel) }, - { ReportsTabHeader(baseUser, accountViewModel) }, + { ReportsTabHeader(baseUser, reportsFeedViewModel, accountViewModel) }, { RelaysTabHeader(baseUser, accountViewModel) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt index d931e2cdc7..122ec9654e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt @@ -54,8 +54,8 @@ class UserProfileFollowersUserFeedViewModel( { it.pubkeyHex }, ) - fun List.toNonHiddenOwners(): List = - mapNotNull { event -> + fun List.toNonHiddenOwners(): Set = + mapNotNullTo(mutableSetOf()) { event -> if (!account.isHidden(event.pubKey)) { account.cache.getOrCreateUser(event.pubKey) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt index 55b70ca2c9..1bc249e054 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt @@ -105,6 +105,9 @@ private fun RenderRelayRow( onRemoveRelay = { nav.nav(Route.EditRelays) }, + onClick = { + nav.nav(Route.RelayInfo(relay.url.url)) + }, ) HorizontalDivider( thickness = DividerThickness, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt index 6de79479bc..5213e99b36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt @@ -23,18 +23,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReportCount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes @Composable fun ReportsTabHeader( baseUser: User, + reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, ) { - val reportCount by observeUserReportCount(baseUser, accountViewModel) + val reportCount by reportsFeedViewModel.followerCount.collectAsStateWithLifecycle() if (reportCount > 0) { Text(text = stringRes(R.string.number_reports, reportCount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt index 6c17f1b379..a08713bfe8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt @@ -21,14 +21,25 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun TabReports( @@ -37,15 +48,36 @@ fun TabReports( accountViewModel: AccountViewModel, nav: INav, ) { - WatchReportsAndUpdateFeed(baseUser, feedViewModel, accountViewModel) - Column(Modifier.fillMaxHeight()) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) + val items by feedViewModel.followersFlow.collectAsStateWithLifecycle() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = FeedPadding, + state = rememberLazyListState(), + ) { + itemsIndexed( + items, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + Row(Modifier.fillMaxWidth().animateItem()) { + NoteCompose( + item, + modifier = Modifier.fillMaxWidth(), + routeForLastRead = null, + isBoostedNote = false, + isHiddenFeed = false, + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt index 8effc41007..c73d900650 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt @@ -23,17 +23,59 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.stateIn @Stable class UserProfileReportFeedViewModel( val user: User, -) : AndroidFeedViewModel(UserProfileReportsFeedFilter(user)) { + val account: Account, +) : ViewModel() { + val sortingModel: Comparator = + compareBy( + { it.author?.let { !account.isFollowing(it) } }, + { it.idHex }, + ) + + @OptIn(kotlinx.coroutines.FlowPreview::class) + val followersFlow: StateFlow> = + user + .reports() + .receivedReportsByAuthor + .map { + it.values.flatten().sortedWith(sortingModel) + }.sample(500) + .flowOn(Dispatchers.IO) + .stateIn( + viewModelScope, + initialValue = emptyList(), + started = SharingStarted.Lazily, + ) + + val followerCount = + followersFlow + .map { it.size } + .flowOn(Dispatchers.IO) + .stateIn( + viewModelScope, + initialValue = 0, + started = SharingStarted.Lazily, + ) + class Factory( val user: User, + val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = UserProfileReportFeedViewModel(user) as T + override fun create(modelClass: Class): T = UserProfileReportFeedViewModel(user, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt deleted file mode 100644 index 2027b5106e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal - -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip56Reports.ReportEvent - -class UserProfileReportsFeedFilter( - val user: User, -) : AdditiveFeedFilter() { - override fun feedKey(): String = user.pubkeyHex - - override fun feed(): List = sort(innerApplyFilter(user.reportsOrNull()?.all() ?: emptyList())) - - override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) - - private fun innerApplyFilter(collection: Collection): Set = - collection - .filterTo(mutableSetOf()) { - it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true - } - - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) - - override fun limit() = 400 -} 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 d34ba4609b..98c40b26bc 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index 7c17155565..3781515ff1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -81,7 +81,7 @@ class TorManager( val activePortOrNull: StateFlow = status .map { - (status.value as? TorServiceStatus.Active)?.port + (it as? TorServiceStatus.Active)?.port }.stateIn( scope, SharingStarted.WhileSubscribed(2000), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index d8c81f5648..ccdea4a0ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -31,12 +31,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import org.torproject.jni.TorService import org.torproject.jni.TorService.LocalBinder +private const val SOCKS_PORT_POLL_INTERVAL_MS = 100L + class TorService( val context: Context, ) { @@ -46,9 +47,7 @@ class TorService( trySend(TorServiceStatus.Connecting) val currentIntent = Intent(context, TorService::class.java) - - context.bindService( - currentIntent, + val serviceConnection: ServiceConnection = object : ServiceConnection { override fun onServiceConnected( name: ComponentName, @@ -59,7 +58,7 @@ class TorService( val torService = (service as LocalBinder).service while (torService.socksPort < 0) { - delay(100) + delay(SOCKS_PORT_POLL_INTERVAL_MS) } val active = TorServiceStatus.Active(torService.socksPort) @@ -74,16 +73,25 @@ class TorService( Log.d("TorService", "Tor Service Disconnected") trySend(TorServiceStatus.Off) } - }, + } + + context.bindService( + currentIntent, + serviceConnection, BIND_AUTO_CREATE, ) awaitClose { Log.d("TorService", "Stopping Tor Service") + try { + context.unbindService(serviceConnection) + } catch (e: Exception) { + Log.d("TorService", "Failed to unbind Tor Service: ${e.message}") + } launch { context.stopService(currentIntent) } trySend(TorServiceStatus.Off) } - }.distinctUntilChanged().flowOn(Dispatchers.IO) + }.flowOn(Dispatchers.IO) } diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index d76098c768..7775e36281 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -290,6 +290,7 @@ Nostr Adresa nikdy nyní + sekundy h m d @@ -536,7 +537,7 @@ Nový Přidat autora do seznamu sledování Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem. - Ikona pro seznam %1$s + Ikona pro seznam %1$s je veřejný člen %1$s je soukromý člen Přidat jako veřejného člena diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index b3e6b35dc8..8ad423f5f6 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -961,7 +961,7 @@ Zdá se, že zatím nemáte žádné sady sledování.\nKlepněte níže pro obnovení nebo použijte tlačítko přidat k vytvoření nové. Přidat autora do sady sledování Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem. - Ikona pro seznam %1$s + Ikona pro seznam %1$s není v tomto seznamu Vaše sady sledování Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 5246f55671..fcf5a470e9 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -294,6 +294,7 @@ anz der Bedingungen ist erforderlich Nostr-Adresse nie jetzt + Sekunden s m t @@ -542,7 +543,7 @@ anz der Bedingungen ist erforderlich Neu Autor zur Follower-Liste hinzufügen Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen. - Symbol für %1$s-Liste + Symbol für Liste %1$s ist ein öffentliches Mitglied %1$s ist ein privates Mitglied Als öffentliches Mitglied hinzufügen diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index 7e962d5e02..e30acbafd6 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -1001,7 +1001,7 @@ anz der Bedingungen ist erforderlich Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen. Autor zum Folge-Set hinzufügen Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen. - Symbol für %1$s-Liste + Symbol für Liste %1$s ist nicht in dieser Liste Deine Folge-Sets Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen. diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index db98abc09c..3d7172fb04 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -290,6 +290,7 @@ Nostr-cím soha most + másodperc ó p n @@ -447,6 +448,8 @@ Maximum Zap Együttműködés (0–100)% + Egyetlen lehetőség + Több lehetőség Szavazás lezárásának dátuma és ideje A szavazás lezárul %1$s múlva Szavazás lezárása @@ -1180,7 +1183,7 @@ Átjátszók a kimenő üzenetkhez Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez A tartalom fogadására kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez, és ez az egyetlen módja annak, hogy a követői megtalálják Önt. - Adjon meg 1-3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért + Adjon meg 1–3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social Átjátszók a bejövő üzenetkhez Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index eea0aa7ada..5d0dd475a7 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -115,7 +115,7 @@ Sekot saraksts - Ikona %1$s sarakstam + Ikona sarakstam Izveidot jaunu sarakstu Jaunais %1$s saraksts Kolekcijas nosaukums diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index d956737d3e..e0790763fe 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -181,7 +181,7 @@ Neutralna Anonimowy Zmienia barwę głosu. Uwaga: podstawowe zmiany barwy głosu mogą zostać wykryte przez uważnych słuchaczy. - Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satsy + Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satosze "odpowiedz tutaj.. " Kopiuje ID wpisu do schowka w celu udostępnienia w Nostr Kopiuj ID kanału (wpisu) do schowka @@ -287,6 +287,7 @@ Adres Nostr nigdy teraz + sekund godz. m d @@ -431,7 +432,7 @@ Kontroluje sposób wyświetlania Twojej tożsamości podczas wysyłania zapa. Podłącz portfel Przejrzyj kanał transmitera - Kwota zobowiązania w Satach + Kwota zobowiązania w Satoszach Wyślij Ankietę Wymagane pola: Odbiorcy zap @@ -444,6 +445,8 @@ Maksymalny Zap Konsensus (0–100)% + Pojedynczy wybór + Wielokrotny wybór Data & godzina zakończenia ankiety Ankieta zostanie zamknięta za %1$s Zamknij po @@ -537,7 +540,7 @@ Nowa Dodaj autora do listy obserwowanych Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem. - Ikona dla listy %1$s + Ikona dla listy %1$s jest uczestnikiem publicznym %1$s jest uczestnikiem prywatnym Dodaj jako uczestnika publicznego @@ -633,7 +636,7 @@ Powiadamia Cię, gdy nadejdzie prywatna wiadomość Otrzymano Zapy Powiadamia Cię, gdy ktoś prześle ci zapy - %1$s Satsów + %1$s Satoszy Od %1$s dla %1$s Odpowiedz @@ -668,9 +671,9 @@ Nowy Symbol Odzewu Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić Zapraiser - Dodaje docelową liczbę satsów do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn - Docelowa kwota w Satach - Zapraiser przy: %1$s. %2$s satach do celu + Dodaje docelową liczbę satoszy do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn + Docelowa kwota w Satoszach + Zapraiser przy: %1$s. %2$s satoszach do celu Odczytaj z Transmitera Zapisz do Transmitera Ilość w bajtach, która została wysłana do tego transmitera, w tym filtry i wydarzenia @@ -878,7 +881,7 @@ Kopiuj do schowka Skopiuj nprofile do schowka Kopiuj npub do schowka - Udostępnij lub Zapisz + Udostępnij lub zapisz Kopiuj adres URL do schowka Kopiuj ID wpisu do schowka Dodaj pliki do Galerii @@ -916,7 +919,7 @@ Szukaj i dodaj użytkownika Nick lub Login Brakująca konfiguracja LN - Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satsy + Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satosze Procentowo 25 Podziel zapsy z @@ -947,7 +950,7 @@ Mint dostarczył następujący komunikat błędu: %1$s Tokeny Cashu zostały już wydane. Cashu odebrano - %1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satsów) + %1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satoszy) W systemie nie znaleziono kompatybilnego portfela Cashu Nie można pobrać faktury z serwerów odbiorcy Twój dostawca połączenia z portfelem zwrócił następujący błąd: %1$s @@ -965,7 +968,7 @@ Nie znaleziono zwrotnego adresu URL z odpowiedzi %1$s Wystąpił błąd podczas analizowania JSON z pobierania faktury z Lightning Adresu. Sprawdź konfigurację lightning użytkownika Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika - Nieprawidłowa kwota faktury (%1$s satsów) od %2$s. Powinieno być %3$s + Nieprawidłowa kwota faktury (%1$s satoszy) od %2$s. Powinieno być %3$s Nie można utworzyć faktury przed wysłaniem zapa. Portfel odbiorcy wysłał następujący błąd: %1$s Nie można utworzyć faktury. Wiadomość od %1$s: %2$s Nie można utworzyć faktury przed wysłaniem zapa. Element pr nie został znaleziony w powstałym JSON. @@ -992,7 +995,7 @@ iPhone 13 Stan Kategoria - Cena (w Satach) + Cena (w Satoszach) 1000 Lokalizacja Miasto, Województwo, Kraj @@ -1405,7 +1408,7 @@ %1$d/%2$d Przekaz Przekaz %1$s - Liczba przekazów: %1$d... + Liczba przekazów: %1$d… Wysłanych przekazów: %1$d Niektóre akcje nie powiodły się Wszystkie akcje udane @@ -1625,7 +1628,7 @@ nowa %1$s brak wydarzeń Eksplorator Bitcoin (OTS) - wydarzenia + wydarzeń DMs profile ustawienia transmiterów diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7d3b9ef4d5..e22277af24 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -290,6 +290,7 @@ Endereço Nostr nunca agora + segundos h m d @@ -536,7 +537,7 @@ Novo Adicionar autor à lista de seguidores Adicionar ou remover usuário de listas, ou criar uma nova lista com este usuário. - Ícone da lista %1$s + Ícone da lista %1$s é um membro público %1$s é um membro privado Adicionar como membro público diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 755b4832f8..da5af67392 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -1419,7 +1419,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem %1$d/%2$d Oddajam Oddajam %1$s - Oddajam %1$d dogodkov... + Oddajam %1$d dogodkov… Poslano %1$d dogodkov Nekaterim dogodkom ni uspelo Vsem dogodkom je uspelo diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 4011ddf584..777f8fa18f 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -290,6 +290,7 @@ Nostr-adress aldrig nu + sekunder t m d @@ -536,7 +537,7 @@ Ny Lägg till författare i följelista Lägg till eller ta bort användare från listor, eller skapa en ny lista med denna användare. - Ikon för %1$s-lista + Ikon för lista %1$s är en offentlig medlem %1$s är en privat medlem Lägg till som offentlig medlem diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 0d45efe82b..a63640a6ff 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -290,6 +290,7 @@ Nostr 地址 从不 现在 + @@ -1104,6 +1105,13 @@ 新社区笔记 新产品 新建地理位置限定帖文 + 新文章 + 标题 + 摘要(选填) + 封面图片URL (可选) + 用 markdown 格式撰写文章… + 预览 + 编辑 展开对此帖子的所有回应 收起对此帖子的所有回应 回复 @@ -1173,7 +1181,7 @@ 发件箱中继 设置您的公共发件箱中继来发布内容 创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 - 插入 1-3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入 + 插入 1–3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入 好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social 收件箱中继 设置您的公共收件箱中继来接收通知 @@ -1241,6 +1249,7 @@ OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 + 编辑文章 编辑帖子 提议改进帖子 变动摘要 @@ -1664,4 +1673,11 @@ 熟练验证类型:%1$s 证明 请求证明 + 日期范围 + + + 刚刚 + 全部时间 + 上次同步: %1$s + 自上次同步后 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b31cf49746..9652292077 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 @@ -539,6 +541,10 @@ No trace in Nostr, only in Lightning + Anonymous + Post as a new throwaway identity. Your account will not be linked to this reply. + This reply will be posted from a new anonymous identity + File Server Choose a server to upload this file to diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt index 9c6c6007ba..6356b2e372 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.startsWithAny import com.vitorpamplona.quartz.utils.urldetector.Url import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import kotlinx.coroutines.CancellationException @Stable class Urls( @@ -77,30 +79,35 @@ class UrlParser { val blossom = mutableSetOf() urls.forEach { url -> - if (url.isValidTopLevelDomain()) { - if (url.wroteWithSchema()) { - if (url.originalUrl.startsWithAny(httpScheme)) { - // quick exit - completeUrls.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(nostrScheme)) { - bech32.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(websocketScheme)) { - relays.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(blossomScheme)) { - blossom.add(url.originalUrl) - } else { - completeUrls.add(url.originalUrl) - } - } else { - // emails are understood as urls from the detector. - if (url.isEmail()) { - Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach { - emails.add(it.value) + try { + if (url.isValidTopLevelDomain()) { + if (url.wroteWithSchema()) { + if (url.originalUrl.startsWithAny(httpScheme)) { + // quick exit + completeUrls.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(nostrScheme)) { + bech32.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(websocketScheme)) { + relays.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(blossomScheme)) { + blossom.add(url.originalUrl) + } else { + completeUrls.add(url.originalUrl) } } else { - urlsWithoutScheme.add(url.originalUrl) + // emails are understood as urls from the detector. + if (url.isEmail()) { + Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach { + emails.add(it.value) + } + } else { + urlsWithoutScheme.add(url.originalUrl) + } } } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("UrlParser", "Trying to parse url `${url.originalUrl}` from `$content`", e) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1a2b35468a..db9af4a9ea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,7 +48,6 @@ netUrlencoderLibVersion = "1.6.0" navigationCompose = "2.9.7" okhttp = "5.3.2" runner = "1.7.0" -rfc3986 = "0.1.2" secp256k1KmpJniAndroid = "0.23.0" securityCryptoKtx = "1.1.0" slf4j = "2.0.17" @@ -58,6 +57,7 @@ torAndroid = "0.4.9.5.1" translate = "17.0.3" jetbrainsCompose = "1.10.3" unifiedpush = "3.0.10" +uriReferenceKmp = "1.0" vico-charts-compose = "3.0.3" zelory = "3.0.1" zoomable = "2.11.1" @@ -72,7 +72,6 @@ androidxExifinterface = "1.4.2" kotlinTest = "2.3.0" core = "1.7.0" mavenPublish = "0.36.0" -spmForKmpVersion = "1.6.1" sqlite = "2.6.2" [libraries] @@ -128,6 +127,7 @@ coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versio coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } +uri-reference-kmp = { module = "io.github.kotlingeekdev:uri-reference-kmp", version.ref = "uriReferenceKmp" } vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } @@ -165,7 +165,6 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } -rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" } secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } @@ -199,4 +198,3 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } -frankois944-spmForKmp = { id = "io.github.frankois944.spmForKmp", version.ref = "spmForKmpVersion" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index a1bf9ab013..1fed30907a 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -1,9 +1,5 @@ -@file:OptIn(ExperimentalSpmForKmpFeature::class) - import com.vanniktech.maven.publish.KotlinMultiplatform import com.vanniktech.maven.publish.SourcesJar -import io.github.frankois944.spmForKmp.swiftPackageConfig -import io.github.frankois944.spmForKmp.utils.ExperimentalSpmForKmpFeature import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest @@ -13,7 +9,6 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.serialization) alias(libs.plugins.vanniktech.mavenPublish) - alias(libs.plugins.frankois944.spmForKmp) } kotlin { @@ -26,7 +21,7 @@ kotlin { } } - androidLibrary { + android { namespace = "com.vitorpamplona.quartz" compileSdk = libs.versions.android.compileSdk @@ -121,21 +116,6 @@ kotlin { dependsOn(libsodiumDefFileGeneration) } } - - target.swiftPackageConfig(cinteropName = "swiftbridge") { - minIos = "17" - minMacos = "14" - dependency { - remotePackageVersion( - url = uri("https://github.com/swift-standards/swift-rfc-3986.git"), - packageName = "swift-rfc-3986", - products = { - add("RFC 3986") - }, - version = "0.1.0", - ) - } - } } iosArm64 { @@ -144,6 +124,7 @@ kotlin { } binaries.framework { baseName = xcfName + isStatic = true binaryOption("bundleId", "com.vitorpamplona.quartz") } } @@ -154,6 +135,7 @@ kotlin { } binaries.framework { baseName = xcfName + isStatic = true binaryOption("bundleId", "com.vitorpamplona.quartz") } } @@ -201,6 +183,9 @@ kotlin { // SQLite KMP driver for event store api(libs.androidx.sqlite) implementation(libs.androidx.sqlite.bundled) + + // RFC3986 library(normalizes URLs) + api(libs.uri.reference.kmp) } } @@ -221,9 +206,6 @@ kotlin { dependsOn(commonMain.get()) dependencies { - // Normalizes URLs - api(libs.rfc3986.normalizer) - // Performant Parser of JSONs into Events api(libs.jackson.module.kotlin) @@ -368,7 +350,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.06.1", + version = "1.06.3", ) // Configure publishing to Maven Central 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 f388b1dcdb..9241a884d1 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 @@ -82,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() 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 2446661ed5..91d38f0aed 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 ad56ebffa6..c4b864ec5f 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/nip18Reposts/quotes/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt index 7a5bf1d0f8..5fc0de6e7f 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,18 +31,18 @@ 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) { - 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 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt index 66f140b471..ca48ffadab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt @@ -21,6 +21,5 @@ package com.vitorpamplona.quartz.nip89AppHandlers.clientTag import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -fun Event.client() = tags.zapraiserAmount() +fun Event.client() = tags.client() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt index 7af90417f6..5403ffca59 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt @@ -24,9 +24,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint -fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) +fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) -fun TagArrayBuilder.client( +fun TagArrayBuilder.client( name: String, address: AddressHint, ) = addUnique(ClientTag.assemble(name, address.addressId, address.relay)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt index 3503c2edf0..a0bc76d7f1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt @@ -20,12 +20,47 @@ */ package com.vitorpamplona.quartz.utils -expect object Rfc3986 { - fun normalize(uri: String): String +import io.kotlingeekdev.urireference.URIReference - fun isValidUrl(url: String): Boolean +object Rfc3986 { + fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString() - fun normalizeAndRemoveFragment(url: String): String + fun isValidUrl(url: String): Boolean = + runCatching { + URIReference.parse(url) + }.isSuccess - fun host(url: String): String + fun normalizeAndRemoveFragment(url: String): String = + URIReference + .parse(url) + .normalize()!! + .toStringNoFragment() + .internIfPossible() + + fun host(url: String): String = + URIReference + .parse(url) + .host + ?.value + .toString() +} + +fun URIReference.toStringSchemeHost(): String { + val sb = StringBuilder() + + if (scheme != null) sb.append(scheme).append(":") + if (authority != null) sb.append("//").append(authority.toString()) + + return sb.toString() +} + +fun URIReference.toStringNoFragment(): String { + val sb = StringBuilder() + + if (scheme != null) sb.append(scheme).append(":") + if (host != null) sb.append("//").append(host.toString()) + if (path != null) sb.append(path) + if (query != null) sb.append("?").append(query) + + return sb.toString() } 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 fe9785bb29..0000000000 --- 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/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt index ebe64927c0..d911471726 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt @@ -298,11 +298,22 @@ class DomainNameReader( topLevelLength = currentLabelLength } - var lastWasAscii: Boolean? = null + var lastWasAscii: Boolean? = + if (current.isNullOrEmpty()) { + null + } else { + val last = current.last() + if (isDot(last)) { + null + } else { + last.code < INTERNATIONAL_CHAR_START + } + } var isAscii = false while (!done && !reader.eof()) { val curr: Char = reader.read() + isAscii = curr.code < INTERNATIONAL_CHAR_START if (lastWasAscii == null) { lastWasAscii = isAscii @@ -765,7 +776,7 @@ class DomainNameReader( * The start of the utf character code table which indicates that this character is an international character. * Everything below this value is either a-z,A-Z,0-9 or symbols that are not included in domain name. */ - private const val INTERNATIONAL_CHAR_START = 192 + const val INTERNATIONAL_CHAR_START = 192 /** * The maximum length of each label in the domain name. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt index f572721a51..0c1f1fa8fe 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.utils.urldetector.detection import com.vitorpamplona.quartz.utils.urldetector.Url import com.vitorpamplona.quartz.utils.urldetector.UrlMarker import com.vitorpamplona.quartz.utils.urldetector.UrlPart +import com.vitorpamplona.quartz.utils.urldetector.detection.DomainNameReader.Companion.INTERNATIONAL_CHAR_START import kotlin.math.max import kotlin.text.deleteRange @@ -84,6 +85,9 @@ class UrlDetector( var length = 0 var position = 0 + var lastWasAscii: Boolean? = null + var isAscii = false + // until end of string read the contents while (!reader.eof()) { // read the next char to process. @@ -96,6 +100,7 @@ class UrlDetector( } readEnd(ReadEndState.InvalidUrl) length = 0 + lastWasAscii = null } '%' -> { @@ -116,6 +121,7 @@ class UrlDetector( length = 0 } } + lastWasAscii = null } '\u3002', '\uFF0E', '\uFF61', '.' -> { @@ -125,6 +131,7 @@ class UrlDetector( readEnd(ReadEndState.InvalidUrl) } length = 0 + lastWasAscii = null } '@' -> { @@ -136,6 +143,7 @@ class UrlDetector( } length = 0 } + lastWasAscii = null } '[' -> { @@ -153,6 +161,7 @@ class UrlDetector( reader.seek(beginning) } length = 0 + lastWasAscii = null } '/' -> { @@ -180,15 +189,29 @@ class UrlDetector( hasScheme = readHtml5Root() length = buffer.length } + lastWasAscii = null } ':' -> { // add the ":" to the url and check for scheme/username buffer.append(curr) length = processColon(length) + lastWasAscii = null } else -> { + isAscii = curr.code < INTERNATIONAL_CHAR_START + if (lastWasAscii == null) { + lastWasAscii = isAscii + } else if (isAscii != lastWasAscii) { + // threat changes in char as a space + if (buffer.isNotEmpty() && hasScheme) { + reader.goBack() + readDomainName(buffer.substring(length)) + } + length = 0 + } + buffer.append(curr) } } 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 aae685bf2b..02fbdc7b45 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/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt index a29c5e99e3..2cf13477b9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt @@ -59,6 +59,6 @@ class UrlTest { assertNotNull(url.path) assertNotNull(url.query) assertNotNull(url.fragment) - assertEquals(-1, url.port) // getPart(PORT) returns null → -1 + assertEquals(443, url.port) // getPart(PORT) returns null → -1 } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt index 9e73fd43f6..7bdf5afc0f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt @@ -739,6 +739,37 @@ class UriDetectionTest { runTest("blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net", "blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net") } + @Test + fun testBrokenCaseInProduction() { + runTest("今北産業") + runTest("http://test.com今北産業", "http://test.com") + runTest("ftp://test.com今北産業", "ftp://test.com") + runTest("test.com今北産業", "test.com") + runTest("wss://test.com今北産業", "wss://test.com") + runTest("blossom:test.com今北産業", "blossom:test.com") + runTest("nostr:test.com今北産業", "nostr:test.com") + runTest("nostr:test今北産業", "nostr:test") + runTest("nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + + runTest("今北産業http://test.com", "http://test.com") + runTest("今北産業ftp://test.com", "ftp://test.com") + runTest("今北産業test.com", "test.com") + runTest("今北産業wss://test.com", "wss://test.com") + runTest("今北産業blossom:test.com", "blossom:test.com") + runTest("今北産業nostr:test.com", "nostr:test.com") + runTest("今北産業nostr:test", "nostr:test") + runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + + runTest("今北産業http://test.com今北産業", "http://test.com") + runTest("今北産業ftp://test.com今北産業", "ftp://test.com") + runTest("今北産業test.com今北産業", "test.com") + runTest("今北産業wss://test.com今北産業", "wss://test.com") + runTest("今北産業blossom:test.com今北産業", "blossom:test.com") + runTest("今北産業nostr:test.com今北産業", "nostr:test.com") + runTest("今北産業nostr:test今北産業", "nostr:test") + runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + } + @Test fun testFullText() { val text = diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt deleted file mode 100644 index c782de2f9c..0000000000 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt +++ /dev/null @@ -1,55 +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 platform.Foundation.NSURLComponents -import swiftbridge.Rfc3986UriBridge - -@OptIn(ExperimentalForeignApi::class) -actual object Rfc3986 { - private val rfc3986UriBridge = Rfc3986UriBridge() - - actual fun normalize(uri: String): String = - rfc3986UriBridge - .normalizeUrlWithUrl(uri, null) - ?.let { if (it.last() == '/') it else "$it/" } ?: throw Exception("Could not normalize URI: $uri") - - actual fun isValidUrl(url: String): Boolean = rfc3986UriBridge.isUrlValidWithUrl(url) - - actual fun normalizeAndRemoveFragment(url: String): String = - NSURLComponents(url) - .toStringNoFragment() - .internIfPossible() - - actual fun host(url: String): String = rfc3986UriBridge.hostFromUriWithUrl(url, null) ?: throw Exception("Could not retrieve host from URL.") -} - -fun NSURLComponents.toStringNoFragment(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (host != null) sb.append("//").append(host.toString()) - if (path != null) sb.append(path) - if (query != null) sb.append("?").append(query) - - return sb.toString() -} 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 ad697c7f99..0000000000 --- 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/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt new file mode 100644 index 0000000000..f5042e2d1b --- /dev/null +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt @@ -0,0 +1,152 @@ +/* + * 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.nip19Bech32 + +import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NIP19EmbedTests { + @Test + fun testEmbedKind1Event() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val textNote = + signer.sign( + TextNoteEvent.build("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size."), + ) + + assertNotNull(textNote) + + val bech32 = NEmbed.create(textNote) + + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(textNote.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionEmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionFhir)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionBundleEmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionBundle2EmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(decodePrivateKeyAsHexOrNull("nsec1arn3jlxv20y76n8ek8ydecy9ga06rl7aw8evznjylc3ap00hwkvqx4vvy6")!!.hexToByteArray()), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle2)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testTimsNembed() { + val uri = "nembed1r79ssq9446hkwqhl642ukmku8qg0c92pu7w3j0jyfte8tc7tvg85vmrys8x3sqgle5vjy7jpjswqhphl0kd6yf4sz0n3peyjq5rp3zkat4w6c6j3f7um0724jmfu5456xxgg2yxkn8dp23j64xsn9npcggzafyh2effyntqrqxzja8dp52kpcvc9zqxlj86e8mx05vevzxkeprjkfs4wmppxm3p96vj6yvu2mqgf5l4v99492r2qsggquxuv93uzx244652h2kkj8xseg9xkq0afpygknjtty9j4ju5v0nm9mezux9wyl6s5wr7lzce7cj397mnu0u04ha7aq3w7exelrhe3zs3l3urwa9sp36u80npllrs0hmsxqdn0fsuyav3nv0azjs5suzuurg2uymncjxez8p9xksc2j6gw992enjflgrdd7n5uq2xrpvfrd3rckw624ey0elvm6grr27tyzlf4vaswgm5vc3hdyczsl983g2j8e67r6z5zt30lat84ma4wclkwwxxrcflvdsuwd7346h7zqav4vdwe3gkt9lr87sfk4aqd2aey03tt4eyspldrqcmkx9pqe2pn63rv7grwwalr86akuldnvjm6m87wrw9sdwns8wq0rnsmj57vqwtc3g7hkwum3vl2dda78dwkycgfzw6qna3ufhpatcvq5a4hm4ehl45an8umwt0clf7rn77ctke475qglwu86hhfwhn7dkca4pkfpyc4y75rll6nvr5qc8nlhf8mk22celn5mecvyuzxd830drhdck9tcdpcafymk8wajwu2w8ha8gatggjfvq0a4jlf2sdamzj0ysqks9dk8me3q7a0qpmf6vykurkrcls4pug3u4pn4u26ezx3h8e482n07x2nsmu80dpufxqc0ttcyzhnppguxma4d8aumdawnlsyy7yzcuxl7lw5y9p4nv5h8fn6u8anpm2tsze3p6mgxy9j9uuqfxg2jvlmtjpakna5m4hln0msmw804hnun96h66fh62270yhhljnmmdl7jln07ll5vft7e870hemcld34a09n943ed6629fgtctsftma9q6tf4jfm2p0ukd2j2n2dpz53fqrkk4ctdcy2j5jar095g5jntf6u807ggkzauzt6uqkwk4tg5w7w55kskspc9663zx5dzzzfwpg3q546g2ve4kukr70n0a46eyce2crsqqq247ql5" + + val decodedNote = (Nip19Parser.uriToRoute(uri)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(timsPrescription, decodedNote.toJson()) + } + + val visionPrescriptionFhir = "{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"Patient/Donald Duck\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"Practitioner/Adam Careful\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}" + val visionPrescriptionBundle = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Careful\",\"given\":[\"Adam\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Duck\",\"given\":[\"Donald\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"#2\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" + + val visionPrescriptionBundle2 = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Smith\",\"given\":[\"Dr. Joe\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Doe\",\"given\":[\"Jane\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" + val timsPrescription = "{\"id\":\"18d8b22e6455dfc9f4c6d6be8c2cf015e961b8d160dfe5e4b7fc1578f2c4e0be\",\"pubkey\":\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\",\"created_at\":1739566773,\"kind\":82,\"tags\":[[\"p\",\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\"]],\"content\":\"{\\\"resourceType\\\": \\\"VisionPrescription\\\", \\\"id\\\": \\\"eyeglass-prescription-001\\\", \\\"status\\\": \\\"active\\\", \\\"created\\\": \\\"2025-02-14T10:00:00Z\\\", \\\"patient\\\": {\\\"reference\\\": \\\"Patient/12345\\\", \\\"display\\\": \\\"John Doe\\\"}, \\\"encounter\\\": {\\\"reference\\\": \\\"Encounter/67890\\\"}, \\\"dateWritten\\\": \\\"2025-02-10T15:00:00Z\\\", \\\"prescriber\\\": {\\\"reference\\\": \\\"Practitioner/56789\\\", \\\"display\\\": \\\"Dr. Emily Smith\\\"}, \\\"lensSpecification\\\": [{\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"right\\\", \\\"sphere\\\": -2.5, \\\"cylinder\\\": -1.0, \\\"axis\\\": 180, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"up\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Right eye prescription for near-sightedness with astigmatism.\\\"}]}, {\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"left\\\", \\\"sphere\\\": -3.0, \\\"cylinder\\\": -0.75, \\\"axis\\\": 160, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"down\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Left eye prescription for near-sightedness with astigmatism.\\\"}]}]}\",\"sig\":\"d22d3b86aea397094de8b6cdf69decdfd886c90008aeebf95fd43a2770b37d486b313bff5bdb44b78e33bbaa3f336d74ee8b36bc5b16050374054246c72d93c2\"}" +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt deleted file mode 100644 index 4595678925..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt +++ /dev/null @@ -1,65 +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 org.czeal.rfc3986.URIReference - -actual object Rfc3986 { - actual fun normalize(uri: String) = - URIReference - .parse(uri) - .normalize() - .toString() - - actual fun isValidUrl(url: String): Boolean = - runCatching { - URIReference.parse(url) - }.isSuccess - - actual fun normalizeAndRemoveFragment(url: String): String = - URIReference - .parse(url) - .normalize() - .toStringNoFragment() - .intern() - - actual fun host(url: String): String = URIReference.parse(url).host.value -} - -fun URIReference.toStringSchemeHost(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (authority != null) sb.append("//").append(authority.toString()) - - return sb.toString() -} - -fun URIReference.toStringNoFragment(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (authority != null) sb.append("//").append(authority.toString()) - if (path != null) sb.append(path) - if (query != null) sb.append("?").append(query) - - return sb.toString() -} 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 bdb01fd24c..0000000000 --- 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 } diff --git a/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift b/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift deleted file mode 100644 index bafd34a5b0..0000000000 --- a/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Created by NullDev on 31/12/2025. -// - -import Foundation -import RFC_3986 - -@objcMembers public class Rfc3986UriBridge: NSObject { - public func normalizeUrl(url: String) throws -> String { - let uri = try RFC_3986.URI(url) - let normalized = uri.normalized() - return normalized.value - } - - public func isUrlValid(url: String) -> Bool { - return RFC_3986.isValidURI(url) - } - - public func hostFromUri(url: String) throws -> String { - let actualUri = try RFC_3986.URI(url) - return actualUri.host! - } -} diff --git a/quartz/src/swift/swiftbridge/UrlDetector.swift b/quartz/src/swift/swiftbridge/UrlDetector.swift deleted file mode 100644 index e4927c516d..0000000000 --- a/quartz/src/swift/swiftbridge/UrlDetector.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Created by NullDev on 20/01/2026. -// - -import Foundation - -@objcMembers public class UrlDetector: NSObject { - public func findURLs(text: String) -> [String] { - var links = [String]() - let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - let matches = detector.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) - - for match in matches { - guard let range = Range(match.range, in: text) else { continue } - let url = text[range] - links.append(String(url)) - } - - return links - } -} \ No newline at end of file