Merge branch 'main' into chess-enhancements-and-bug-fixes

This commit is contained in:
davotoula
2026-03-25 09:10:46 +01:00
82 changed files with 1006 additions and 635 deletions
+2 -2
View File
@@ -745,8 +745,8 @@ android {
applicationId = "com.vitorpamplona.amethyst" applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo) minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15 targetSdk = 36 // Android 15
versionCode = 434 versionCode = 435
versionName = "1.06.1" versionName = "1.06.3"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
+3 -3
View File
@@ -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. 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`) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT **License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml ```toml
[versions] [versions]
quartz = "1.06.1" quartz = "1.06.3"
[libraries] [libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin ```kotlin
dependencies { dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.1") implementation("com.vitorpamplona.quartz:quartz:1.06.3")
} }
``` ```
@@ -3,7 +3,7 @@
## Current version ## 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 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 ```toml
[versions] [versions]
quartz = "1.06.1" quartz = "1.06.3"
[libraries] [libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin ```kotlin
// build.gradle.kts (app module) // build.gradle.kts (app module)
dependencies { dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.1") implementation("com.vitorpamplona.quartz:quartz:1.06.3")
} }
``` ```
@@ -70,7 +70,7 @@ plugins {
} }
dependencies { 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 // JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1") implementation("net.java.dev.jna:jna:5.18.1")
} }
+1 -1
View File
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
## Current artifact ## 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. See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
+1
View File
@@ -268,6 +268,7 @@ Updated translations:
- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t - Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t
- Chinese by hypnotichemionus4 - Chinese by hypnotichemionus4
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 - Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
- Russian by Anton Zhao
<a id="v1.05.1"></a> <a id="v1.05.1"></a>
# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08 # [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08
+2 -2
View File
@@ -54,8 +54,8 @@ android {
applicationId = "com.vitorpamplona.amethyst" applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger() minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 433 versionCode = 435
versionName = generateVersionName("1.06.1") versionName = generateVersionName("1.06.3")
buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\"" buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -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.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner 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.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip01Core.tags.references.references
@@ -1292,6 +1293,30 @@ class Account(
return event return event
} }
suspend fun <T : Event> signAnonymouslyAndBroadcast(
template: EventTemplate<T>,
broadcast: List<Event> = 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. * Creates a post event without sending it.
* Returns the event, target relays, and extra events to broadcast. * Returns the event, target relays, and extra events to broadcast.
@@ -47,7 +47,7 @@ class ConnectivityManager(
val isMobileOrNull: StateFlow<Boolean?> = val isMobileOrNull: StateFlow<Boolean?> =
status status
.map { .map {
(status.value as? ConnectivityStatus.Active)?.isMobile (it as? ConnectivityStatus.Active)?.isMobile
}.stateIn( }.stateIn(
scope, scope,
SharingStarted.WhileSubscribed(2000), SharingStarted.WhileSubscribed(2000),
@@ -57,7 +57,7 @@ class ConnectivityManager(
val isMobileOrFalse: StateFlow<Boolean> = val isMobileOrFalse: StateFlow<Boolean> =
status status
.map { .map {
(status.value as? ConnectivityStatus.Active)?.isMobile ?: false (it as? ConnectivityStatus.Active)?.isMobile ?: false
}.stateIn( }.stateIn(
scope, scope,
SharingStarted.WhileSubscribed(2000), SharingStarted.WhileSubscribed(2000),
@@ -47,7 +47,7 @@ fun VideoViewInner(
authorName: String? = null, authorName: String? = null,
nostrUriCallback: String? = null, nostrUriCallback: String? = null,
automaticallyStartPlayback: Boolean, automaticallyStartPlayback: Boolean,
controllerVisible: MutableState<Boolean> = mutableStateOf(true), controllerVisible: MutableState<Boolean> = mutableStateOf(false),
onZoom: (() -> Unit)? = null, onZoom: (() -> Unit)? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth 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.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -103,21 +106,43 @@ private fun HorizontalLinearProgressIndicator(
scrubberColor: Color = playedColor, scrubberColor: Color = playedColor,
rectHeightDp: Dp = 4.dp, rectHeightDp: Dp = 4.dp,
) { ) {
var isDragging by remember { mutableStateOf(false) }
var dragProgress by remember { mutableFloatStateOf(0f) }
Canvas( Canvas(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = rectHeightDp * 2.5f) .padding(horizontal = rectHeightDp * 2.5f)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures { offset -> detectTapGestures { offset ->
// Capture the exact position
onSeek(offset.x / this.size.width.toFloat()) 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) }.padding(vertical = rectHeightDp * 2)
.height(rectHeightDp) .height(rectHeightDp)
.onSizeChanged { (w, _) -> onLayoutWidthChanged(w) }, .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 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(unplayedColor, size = Size(size.width, size.height))
drawRect(bufferedColor, size = Size(bufferX, size.height)) drawRect(bufferedColor, size = Size(bufferX, size.height))
@@ -125,7 +150,7 @@ private fun HorizontalLinearProgressIndicator(
drawCircle( drawCircle(
color = scrubberColor, color = scrubberColor,
radius = size.height * 2f, radius = scrubberRadius,
center = Offset(x = positionX, y = size.height / 2.0f), center = Offset(x = positionX, y = size.height / 2.0f),
) )
} }
@@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.sample
@@ -426,41 +425,6 @@ fun observeUserIsFollowingChannel(
return flow.collectAsStateWithLifecycle(channel.roomId in account.ephemeralChatList.liveEphemeralChatList.value) 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<Int> {
// 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) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable @Composable
fun observeUserContactCardsScore( fun observeUserContactCardsScore(
@@ -124,7 +124,6 @@ import okhttp3.coroutines.executeAsync
import okio.sink import okio.sink
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import kotlin.time.Duration.Companion.seconds
// Delay before cleaning up shared video temp files. // Delay before cleaning up shared video temp files.
// Allows time for receiving app to copy the file after user confirms share. // Allows time for receiving app to copy the file after user confirms share.
@@ -224,12 +223,7 @@ fun TwoSecondController(
content: BaseMediaContent, content: BaseMediaContent,
inner: @Composable (controllerVisible: MutableState<Boolean>) -> Unit, inner: @Composable (controllerVisible: MutableState<Boolean>) -> Unit,
) { ) {
val controllerVisible = remember(content) { mutableStateOf(true) } val controllerVisible = remember(content) { mutableStateOf(false) }
LaunchedEffect(content) {
delay(2.seconds)
controllerVisible.value = false
}
inner(controllerVisible) inner(controllerVisible)
} }
@@ -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.loggedIn.wallet.WalletTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute 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.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -209,11 +206,7 @@ fun AppNavigation(
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) } composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ImportFollowsPickFollows> { composableFromEndArgs<Route.ImportFollowsPickFollows> {
ImportFollowListPickFollowsScreen( ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(it.userHex)),
accountViewModel,
nav,
)
} }
composableFromEndArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
@@ -238,35 +231,28 @@ fun AppNavigation(
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEndArgs<Route.PublicChatChannel> { composableFromEndArgs<Route.PublicChatChannel> {
PublicChatChannelScreen( PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
it.id,
it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) },
it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) },
accountViewModel,
nav,
)
} }
composableFromEndArgs<Route.LiveActivityChannel> { composableFromEndArgs<Route.LiveActivityChannel> {
LiveActivityChannelScreen( LiveActivityChannelScreen(
Address(it.kind, it.pubKeyHex, it.dTag), Address(it.kind, it.pubKeyHex, it.dTag),
draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draftId = it.draftId,
replyTo = it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) }, replyToId = it.replyTo,
accountViewModel, accountViewModel,
nav, nav,
) )
} }
composableFromEndArgs<Route.EphemeralChat> { composableFromEndArgs<Route.EphemeralChat> {
RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.let { relay -> EphemeralChatScreen(
EphemeralChatScreen( id = it.id,
channelId = RoomId(it.id, relay), relayUrl = it.relayUrl,
draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draftId = it.draftId,
replyTo = it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) }, replyToId = it.replyTo,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
}
} }
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
@@ -280,9 +266,9 @@ fun AppNavigation(
geohash = it.geohash, geohash = it.geohash,
message = it.message, message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(), attachment = it.attachment?.ifBlank { null }?.toUri(),
reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, replyId = it.replyTo,
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, quoteId = it.quote,
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draftId = it.draft,
accountViewModel, accountViewModel,
nav, nav,
) )
@@ -291,8 +277,8 @@ fun AppNavigation(
composableFromBottomArgs<Route.NewPublicMessage> { composableFromBottomArgs<Route.NewPublicMessage> {
NewPublicMessageScreen( NewPublicMessageScreen(
to = it.toKey(), to = it.toKey(),
reply = it.replyId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, replyId = it.replyId,
draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draftId = it.draftId,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -303,9 +289,9 @@ fun AppNavigation(
hashtag = it.hashtag, hashtag = it.hashtag,
message = it.message, message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(), attachment = it.attachment?.ifBlank { null }?.toUri(),
reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, replyId = it.replyTo,
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, quoteId = it.quote,
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draftId = it.draft,
accountViewModel, accountViewModel,
nav, nav,
) )
@@ -313,11 +299,11 @@ fun AppNavigation(
composableFromBottomArgs<Route.GenericCommentPost> { composableFromBottomArgs<Route.GenericCommentPost> {
ReplyCommentPostScreen( ReplyCommentPostScreen(
reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, replyId = it.replyTo,
message = it.message, message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(), attachment = it.attachment?.ifBlank { null }?.toUri(),
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, quoteId = it.quote,
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draftId = it.draft,
accountViewModel, accountViewModel,
nav, nav,
) )
@@ -327,8 +313,8 @@ fun AppNavigation(
NewProductScreen( NewProductScreen(
message = it.message, message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(), attachment = it.attachment?.ifBlank { null }?.toUri(),
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, quoteId = it.quote,
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draftId = it.draft,
accountViewModel, accountViewModel,
nav, nav,
) )
@@ -336,8 +322,8 @@ fun AppNavigation(
composableFromBottomArgs<Route.NewLongFormPost> { composableFromBottomArgs<Route.NewLongFormPost> {
LongFormPostScreen( LongFormPostScreen(
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draftId = it.draft,
version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, versionId = it.version,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -347,11 +333,11 @@ fun AppNavigation(
ShortNotePostScreen( ShortNotePostScreen(
message = it.message, message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(), attachment = it.attachment?.ifBlank { null }?.toUri(),
baseReplyTo = it.baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, baseReplyToId = it.baseReplyTo,
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, quoteId = it.quote,
fork = it.fork?.let { hex -> accountViewModel.getNoteIfExists(hex) }, forkId = it.fork,
version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, versionId = it.version,
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draftId = it.draft,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -86,7 +86,11 @@ fun routeForInner(
): Route? = ): Route? =
when (noteEvent) { when (noteEvent) {
is AppDefinitionEvent -> { is AppDefinitionEvent -> {
Route.ContentDiscovery(noteEvent.id) if (noteEvent.includeKind(5300)) {
Route.ContentDiscovery(noteEvent.id)
} else {
Route.Note(noteEvent.id)
}
} }
is IsInPublicChatChannel -> { is IsInPublicChatChannel -> {
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.note package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -59,11 +60,12 @@ fun RelayCompose(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onAddRelay: () -> Unit, onAddRelay: () -> Unit,
onRemoveRelay: () -> Unit, onRemoveRelay: () -> Unit,
onClick: (() -> Unit)? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
Row( Row(
modifier = StdPadding, modifier = StdPadding.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column( Column(
@@ -18,19 +18,41 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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 androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.model.User import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReports import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
@Composable @Composable
fun WatchReportsAndUpdateFeed( fun AnonymousPostButton(
baseUser: User, isActive: Boolean,
feedViewModel: UserProfileReportFeedViewModel, onClick: () -> Unit,
accountViewModel: AccountViewModel,
) { ) {
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,
)
}
}
} }
@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.creators.polls
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.material.icons.filled.Delete
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
@Composable @Composable
@@ -55,6 +59,13 @@ fun PollOptionsField(postViewModel: ShortNotePostViewModel) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
PollTypeSelector(
selectedType = postViewModel.pollType,
onTypeSelected = { postViewModel.pollType = it },
)
Spacer(Modifier.height(4.dp))
optionsList.forEach { option -> optionsList.forEach { option ->
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), 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") @SuppressLint("ViewModelConstructorInComposable")
@Preview @Preview
@Composable @Composable
@@ -30,23 +30,33 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture 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.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.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.Size55dp
import com.vitorpamplona.amethyst.ui.theme.nip05
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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,
)
}
@@ -194,6 +194,8 @@ open class CommentPostViewModel :
var wantsZapraiser by mutableStateOf(false) var wantsZapraiser by mutableStateOf(false)
override val zapRaiserAmount = mutableStateOf<Long?>(null) override val zapRaiserAmount = mutableStateOf<Long?>(null)
var wantsAnonymousPost by mutableStateOf(false)
fun lnAddress(): String? = account.userProfile().lnAddress() fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
@@ -342,10 +344,15 @@ open class CommentPostViewModel :
} }
val version = draftTag.current val version = draftTag.current
val anonymous = wantsAnonymousPost
cancel() 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.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.deleteDraftIgnoreErrors(version) accountViewModel.account.deleteDraftIgnoreErrors(version)
} }
@@ -588,6 +595,7 @@ open class CommentPostViewModel :
contentWarningDescription = "" contentWarningDescription = ""
wantsToAddGeoHash = false wantsToAddGeoHash = false
wantsSecretEmoji = false wantsSecretEmoji = false
wantsAnonymousPost = false
forwardZapTo.value = SplitBuilder() forwardZapTo.value = SplitBuilder()
forwardZapToEditting.value = TextFieldValue("") forwardZapToEditting.value = TextFieldValue("")
@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -47,7 +51,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery 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.ForwardZapTo
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp 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.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -94,11 +101,11 @@ import kotlinx.coroutines.withContext
@Composable @Composable
fun ReplyCommentPostScreen( fun ReplyCommentPostScreen(
reply: Note? = null, replyId: HexKey? = null,
message: String? = null, message: String? = null,
attachment: Uri? = null, attachment: Uri? = null,
quote: Note? = null, quoteId: HexKey? = null,
draft: Note? = null, draftId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: Nav, nav: Nav,
) { ) {
@@ -108,13 +115,13 @@ fun ReplyCommentPostScreen(
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(postViewModel, accountViewModel) { LaunchedEffect(postViewModel, accountViewModel) {
reply?.let { replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it) postViewModel.reply(it)
} }
draft?.let { draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it) postViewModel.editFromDraft(it)
} }
quote?.let { quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it) postViewModel.quote(it)
} }
message?.ifBlank { null }?.let { message?.ifBlank { null }?.let {
@@ -241,11 +248,32 @@ private fun GenericCommentPostBody(
Row( Row(
modifier = Modifier.padding(vertical = Size10dp), modifier = Modifier.padding(vertical = Size10dp),
) { ) {
BaseUserPicture( if (postViewModel.wantsAnonymousPost) {
accountViewModel.userProfile(), IconButton(
Size35dp, modifier = Size35Modifier,
accountViewModel = accountViewModel, 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( MessageField(
R.string.what_s_on_your_mind, R.string.what_s_on_your_mind,
postViewModel, postViewModel,
@@ -34,13 +34,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.CheckCircle
import androidx.compose.material.icons.filled.Close 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.HourglassTop
import androidx.compose.material.icons.filled.Recommend 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.Send
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -591,13 +595,13 @@ private fun attestationColor(
validity: Validity?, validity: Validity?,
): Color = ): Color =
when { when {
status == AttestationStatus.REVOKED -> Color(0xFFB71C1C)
status == AttestationStatus.REJECTED -> Color(0xFFB71C1C)
validity == Validity.INVALID -> Color(0xFFB71C1C) validity == Validity.INVALID -> Color(0xFFB71C1C)
status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32)
validity == Validity.VALID -> Color(0xFF2E7D32) validity == Validity.VALID -> Color(0xFF2E7D32)
status == AttestationStatus.VERIFYING -> Color(0xFFF57F17) status == AttestationStatus.REVOKED -> Color(0xFFB21CB7)
status == AttestationStatus.ACCEPTED -> Color(0xFF1565C0) status == AttestationStatus.REJECTED -> Color(0xFFB71C1C)
status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32)
status == AttestationStatus.VERIFYING -> Color(0xFF173CF5)
status == AttestationStatus.ACCEPTED -> Color(0xFF15C0C0)
else -> Color(0xFF757575) else -> Color(0xFF757575)
} }
@@ -606,14 +610,14 @@ private fun attestationIcon(
validity: Validity?, validity: Validity?,
): ImageVector = ): ImageVector =
when { when {
status == AttestationStatus.REVOKED -> Icons.Default.Close
status == AttestationStatus.REJECTED -> Icons.Default.Close
validity == Validity.INVALID -> Icons.Default.Close validity == Validity.INVALID -> Icons.Default.Close
status == AttestationStatus.VERIFIED -> Icons.Default.VerifiedUser
validity == Validity.VALID -> Icons.Default.CheckCircle 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.VERIFYING -> Icons.Default.HourglassTop
status == AttestationStatus.ACCEPTED -> Icons.Default.CheckCircle status == AttestationStatus.ACCEPTED -> Icons.Default.HourglassEmpty
else -> Icons.Default.VerifiedUser else -> Icons.Default.ErrorOutline
} }
@Composable @Composable
@@ -622,11 +626,11 @@ private fun attestationStatusLabel(
validity: Validity?, validity: Validity?,
): String = ): String =
when { 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.REVOKED -> stringRes(R.string.attestation_status_revoked)
status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected) 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) 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.VERIFYING -> stringRes(R.string.attestation_status_verifying)
status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted) status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted)
else -> stringRes(R.string.attestation) else -> stringRes(R.string.attestation)
@@ -412,8 +412,15 @@ private fun RenderResults(
resultContent: @Composable RowScope.(user: User) -> Unit, resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit),
) { ) {
val showGallery =
remember {
card.options.all {
it.label.length < 50
}
}
card.options.forEach { pollItem -> card.options.forEach { pollItem ->
RenderClosedItem(pollItem, resultContent) { RenderClosedItem(pollItem, showGallery, resultContent) {
labelContent(pollItem.code, pollItem.label) labelContent(pollItem.code, pollItem.label)
} }
} }
@@ -422,18 +429,20 @@ private fun RenderResults(
@Composable @Composable
private fun RenderClosedItem( private fun RenderClosedItem(
item: PollItemCard, item: PollItemCard,
showGallery: Boolean,
resultContent: @Composable RowScope.(user: User) -> Unit, resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable ColumnScope.() -> Unit, labelContent: @Composable ColumnScope.() -> Unit,
) { ) {
val tally by item.results.collectAsStateWithLifecycle(item.currentResults()) val tally by item.results.collectAsStateWithLifecycle(item.currentResults())
RenderClosedItem(tally, item.label, resultContent, labelContent) RenderClosedItem(tally, item.label, showGallery, resultContent, labelContent)
} }
@Composable @Composable
private fun RenderClosedItem( private fun RenderClosedItem(
tally: TallyResults, tally: TallyResults,
label: String, label: String,
showGallery: Boolean,
resultContent: @Composable RowScope.(user: User) -> Unit, resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable ColumnScope.() -> Unit, labelContent: @Composable ColumnScope.() -> Unit,
) { ) {
@@ -496,7 +505,7 @@ private fun RenderClosedItem(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (label.length < 30) { if (showGallery) {
UserGallery(tally, resultContent) UserGallery(tally, resultContent)
} }
@@ -106,7 +106,7 @@ fun ArticleList(
contentPadding = FeedPadding, contentPadding = FeedPadding,
state = listState, state = listState,
) { ) {
itemsIndexed(articles, key = { _, item -> item.toNAddr() }) { _, item -> itemsIndexed(articles, key = { _, item -> item.address }) { _, item ->
NoteCompose( NoteCompose(
baseNote = item, baseNote = item,
modifier = Modifier.animateContentSize(), modifier = Modifier.animateContentSize(),
@@ -28,6 +28,9 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import kotlinx.coroutines.launch
@Composable @Composable
fun RefreshingChatroomFeedView( fun RefreshingChatroomFeedView(
@@ -131,6 +135,18 @@ fun ChatFeedLoaded(
} }
} }
val scope = rememberCoroutineScope()
val highlightedNoteId = remember { mutableStateOf<String?>(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( LazyColumn(
contentPadding = FeedPadding, contentPadding = FeedPadding,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -147,6 +163,9 @@ fun ChatFeedLoaded(
nav = nav, nav = nav,
onWantsToReply = onWantsToReply, onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft, onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
shouldHighlight = highlightedNoteId.value == item.idHex,
onHighlightFinished = { highlightedNoteId.value = null },
) )
NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item)
@@ -95,6 +95,9 @@ fun ChatroomMessageCompose(
nav: INav, nav: INav,
onWantsToReply: (Note) -> Unit, onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
shouldHighlight: Boolean = false,
onHighlightFinished: (() -> Unit)? = null,
) { ) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) { WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) {
WatchBlockAndReport( WatchBlockAndReport(
@@ -114,6 +117,9 @@ fun ChatroomMessageCompose(
nav, nav,
onWantsToReply, onWantsToReply,
onWantsToEditDraft, onWantsToEditDraft,
onScrollToNote,
shouldHighlight,
onHighlightFinished,
) )
} }
} }
@@ -130,6 +136,9 @@ fun NormalChatNote(
nav: INav, nav: INav,
onWantsToReply: (Note) -> Unit, onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
shouldHighlight: Boolean = false,
onHighlightFinished: (() -> Unit)? = null,
) { ) {
val isLoggedInUser = val isLoggedInUser =
remember(note.author) { remember(note.author) {
@@ -167,10 +176,15 @@ fun NormalChatNote(
hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(), hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(),
drawAuthorInfo = drawAuthorInfo, drawAuthorInfo = drawAuthorInfo,
parentBackgroundColor = parentBackgroundColor, parentBackgroundColor = parentBackgroundColor,
shouldHighlight = shouldHighlight,
onHighlightFinished = onHighlightFinished,
onClick = { onClick = {
if (note.event is ChannelCreateEvent) { if (note.event is ChannelCreateEvent) {
nav.nav(Route.PublicChatChannel(note.idHex)) nav.nav(Route.PublicChatChannel(note.idHex))
true true
} else if (innerQuote && onScrollToNote != null) {
onScrollToNote(note)
true
} else { } else {
false false
} }
@@ -253,6 +267,7 @@ fun NormalChatNote(
canPreview, canPreview,
accountViewModel, accountViewModel,
nav, nav,
onScrollToNote,
) )
} }
} }
@@ -288,6 +303,7 @@ private fun MessageBubbleLines(
canPreview: Boolean, canPreview: Boolean,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
onScrollToNote: ((Note) -> Unit)? = null,
) { ) {
if (baseNote.event !is DraftWrapEvent) { if (baseNote.event !is DraftWrapEvent) {
RenderReplyRow( RenderReplyRow(
@@ -298,6 +314,7 @@ private fun MessageBubbleLines(
nav = nav, nav = nav,
onWantsToReply = onWantsToReply, onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft, onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
) )
} }
@@ -334,9 +351,10 @@ fun RenderReplyRow(
nav: INav, nav: INav,
onWantsToReply: (Note) -> Unit, onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
) { ) {
if (!innerQuote && note.replyTo?.lastOrNull() != 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, nav: INav,
onWantsToReply: (Note) -> Unit, onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@Suppress("ProduceStateDoesNotAssignValue") @Suppress("ProduceStateDoesNotAssignValue")
@@ -368,6 +387,7 @@ private fun RenderReply(
nav = nav, nav = nav,
onWantsToReply = onWantsToReply, onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft, onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
) )
} }
} }
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts 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.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -36,7 +38,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.chatDraftBackground
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits
import kotlinx.coroutines.delay
private const val RELAYS_AND_ACTIONS_TEXT = "Relays and Actions" private const val RELAYS_AND_ACTIONS_TEXT = "Relays and Actions"
@@ -71,6 +76,8 @@ fun ChatBubbleLayout(
hasDetailsToShow: Boolean, hasDetailsToShow: Boolean,
drawAuthorInfo: Boolean, drawAuthorInfo: Boolean,
parentBackgroundColor: MutableState<Color>? = null, parentBackgroundColor: MutableState<Color>? = null,
shouldHighlight: Boolean = false,
onHighlightFinished: (() -> Unit)? = null,
onClick: () -> Boolean, onClick: () -> Boolean,
onAuthorClick: () -> Unit, onAuthorClick: () -> Unit,
actionMenu: @Composable (onDismiss: () -> Unit) -> 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( Row(
modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier, modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier,
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
@@ -136,7 +161,7 @@ fun ChatBubbleLayout(
modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier, modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier,
) { ) {
Surface( Surface(
color = bgColor.value, color = animatedColor,
shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem, shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem,
modifier = clickableModifier, modifier = clickableModifier,
) { ) {
@@ -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.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@Composable @Composable
fun EphemeralChatScreen( fun EphemeralChatScreen(
channelId: RoomId, id: HexKey,
draft: Note? = null, relayUrl: String,
replyTo: Note? = null, draftId: HexKey? = null,
replyToId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, 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( DisappearingScaffold(
isInvertedLayout = true, isInvertedLayout = true,
topBar = { topBar = {
@@ -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.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
@@ -35,13 +35,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Composable @Composable
fun PublicChatChannelScreen( fun PublicChatChannelScreen(
channelId: HexKey?, channelId: HexKey?,
draft: Note?, draftId: HexKey? = null,
replyTo: Note? = null, replyToId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
if (channelId == null) return if (channelId == null) return
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
DisappearingScaffold( DisappearingScaffold(
isInvertedLayout = true, isInvertedLayout = true,
topBar = { topBar = {
@@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar 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.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Composable @Composable
fun LiveActivityChannelScreen( fun LiveActivityChannelScreen(
channelId: Address?, channelId: Address?,
draft: Note? = null, draftId: HexKey? = null,
replyTo: Note? = null, replyToId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
if (channelId == null) return if (channelId == null) return
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
DisappearingScaffold( DisappearingScaffold(
isInvertedLayout = true, isInvertedLayout = true,
topBar = { topBar = {
@@ -61,7 +61,6 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList 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.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles 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.Size5dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LongFormPostScreen( fun LongFormPostScreen(
draft: Note? = null, draftId: HexKey? = null,
version: Note? = null, versionId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: Nav, nav: Nav,
) { ) {
@@ -111,6 +111,8 @@ fun LongFormPostScreen(
postViewModel.init(accountViewModel) postViewModel.init(accountViewModel)
LaunchedEffect(postViewModel, accountViewModel) { LaunchedEffect(postViewModel, accountViewModel) {
val draft = draftId?.let { accountViewModel.getNoteIfExists(it) }
val version = versionId?.let { accountViewModel.getNoteIfExists(it) }
postViewModel.load(draft, version) postViewModel.load(draft, version)
} }
@@ -46,7 +46,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery 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.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@@ -93,8 +93,8 @@ import kotlinx.coroutines.withContext
fun NewProductScreen( fun NewProductScreen(
message: String? = null, message: String? = null,
attachment: Uri? = null, attachment: Uri? = null,
quote: Note? = null, quoteId: HexKey? = null,
draft: Note? = null, draftId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: Nav, nav: Nav,
) { ) {
@@ -105,10 +105,10 @@ fun NewProductScreen(
LaunchedEffect(postViewModel, accountViewModel) { LaunchedEffect(postViewModel, accountViewModel) {
postViewModel.reloadRelaySet() postViewModel.reloadRelaySet()
draft?.let { draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it) postViewModel.editFromDraft(it)
} }
quote?.let { quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it) postViewModel.quote(it)
} }
message?.ifBlank { null }?.let { message?.ifBlank { null }?.let {
@@ -26,12 +26,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.viewmodel.compose.viewModel 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.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel
import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -42,9 +42,9 @@ fun GeoHashPostScreen(
geohash: String? = null, geohash: String? = null,
message: String? = null, message: String? = null,
attachment: Uri? = null, attachment: Uri? = null,
reply: Note? = null, replyId: HexKey? = null,
quote: Note? = null, quoteId: HexKey? = null,
draft: Note? = null, draftId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: Nav, nav: Nav,
) { ) {
@@ -57,13 +57,13 @@ fun GeoHashPostScreen(
geohash?.let { geohash?.let {
postViewModel.newPostFor(GeohashId(it)) postViewModel.newPostFor(GeohashId(it))
} }
reply?.let { replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it) postViewModel.reply(it)
} }
draft?.let { draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it) postViewModel.editFromDraft(it)
} }
quote?.let { quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it) postViewModel.quote(it)
} }
message?.ifBlank { null }?.let { message?.ifBlank { null }?.let {
@@ -26,12 +26,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.viewmodel.compose.viewModel 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.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel
import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -42,9 +42,9 @@ fun HashtagPostScreen(
hashtag: String? = null, hashtag: String? = null,
message: String? = null, message: String? = null,
attachment: Uri? = null, attachment: Uri? = null,
reply: Note? = null, replyId: HexKey? = null,
quote: Note? = null, quoteId: HexKey? = null,
draft: Note? = null, draftId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: Nav, nav: Nav,
) { ) {
@@ -57,13 +57,13 @@ fun HashtagPostScreen(
hashtag?.let { hashtag?.let {
postViewModel.newPostFor(HashtagId(it)) postViewModel.newPostFor(HashtagId(it))
} }
reply?.let { replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it) postViewModel.reply(it)
} }
draft?.let { draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it) postViewModel.editFromDraft(it)
} }
quote?.let { quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it) postViewModel.quote(it)
} }
message?.ifBlank { null }?.let { message?.ifBlank { null }?.let {
@@ -25,7 +25,9 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -59,7 +61,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier 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.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -123,11 +127,11 @@ import kotlinx.coroutines.withContext
fun ShortNotePostScreen( fun ShortNotePostScreen(
message: String? = null, message: String? = null,
attachment: Uri? = null, attachment: Uri? = null,
baseReplyTo: Note? = null, baseReplyToId: HexKey? = null,
quote: Note? = null, quoteId: HexKey? = null,
fork: Note? = null, forkId: HexKey? = null,
version: Note? = null, versionId: HexKey? = null,
draft: Note? = null, draftId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: Nav, nav: Nav,
) { ) {
@@ -138,6 +142,11 @@ fun ShortNotePostScreen(
val activity = context.getActivity() val activity = context.getActivity()
LaunchedEffect(postViewModel, accountViewModel) { 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) postViewModel.load(baseReplyTo, quote, fork, version, draft)
message?.ifBlank { null }?.let { message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it)) postViewModel.updateMessage(TextFieldValue(it))
@@ -283,11 +292,32 @@ private fun NewPostScreenBody(
Row( Row(
modifier = Modifier.padding(vertical = Size10dp), modifier = Modifier.padding(vertical = Size10dp),
) { ) {
BaseUserPicture( if (postViewModel.wantsAnonymousPost) {
accountViewModel.userProfile(), IconButton(
Size35dp, modifier = Size35Modifier,
accountViewModel = accountViewModel, 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( MessageField(
R.string.what_s_on_your_mind, R.string.what_s_on_your_mind,
postViewModel, postViewModel,
@@ -117,6 +117,7 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag 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.IMetaTagBuilder
import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.alt import com.vitorpamplona.quartz.nip94FileMetadata.alt
@@ -238,6 +239,7 @@ open class ShortNotePostViewModel :
var canUsePoll by mutableStateOf(false) var canUsePoll by mutableStateOf(false)
var wantsPoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false)
var pollOptions: SnapshotStateMap<Int, OptionTag> = newStateMapPollOptions() var pollOptions: SnapshotStateMap<Int, OptionTag> = newStateMapPollOptions()
var pollType by mutableStateOf(PollType.SINGLE_CHOICE)
var closedAt by mutableLongStateOf(TimeUtils.oneDayAhead()) var closedAt by mutableLongStateOf(TimeUtils.oneDayAhead())
// ZapPolls // ZapPolls
@@ -283,6 +285,9 @@ open class ShortNotePostViewModel :
var wantsZapRaiser by mutableStateOf(false) var wantsZapRaiser by mutableStateOf(false)
override val zapRaiserAmount = mutableStateOf<Long?>(null) override val zapRaiserAmount = mutableStateOf<Long?>(null)
// Anonymous Reply
var wantsAnonymousPost by mutableStateOf(false)
fun lnAddress(): String? = account.userProfile().lnAddress() fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
@@ -336,6 +341,7 @@ open class ShortNotePostViewModel :
val currentMentions = val currentMentions =
(replyNote.event as? TextNoteEvent) (replyNote.event as? TextNoteEvent)
?.mentions() ?.mentions()
?.toSet()
?.map { LocalCache.getOrCreateUser(it.pubKey) } ?.map { LocalCache.getOrCreateUser(it.pubKey) }
?: emptyList() ?: emptyList()
@@ -490,8 +496,8 @@ open class ShortNotePostViewModel :
} }
pTags = pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.getOrCreateUser(it[1]) LocalCache.checkGetOrCreateUser(it[1])
} }
draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "fork" }.forEach { draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "fork" }.forEach {
@@ -576,8 +582,8 @@ open class ShortNotePostViewModel :
} }
pTags = pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.getOrCreateUser(it[1]) LocalCache.checkGetOrCreateUser(it[1])
} }
canUsePoll = originalNote == null canUsePoll = originalNote == null
@@ -596,6 +602,7 @@ open class ShortNotePostViewModel :
pollOptions[index] = tag pollOptions[index] = tag
} }
pollType = draftEvent.pollType() ?: PollType.SINGLE_CHOICE
closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead() closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead()
message = TextFieldValue(draftEvent.content) message = TextFieldValue(draftEvent.content)
@@ -647,8 +654,8 @@ open class ShortNotePostViewModel :
} }
pTags = pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.getOrCreateUser(it[1]) LocalCache.checkGetOrCreateUser(it[1])
} }
canUsePoll = originalNote == null canUsePoll = originalNote == null
@@ -712,9 +719,12 @@ open class ShortNotePostViewModel :
} }
val version = draftTag.current val version = draftTag.current
val anonymous = wantsAnonymousPost
cancel() cancel()
if (accountViewModel.settings.isCompleteUIMode()) { if (anonymous) {
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
} else if (accountViewModel.settings.isCompleteUIMode()) {
// Tracked broadcasting with progress feedback (non-blocking) // Tracked broadcasting with progress feedback (non-blocking)
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
@@ -828,7 +838,7 @@ open class ShortNotePostViewModel :
accountViewModel.account.nip65RelayList.outboxFlow.value accountViewModel.account.nip65RelayList.outboxFlow.value
.toList() .toList()
PollEvent.build(tagger.message, options, closedAt, relays) { PollEvent.build(tagger.message, options, closedAt, relays, pollType) {
pTags(tagger.directMentionsUsers.map { it.toPTag() }) pTags(tagger.directMentionsUsers.map { it.toPTag() })
quotes(quotes) quotes(quotes)
hashtags(findHashtags(tagger.message)) hashtags(findHashtags(tagger.message))
@@ -1054,6 +1064,7 @@ open class ShortNotePostViewModel :
wantsPoll = false wantsPoll = false
pollOptions = newStateMapPollOptions() pollOptions = newStateMapPollOptions()
pollType = PollType.SINGLE_CHOICE
closedAt = TimeUtils.oneDayAhead() closedAt = TimeUtils.oneDayAhead()
wantsZapPoll = false wantsZapPoll = false
@@ -1073,6 +1084,7 @@ open class ShortNotePostViewModel :
wantsToAddGeoHash = false wantsToAddGeoHash = false
wantsExclusiveGeoPost = false wantsExclusiveGeoPost = false
wantsSecretEmoji = false wantsSecretEmoji = false
wantsAnonymousPost = false
forwardZapTo.value = SplitBuilder() forwardZapTo.value = SplitBuilder()
forwardZapToEditting.value = TextFieldValue("") forwardZapToEditting.value = TextFieldValue("")
@@ -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.note.types.DisplayFollowList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@Composable @Composable
fun ImportFollowListPickFollowsScreen( fun ImportFollowListPickFollowsScreen(
contactListNote: AddressableNote, userHex: HexKey,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val contactListNote =
remember(userHex) {
accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(userHex))
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
@@ -125,6 +131,7 @@ fun DisplayFollowList(
val contactsState by observeNoteEventAndMap<ContactListEvent, ImmutableList<User>?>(contactListNote, accountViewModel) { contactList -> val contactsState by observeNoteEventAndMap<ContactListEvent, ImmutableList<User>?>(contactListNote, accountViewModel) { contactList ->
contactList contactList
?.unverifiedFollowKeySet() ?.unverifiedFollowKeySet()
?.toSet()
?.mapNotNull { ?.mapNotNull {
accountViewModel.checkGetOrCreateUser(it) accountViewModel.checkGetOrCreateUser(it)
}?.toPersistentList() }?.toPersistentList()
@@ -55,7 +55,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
@@ -108,8 +107,8 @@ import kotlinx.coroutines.withContext
@Composable @Composable
fun NewPublicMessageScreen( fun NewPublicMessageScreen(
to: Set<HexKey>? = null, to: Set<HexKey>? = null,
reply: Note? = null, replyId: HexKey? = null,
draft: Note? = null, draftId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: Nav, nav: Nav,
) { ) {
@@ -121,10 +120,10 @@ fun NewPublicMessageScreen(
to?.let { to?.let {
postViewModel.load(it) postViewModel.load(it)
} }
reply?.let { replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it) postViewModel.reply(it)
} }
draft?.let { draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it) postViewModel.editFromDraft(it)
} }
} }
@@ -229,6 +229,7 @@ fun PrepareViewModels(
factory = factory =
UserProfileReportFeedViewModel.Factory( UserProfileReportFeedViewModel.Factory(
baseUser, baseUser,
accountViewModel.account,
), ),
) )
@@ -273,7 +274,6 @@ fun ProfileScreen(
WatchLifecycleAndUpdateModel(appRecommendations) WatchLifecycleAndUpdateModel(appRecommendations)
WatchLifecycleAndUpdateModel(bookmarksFeedViewModel) WatchLifecycleAndUpdateModel(bookmarksFeedViewModel)
WatchLifecycleAndUpdateModel(galleryFeedViewModel) WatchLifecycleAndUpdateModel(galleryFeedViewModel)
WatchLifecycleAndUpdateModel(reportsFeedViewModel)
UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile) UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile)
@@ -521,7 +521,7 @@ private fun CreateAndRenderTabs(
{ ZapTabHeader(zapFeedViewModel, accountViewModel) }, { ZapTabHeader(zapFeedViewModel, accountViewModel) },
{ BookmarkTabHeader(baseUser, accountViewModel) }, { BookmarkTabHeader(baseUser, accountViewModel) },
{ FollowedTagsTabHeader(baseUser, accountViewModel) }, { FollowedTagsTabHeader(baseUser, accountViewModel) },
{ ReportsTabHeader(baseUser, accountViewModel) }, { ReportsTabHeader(baseUser, reportsFeedViewModel, accountViewModel) },
{ RelaysTabHeader(baseUser, accountViewModel) }, { RelaysTabHeader(baseUser, accountViewModel) },
) )
@@ -54,8 +54,8 @@ class UserProfileFollowersUserFeedViewModel(
{ it.pubkeyHex }, { it.pubkeyHex },
) )
fun List<Event>.toNonHiddenOwners(): List<User> = fun List<Event>.toNonHiddenOwners(): Set<User> =
mapNotNull { event -> mapNotNullTo(mutableSetOf()) { event ->
if (!account.isHidden(event.pubKey)) { if (!account.isHidden(event.pubKey)) {
account.cache.getOrCreateUser(event.pubKey) account.cache.getOrCreateUser(event.pubKey)
} else { } else {
@@ -105,6 +105,9 @@ private fun RenderRelayRow(
onRemoveRelay = { onRemoveRelay = {
nav.nav(Route.EditRelays) nav.nav(Route.EditRelays)
}, },
onClick = {
nav.nav(Route.RelayInfo(relay.url.url))
},
) )
HorizontalDivider( HorizontalDivider(
thickness = DividerThickness, thickness = DividerThickness,
@@ -23,18 +23,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@Composable @Composable
fun ReportsTabHeader( fun ReportsTabHeader(
baseUser: User, baseUser: User,
reportsFeedViewModel: UserProfileReportFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val reportCount by observeUserReportCount(baseUser, accountViewModel) val reportCount by reportsFeedViewModel.followerCount.collectAsStateWithLifecycle()
if (reportCount > 0) { if (reportCount > 0) {
Text(text = stringRes(R.string.number_reports, reportCount)) Text(text = stringRes(R.string.number_reports, reportCount))
@@ -21,14 +21,25 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight 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.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel 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 @Composable
fun TabReports( fun TabReports(
@@ -37,15 +48,36 @@ fun TabReports(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
WatchReportsAndUpdateFeed(baseUser, feedViewModel, accountViewModel)
Column(Modifier.fillMaxHeight()) { Column(Modifier.fillMaxHeight()) {
RefresheableFeedView( val items by feedViewModel.followersFlow.collectAsStateWithLifecycle()
feedViewModel,
null, LazyColumn(
enablePullRefresh = false, modifier = Modifier.fillMaxSize(),
accountViewModel = accountViewModel, contentPadding = FeedPadding,
nav = nav, 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,
)
}
}
} }
} }
@@ -23,17 +23,59 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider 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.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 @Stable
class UserProfileReportFeedViewModel( class UserProfileReportFeedViewModel(
val user: User, val user: User,
) : AndroidFeedViewModel(UserProfileReportsFeedFilter(user)) { val account: Account,
) : ViewModel() {
val sortingModel: Comparator<Note> =
compareBy(
{ it.author?.let { !account.isFollowing(it) } },
{ it.idHex },
)
@OptIn(kotlinx.coroutines.FlowPreview::class)
val followersFlow: StateFlow<List<Note>> =
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( class Factory(
val user: User, val user: User,
val account: Account,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = UserProfileReportFeedViewModel(user) as T override fun <T : ViewModel> create(modelClass: Class<T>): T = UserProfileReportFeedViewModel(user, account) as T
} }
} }
@@ -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<Note>() {
override fun feedKey(): String = user.pubkeyHex
override fun feed(): List<Note> = sort(innerApplyFilter(user.reportsOrNull()?.all() ?: emptyList()))
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> =
collection
.filterTo(mutableSetOf()) {
it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
override fun limit() = 400
}
@@ -47,7 +47,7 @@ val FollowsFollow = Color.Yellow
val NIP05Verified = Color.Blue val NIP05Verified = Color.Blue
val Nip05EmailColor = Color(0xFFb198ec) val Nip05EmailColor = Color(0xFFb198ec)
val Nip05EmailColorDark = Color(0xFF6e5490) val Nip05EmailColorDark = Color(0xFF765AA2)
val Nip05EmailColorLight = Color(0xFFa770f3) val Nip05EmailColorLight = Color(0xFFa770f3)
val DarkerGreen = Color.Green.copy(alpha = 0.32f) val DarkerGreen = Color.Green.copy(alpha = 0.32f)
@@ -81,7 +81,7 @@ class TorManager(
val activePortOrNull: StateFlow<Int?> = val activePortOrNull: StateFlow<Int?> =
status status
.map { .map {
(status.value as? TorServiceStatus.Active)?.port (it as? TorServiceStatus.Active)?.port
}.stateIn( }.stateIn(
scope, scope,
SharingStarted.WhileSubscribed(2000), SharingStarted.WhileSubscribed(2000),
@@ -31,12 +31,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.torproject.jni.TorService import org.torproject.jni.TorService
import org.torproject.jni.TorService.LocalBinder import org.torproject.jni.TorService.LocalBinder
private const val SOCKS_PORT_POLL_INTERVAL_MS = 100L
class TorService( class TorService(
val context: Context, val context: Context,
) { ) {
@@ -46,9 +47,7 @@ class TorService(
trySend(TorServiceStatus.Connecting) trySend(TorServiceStatus.Connecting)
val currentIntent = Intent(context, TorService::class.java) val currentIntent = Intent(context, TorService::class.java)
val serviceConnection: ServiceConnection =
context.bindService(
currentIntent,
object : ServiceConnection { object : ServiceConnection {
override fun onServiceConnected( override fun onServiceConnected(
name: ComponentName, name: ComponentName,
@@ -59,7 +58,7 @@ class TorService(
val torService = (service as LocalBinder).service val torService = (service as LocalBinder).service
while (torService.socksPort < 0) { while (torService.socksPort < 0) {
delay(100) delay(SOCKS_PORT_POLL_INTERVAL_MS)
} }
val active = TorServiceStatus.Active(torService.socksPort) val active = TorServiceStatus.Active(torService.socksPort)
@@ -74,16 +73,25 @@ class TorService(
Log.d("TorService", "Tor Service Disconnected") Log.d("TorService", "Tor Service Disconnected")
trySend(TorServiceStatus.Off) trySend(TorServiceStatus.Off)
} }
}, }
context.bindService(
currentIntent,
serviceConnection,
BIND_AUTO_CREATE, BIND_AUTO_CREATE,
) )
awaitClose { awaitClose {
Log.d("TorService", "Stopping Tor Service") Log.d("TorService", "Stopping Tor Service")
try {
context.unbindService(serviceConnection)
} catch (e: Exception) {
Log.d("TorService", "Failed to unbind Tor Service: ${e.message}")
}
launch { launch {
context.stopService(currentIntent) context.stopService(currentIntent)
} }
trySend(TorServiceStatus.Off) trySend(TorServiceStatus.Off)
} }
}.distinctUntilChanged().flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
} }
@@ -537,7 +537,7 @@
<string name="follow_set_create_btn_label">Nový</string> <string name="follow_set_create_btn_label">Nový</string>
<string name="follow_set_add_author_from_note_action">Přidat autora do seznamu sledování</string> <string name="follow_set_add_author_from_note_action">Přidat autora do seznamu sledování</string>
<string name="follow_set_profile_actions_menu_description">Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem.</string> <string name="follow_set_profile_actions_menu_description">Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem.</string>
<string name="follow_set_icon_description">Ikona pro seznam %1$s</string> <string name="follow_set_icon_description">Ikona pro seznam</string>
<string name="follow_set_public_presence_indicator">%1$s je veřejný člen</string> <string name="follow_set_public_presence_indicator">%1$s je veřejný člen</string>
<string name="follow_set_private_presence_indicator">%1$s je soukromý člen</string> <string name="follow_set_private_presence_indicator">%1$s je soukromý člen</string>
<string name="follow_set_public_member_add_label">Přidat jako veřejného člena</string> <string name="follow_set_public_member_add_label">Přidat jako veřejného člena</string>
+1 -1
View File
@@ -961,7 +961,7 @@
<string name="follow_set_empty_feed_msg">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é.</string> <string name="follow_set_empty_feed_msg">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é.</string>
<string name="follow_set_add_author_from_note_action">Přidat autora do sady sledování</string> <string name="follow_set_add_author_from_note_action">Přidat autora do sady sledování</string>
<string name="follow_set_profile_actions_menu_description">Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem.</string> <string name="follow_set_profile_actions_menu_description">Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem.</string>
<string name="follow_set_icon_description">Ikona pro seznam %1$s</string> <string name="follow_set_icon_description">Ikona pro seznam</string>
<string name="follow_set_absence_indicator">%1$s není v tomto seznamu</string> <string name="follow_set_absence_indicator">%1$s není v tomto seznamu</string>
<string name="follow_set_man_dialog_title">Vaše sady sledování</string> <string name="follow_set_man_dialog_title">Vaše sady sledování</string>
<string name="follow_set_empty_dialog_msg">Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové.</string> <string name="follow_set_empty_dialog_msg">Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové.</string>
@@ -543,7 +543,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_set_create_btn_label">Neu</string> <string name="follow_set_create_btn_label">Neu</string>
<string name="follow_set_add_author_from_note_action">Autor zur Follower-Liste hinzufügen</string> <string name="follow_set_add_author_from_note_action">Autor zur Follower-Liste hinzufügen</string>
<string name="follow_set_profile_actions_menu_description">Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen.</string> <string name="follow_set_profile_actions_menu_description">Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen.</string>
<string name="follow_set_icon_description">Symbol für %1$s-Liste</string> <string name="follow_set_icon_description">Symbol für Liste</string>
<string name="follow_set_public_presence_indicator">%1$s ist ein öffentliches Mitglied</string> <string name="follow_set_public_presence_indicator">%1$s ist ein öffentliches Mitglied</string>
<string name="follow_set_private_presence_indicator">%1$s ist ein privates Mitglied</string> <string name="follow_set_private_presence_indicator">%1$s ist ein privates Mitglied</string>
<string name="follow_set_public_member_add_label">Als öffentliches Mitglied hinzufügen</string> <string name="follow_set_public_member_add_label">Als öffentliches Mitglied hinzufügen</string>
+1 -1
View File
@@ -1001,7 +1001,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_set_empty_feed_msg">Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen.</string> <string name="follow_set_empty_feed_msg">Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen.</string>
<string name="follow_set_add_author_from_note_action">Autor zum Folge-Set hinzufügen</string> <string name="follow_set_add_author_from_note_action">Autor zum Folge-Set hinzufügen</string>
<string name="follow_set_profile_actions_menu_description">Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen.</string> <string name="follow_set_profile_actions_menu_description">Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen.</string>
<string name="follow_set_icon_description">Symbol für %1$s-Liste</string> <string name="follow_set_icon_description">Symbol für Liste</string>
<string name="follow_set_absence_indicator">%1$s ist nicht in dieser Liste</string> <string name="follow_set_absence_indicator">%1$s ist nicht in dieser Liste</string>
<string name="follow_set_man_dialog_title">Deine Folge-Sets</string> <string name="follow_set_man_dialog_title">Deine Folge-Sets</string>
<string name="follow_set_empty_dialog_msg">Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen.</string> <string name="follow_set_empty_dialog_msg">Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen.</string>
@@ -290,6 +290,7 @@
<string name="nip_05">Nostr-cím</string> <string name="nip_05">Nostr-cím</string>
<string name="never">soha</string> <string name="never">soha</string>
<string name="now">most</string> <string name="now">most</string>
<string name="seconds">másodperc</string>
<string name="h">ó</string> <string name="h">ó</string>
<string name="m">p</string> <string name="m">p</string>
<string name="d">n</string> <string name="d">n</string>
@@ -447,6 +448,8 @@
<string name="poll_zap_value_max">Maximum Zap</string> <string name="poll_zap_value_max">Maximum Zap</string>
<string name="poll_consensus_threshold">Együttműködés</string> <string name="poll_consensus_threshold">Együttműködés</string>
<string name="poll_consensus_threshold_percent">(0100)%</string> <string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Egyetlen lehetőség</string>
<string name="poll_multiple_choice">Több lehetőség</string>
<string name="poll_closing_date_time">Szavazás lezárásának dátuma és ideje</string> <string name="poll_closing_date_time">Szavazás lezárásának dátuma és ideje</string>
<string name="poll_closing_in">A szavazás lezárul %1$s múlva</string> <string name="poll_closing_in">A szavazás lezárul %1$s múlva</string>
<string name="poll_closing_time">Szavazás lezárása</string> <string name="poll_closing_time">Szavazás lezárása</string>
@@ -1180,7 +1183,7 @@
<string name="outbox_relays_title">Átjátszók a kimenő üzenetkhez</string> <string name="outbox_relays_title">Átjátszók a kimenő üzenetkhez</string>
<string name="outbox_relays_not_found">Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez</string> <string name="outbox_relays_not_found">Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez</string>
<string name="outbox_relays_not_found_description">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. </string> <string name="outbox_relays_not_found_description">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. </string>
<string name="outbox_relays_not_found_editing">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</string> <string name="outbox_relays_not_found_editing">Adjon meg 13 á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</string>
<string name="outbox_relays_not_found_examples">Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string> <string name="outbox_relays_not_found_examples">Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Átjátszók a bejövő üzenetkhez</string> <string name="inbox_relays_title">Átjátszók a bejövő üzenetkhez</string>
<string name="inbox_relays_not_found">Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához</string> <string name="inbox_relays_not_found">Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához</string>
@@ -115,7 +115,7 @@
<string name="yes"></string> <string name="yes"></string>
<string name="no"></string> <string name="no"></string>
<string name="follow_list_selection">Sekot saraksts</string> <string name="follow_list_selection">Sekot saraksts</string>
<string name="follow_set_icon_description">Ikona %1$s sarakstam</string> <string name="follow_set_icon_description">Ikona sarakstam</string>
<string name="follow_set_creation_menu_title">Izveidot jaunu sarakstu</string> <string name="follow_set_creation_menu_title">Izveidot jaunu sarakstu</string>
<string name="follow_set_creation_dialog_title">Jaunais %1$s saraksts</string> <string name="follow_set_creation_dialog_title">Jaunais %1$s saraksts</string>
<string name="follow_set_creation_name_label">Kolekcijas nosaukums</string> <string name="follow_set_creation_name_label">Kolekcijas nosaukums</string>
+17 -14
View File
@@ -181,7 +181,7 @@
<string name="voice_preset_neutral">Neutralna</string> <string name="voice_preset_neutral">Neutralna</string>
<string name="voice_anonymize_title">Anonimowy</string> <string name="voice_anonymize_title">Anonimowy</string>
<string name="voice_anonymize_description">Zmienia barwę głosu. Uwaga: podstawowe zmiany barwy głosu mogą zostać wykryte przez uważnych słuchaczy.</string> <string name="voice_anonymize_description">Zmienia barwę głosu. Uwaga: podstawowe zmiany barwy głosu mogą zostać wykryte przez uważnych słuchaczy.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satsy</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satosze</string>
<string name="reply_here">"odpowiedz tutaj.. "</string> <string name="reply_here">"odpowiedz tutaj.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopiuje ID wpisu do schowka w celu udostępnienia w Nostr</string> <string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopiuje ID wpisu do schowka w celu udostępnienia w Nostr</string>
<string name="copy_channel_id_note_to_the_clipboard">Kopiuj ID kanału (wpisu) do schowka</string> <string name="copy_channel_id_note_to_the_clipboard">Kopiuj ID kanału (wpisu) do schowka</string>
@@ -287,6 +287,7 @@
<string name="nip_05">Adres Nostr</string> <string name="nip_05">Adres Nostr</string>
<string name="never">nigdy</string> <string name="never">nigdy</string>
<string name="now">teraz</string> <string name="now">teraz</string>
<string name="seconds">sekund</string>
<string name="h">godz.</string> <string name="h">godz.</string>
<string name="m">m</string> <string name="m">m</string>
<string name="d">d</string> <string name="d">d</string>
@@ -431,7 +432,7 @@
<string name="zap_type_section_explainer">Kontroluje sposób wyświetlania Twojej tożsamości podczas wysyłania zapa.</string> <string name="zap_type_section_explainer">Kontroluje sposób wyświetlania Twojej tożsamości podczas wysyłania zapa.</string>
<string name="wallet_connect_connect_app">Podłącz portfel</string> <string name="wallet_connect_connect_app">Podłącz portfel</string>
<string name="see_relay_feed">Przejrzyj kanał transmitera</string> <string name="see_relay_feed">Przejrzyj kanał transmitera</string>
<string name="pledge_amount_in_sats">Kwota zobowiązania w Satach</string> <string name="pledge_amount_in_sats">Kwota zobowiązania w Satoszach</string>
<string name="post_poll">Wyślij Ankietę</string> <string name="post_poll">Wyślij Ankietę</string>
<string name="poll_heading_required">Wymagane pola:</string> <string name="poll_heading_required">Wymagane pola:</string>
<string name="poll_zap_recipients">Odbiorcy zap</string> <string name="poll_zap_recipients">Odbiorcy zap</string>
@@ -444,6 +445,8 @@
<string name="poll_zap_value_max">Maksymalny Zap</string> <string name="poll_zap_value_max">Maksymalny Zap</string>
<string name="poll_consensus_threshold">Konsensus</string> <string name="poll_consensus_threshold">Konsensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string> <string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Pojedynczy wybór</string>
<string name="poll_multiple_choice">Wielokrotny wybór</string>
<string name="poll_closing_date_time">Data &amp; godzina zakończenia ankiety</string> <string name="poll_closing_date_time">Data &amp; godzina zakończenia ankiety</string>
<string name="poll_closing_in">Ankieta zostanie zamknięta za %1$s</string> <string name="poll_closing_in">Ankieta zostanie zamknięta za %1$s</string>
<string name="poll_closing_time">Zamknij po</string> <string name="poll_closing_time">Zamknij po</string>
@@ -537,7 +540,7 @@
<string name="follow_set_create_btn_label">Nowa</string> <string name="follow_set_create_btn_label">Nowa</string>
<string name="follow_set_add_author_from_note_action">Dodaj autora do listy obserwowanych</string> <string name="follow_set_add_author_from_note_action">Dodaj autora do listy obserwowanych</string>
<string name="follow_set_profile_actions_menu_description">Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem.</string> <string name="follow_set_profile_actions_menu_description">Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem.</string>
<string name="follow_set_icon_description">Ikona dla listy %1$s</string> <string name="follow_set_icon_description">Ikona dla listy</string>
<string name="follow_set_public_presence_indicator">%1$s jest uczestnikiem publicznym</string> <string name="follow_set_public_presence_indicator">%1$s jest uczestnikiem publicznym</string>
<string name="follow_set_private_presence_indicator">%1$s jest uczestnikiem prywatnym</string> <string name="follow_set_private_presence_indicator">%1$s jest uczestnikiem prywatnym</string>
<string name="follow_set_public_member_add_label">Dodaj jako uczestnika publicznego</string> <string name="follow_set_public_member_add_label">Dodaj jako uczestnika publicznego</string>
@@ -633,7 +636,7 @@
<string name="app_notification_dms_channel_description">Powiadamia Cię, gdy nadejdzie prywatna wiadomość</string> <string name="app_notification_dms_channel_description">Powiadamia Cię, gdy nadejdzie prywatna wiadomość</string>
<string name="app_notification_zaps_channel_name">Otrzymano Zapy</string> <string name="app_notification_zaps_channel_name">Otrzymano Zapy</string>
<string name="app_notification_zaps_channel_description">Powiadamia Cię, gdy ktoś prześle ci zapy</string> <string name="app_notification_zaps_channel_description">Powiadamia Cię, gdy ktoś prześle ci zapy</string>
<string name="app_notification_zaps_channel_message">%1$s Satsów</string> <string name="app_notification_zaps_channel_message">%1$s Satoszy</string>
<string name="app_notification_zaps_channel_message_from">Od %1$s</string> <string name="app_notification_zaps_channel_message_from">Od %1$s</string>
<string name="app_notification_zaps_channel_message_for">dla %1$s</string> <string name="app_notification_zaps_channel_message_for">dla %1$s</string>
<string name="app_notification_reply_label">Odpowiedz</string> <string name="app_notification_reply_label">Odpowiedz</string>
@@ -668,9 +671,9 @@
<string name="new_reaction_symbol">Nowy Symbol Odzewu</string> <string name="new_reaction_symbol">Nowy Symbol Odzewu</string>
<string name="no_reaction_type_setup_long_press_to_change">Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić</string> <string name="no_reaction_type_setup_long_press_to_change">Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić</string>
<string name="zapraiser">Zapraiser</string> <string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">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</string> <string name="zapraiser_explainer">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</string>
<string name="zapraiser_target_amount_in_sats">Docelowa kwota w Satach</string> <string name="zapraiser_target_amount_in_sats">Docelowa kwota w Satoszach</string>
<string name="sats_to_complete">Zapraiser przy: %1$s. %2$s satach do celu</string> <string name="sats_to_complete">Zapraiser przy: %1$s. %2$s satoszach do celu</string>
<string name="read_from_relay">Odczytaj z Transmitera</string> <string name="read_from_relay">Odczytaj z Transmitera</string>
<string name="write_to_relay">Zapisz do Transmitera</string> <string name="write_to_relay">Zapisz do Transmitera</string>
<string name="write_to_relay_description">Ilość w bajtach, która została wysłana do tego transmitera, w tym filtry i wydarzenia</string> <string name="write_to_relay_description">Ilość w bajtach, która została wysłana do tego transmitera, w tym filtry i wydarzenia</string>
@@ -878,7 +881,7 @@
<string name="copy_to_clipboard">Kopiuj do schowka</string> <string name="copy_to_clipboard">Kopiuj do schowka</string>
<string name="copy_nprofile_to_clipboard">Skopiuj nprofile do schowka</string> <string name="copy_nprofile_to_clipboard">Skopiuj nprofile do schowka</string>
<string name="copy_npub_to_clipboard">Kopiuj npub do schowka</string> <string name="copy_npub_to_clipboard">Kopiuj npub do schowka</string>
<string name="share_or_save">Udostępnij lub Zapisz</string> <string name="share_or_save">Udostępnij lub zapisz</string>
<string name="copy_url_to_clipboard">Kopiuj adres URL do schowka</string> <string name="copy_url_to_clipboard">Kopiuj adres URL do schowka</string>
<string name="copy_the_note_id_to_the_clipboard">Kopiuj ID wpisu do schowka</string> <string name="copy_the_note_id_to_the_clipboard">Kopiuj ID wpisu do schowka</string>
<string name="add_media_to_gallery">Dodaj pliki do Galerii</string> <string name="add_media_to_gallery">Dodaj pliki do Galerii</string>
@@ -916,7 +919,7 @@
<string name="zap_split_search_and_add_user">Szukaj i dodaj użytkownika</string> <string name="zap_split_search_and_add_user">Szukaj i dodaj użytkownika</string>
<string name="zap_split_search_and_add_user_placeholder">Nick lub Login</string> <string name="zap_split_search_and_add_user_placeholder">Nick lub Login</string>
<string name="missing_lud16">Brakująca konfiguracja LN</string> <string name="missing_lud16">Brakująca konfiguracja LN</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satsy</string> <string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satosze</string>
<string name="zap_split_weight">Procentowo</string> <string name="zap_split_weight">Procentowo</string>
<string name="zap_split_weight_placeholder">25</string> <string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Podziel zapsy z</string> <string name="splitting_zaps_with">Podziel zapsy z</string>
@@ -947,7 +950,7 @@
<string name="cashu_failed_redemption_explainer_error_msg">Mint dostarczył następujący komunikat błędu: %1$s</string> <string name="cashu_failed_redemption_explainer_error_msg">Mint dostarczył następujący komunikat błędu: %1$s</string>
<string name="cashu_failed_redemption_explainer_already_spent">Tokeny Cashu zostały już wydane.</string> <string name="cashu_failed_redemption_explainer_already_spent">Tokeny Cashu zostały już wydane.</string>
<string name="cashu_successful_redemption">Cashu odebrano</string> <string name="cashu_successful_redemption">Cashu odebrano</string>
<string name="cashu_successful_redemption_explainer">%1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satsów)</string> <string name="cashu_successful_redemption_explainer">%1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satoszy)</string>
<string name="cashu_no_wallet_found">W systemie nie znaleziono kompatybilnego portfela Cashu</string> <string name="cashu_no_wallet_found">W systemie nie znaleziono kompatybilnego portfela Cashu</string>
<string name="error_unable_to_fetch_invoice">Nie można pobrać faktury z serwerów odbiorcy</string> <string name="error_unable_to_fetch_invoice">Nie można pobrać faktury z serwerów odbiorcy</string>
<string name="wallet_connect_pay_invoice_error_error">Twój dostawca połączenia z portfelem zwrócił następujący błąd: %1$s</string> <string name="wallet_connect_pay_invoice_error_error">Twój dostawca połączenia z portfelem zwrócił następujący błąd: %1$s</string>
@@ -965,7 +968,7 @@
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">Nie znaleziono zwrotnego adresu URL z odpowiedzi %1$s</string> <string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">Nie znaleziono zwrotnego adresu URL z odpowiedzi %1$s</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">Wystąpił błąd podczas analizowania JSON z pobierania faktury z Lightning Adresu. Sprawdź konfigurację lightning użytkownika</string> <string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">Wystąpił błąd podczas analizowania JSON z pobierania faktury z Lightning Adresu. Sprawdź konfigurację lightning użytkownika</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika</string> <string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika</string>
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">Nieprawidłowa kwota faktury (%1$s satsów) od %2$s. Powinieno być %3$s</string> <string name="incorrect_invoice_amount_sats_from_it_should_have_been">Nieprawidłowa kwota faktury (%1$s satoszy) od %2$s. Powinieno być %3$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">Nie można utworzyć faktury przed wysłaniem zapa. Portfel odbiorcy wysłał następujący błąd: %1$s</string> <string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">Nie można utworzyć faktury przed wysłaniem zapa. Portfel odbiorcy wysłał następujący błąd: %1$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Nie można utworzyć faktury. Wiadomość od %1$s: %2$s</string> <string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Nie można utworzyć faktury. Wiadomość od %1$s: %2$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">Nie można utworzyć faktury przed wysłaniem zapa. Element pr nie został znaleziony w powstałym JSON.</string> <string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">Nie można utworzyć faktury przed wysłaniem zapa. Element pr nie został znaleziony w powstałym JSON.</string>
@@ -992,7 +995,7 @@
<string name="classifieds_title_placeholder">iPhone 13</string> <string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Stan</string> <string name="classifieds_condition">Stan</string>
<string name="classifieds_category">Kategoria</string> <string name="classifieds_category">Kategoria</string>
<string name="classifieds_price">Cena (w Satach)</string> <string name="classifieds_price">Cena (w Satoszach)</string>
<string name="classifieds_price_placeholder">1000</string> <string name="classifieds_price_placeholder">1000</string>
<string name="classifieds_location">Lokalizacja</string> <string name="classifieds_location">Lokalizacja</string>
<string name="classifieds_location_placeholder">Miasto, Województwo, Kraj</string> <string name="classifieds_location_placeholder">Miasto, Województwo, Kraj</string>
@@ -1405,7 +1408,7 @@
<string name="share_of">%1$d/%2$d</string> <string name="share_of">%1$d/%2$d</string>
<string name="broadcasting">Przekaz</string> <string name="broadcasting">Przekaz</string>
<string name="broadcasting_name">Przekaz %1$s</string> <string name="broadcasting_name">Przekaz %1$s</string>
<string name="broadcasting_number_events">Liczba przekazów: %1$d...</string> <string name="broadcasting_number_events">Liczba przekazów: %1$d</string>
<string name="sent_number_events">Wysłanych przekazów: %1$d</string> <string name="sent_number_events">Wysłanych przekazów: %1$d</string>
<string name="bradcasting_result_partial">Niektóre akcje nie powiodły się</string> <string name="bradcasting_result_partial">Niektóre akcje nie powiodły się</string>
<string name="bradcasting_result_success">Wszystkie akcje udane</string> <string name="bradcasting_result_success">Wszystkie akcje udane</string>
@@ -1625,7 +1628,7 @@
<string name="event_sync_log_new">nowa %1$s</string> <string name="event_sync_log_new">nowa %1$s</string>
<string name="event_sync_no_events">brak wydarzeń</string> <string name="event_sync_no_events">brak wydarzeń</string>
<string name="ots_explorer_settings">Eksplorator Bitcoin (OTS)</string> <string name="ots_explorer_settings">Eksplorator Bitcoin (OTS)</string>
<string name="events">wydarzenia</string> <string name="events">wydarzeń</string>
<string name="dms">DMs</string> <string name="dms">DMs</string>
<string name="profiles">profile</string> <string name="profiles">profile</string>
<string name="relay_settings_lower">ustawienia transmiterów</string> <string name="relay_settings_lower">ustawienia transmiterów</string>
@@ -537,7 +537,7 @@
<string name="follow_set_create_btn_label">Novo</string> <string name="follow_set_create_btn_label">Novo</string>
<string name="follow_set_add_author_from_note_action">Adicionar autor à lista de seguidores</string> <string name="follow_set_add_author_from_note_action">Adicionar autor à lista de seguidores</string>
<string name="follow_set_profile_actions_menu_description">Adicionar ou remover usuário de listas, ou criar uma nova lista com este usuário.</string> <string name="follow_set_profile_actions_menu_description">Adicionar ou remover usuário de listas, ou criar uma nova lista com este usuário.</string>
<string name="follow_set_icon_description">Ícone da lista %1$s</string> <string name="follow_set_icon_description">Ícone da lista</string>
<string name="follow_set_public_presence_indicator">%1$s é um membro público</string> <string name="follow_set_public_presence_indicator">%1$s é um membro público</string>
<string name="follow_set_private_presence_indicator">%1$s é um membro privado</string> <string name="follow_set_private_presence_indicator">%1$s é um membro privado</string>
<string name="follow_set_public_member_add_label">Adicionar como membro público</string> <string name="follow_set_public_member_add_label">Adicionar como membro público</string>
@@ -1419,7 +1419,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="share_of">%1$d/%2$d</string> <string name="share_of">%1$d/%2$d</string>
<string name="broadcasting">Oddajam</string> <string name="broadcasting">Oddajam</string>
<string name="broadcasting_name">Oddajam %1$s</string> <string name="broadcasting_name">Oddajam %1$s</string>
<string name="broadcasting_number_events">Oddajam %1$d dogodkov...</string> <string name="broadcasting_number_events">Oddajam %1$d dogodkov</string>
<string name="sent_number_events">Poslano %1$d dogodkov</string> <string name="sent_number_events">Poslano %1$d dogodkov</string>
<string name="bradcasting_result_partial">Nekaterim dogodkom ni uspelo</string> <string name="bradcasting_result_partial">Nekaterim dogodkom ni uspelo</string>
<string name="bradcasting_result_success">Vsem dogodkom je uspelo</string> <string name="bradcasting_result_success">Vsem dogodkom je uspelo</string>
@@ -537,7 +537,7 @@
<string name="follow_set_create_btn_label">Ny</string> <string name="follow_set_create_btn_label">Ny</string>
<string name="follow_set_add_author_from_note_action">Lägg till författare i följelista</string> <string name="follow_set_add_author_from_note_action">Lägg till författare i följelista</string>
<string name="follow_set_profile_actions_menu_description">Lägg till eller ta bort användare från listor, eller skapa en ny lista med denna användare.</string> <string name="follow_set_profile_actions_menu_description">Lägg till eller ta bort användare från listor, eller skapa en ny lista med denna användare.</string>
<string name="follow_set_icon_description">Ikon för %1$s-lista</string> <string name="follow_set_icon_description">Ikon för lista</string>
<string name="follow_set_public_presence_indicator">%1$s är en offentlig medlem</string> <string name="follow_set_public_presence_indicator">%1$s är en offentlig medlem</string>
<string name="follow_set_private_presence_indicator">%1$s är en privat medlem</string> <string name="follow_set_private_presence_indicator">%1$s är en privat medlem</string>
<string name="follow_set_public_member_add_label">Lägg till som offentlig medlem</string> <string name="follow_set_public_member_add_label">Lägg till som offentlig medlem</string>
@@ -1181,7 +1181,7 @@
<string name="outbox_relays_title">发件箱中继</string> <string name="outbox_relays_title">发件箱中继</string>
<string name="outbox_relays_not_found">设置您的公共发件箱中继来发布内容</string> <string name="outbox_relays_not_found">设置您的公共发件箱中继来发布内容</string>
<string name="outbox_relays_not_found_description">创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 </string> <string name="outbox_relays_not_found_description">创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 </string>
<string name="outbox_relays_not_found_editing">插入 1-3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入</string> <string name="outbox_relays_not_found_editing">插入 13 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入</string>
<string name="outbox_relays_not_found_examples">好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string> <string name="outbox_relays_not_found_examples">好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">收件箱中继</string> <string name="inbox_relays_title">收件箱中继</string>
<string name="inbox_relays_not_found">设置您的公共收件箱中继来接收通知</string> <string name="inbox_relays_not_found">设置您的公共收件箱中继来接收通知</string>
+6
View File
@@ -483,6 +483,8 @@
<string name="poll_zap_value_max">Zap maximum</string> <string name="poll_zap_value_max">Zap maximum</string>
<string name="poll_consensus_threshold">Consensus</string> <string name="poll_consensus_threshold">Consensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string> <string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Single choice</string>
<string name="poll_multiple_choice">Multiple choice</string>
<string name="poll_closing_date_time">Poll Closing Date &amp; Time</string> <string name="poll_closing_date_time">Poll Closing Date &amp; Time</string>
<string name="poll_closing_in">Poll closes in %1$s</string> <string name="poll_closing_in">Poll closes in %1$s</string>
<string name="poll_closing_time">Close after</string> <string name="poll_closing_time">Close after</string>
@@ -539,6 +541,10 @@
<string name="zap_type_nonzap_explainer">No trace in Nostr, only in Lightning</string> <string name="zap_type_nonzap_explainer">No trace in Nostr, only in Lightning</string>
<string name="post_anonymously">Anonymous</string>
<string name="post_anonymously_explainer">Post as a new throwaway identity. Your account will not be linked to this reply.</string>
<string name="anonymous_reply_warning">This reply will be posted from a new anonymous identity</string>
<string name="file_server">File Server</string> <string name="file_server">File Server</string>
<string name="file_server_description">Choose a server to upload this file to</string> <string name="file_server_description">Choose a server to upload this file to</string>
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.commons.richtext
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.startsWithAny import com.vitorpamplona.quartz.utils.startsWithAny
import com.vitorpamplona.quartz.utils.urldetector.Url import com.vitorpamplona.quartz.utils.urldetector.Url
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
import kotlinx.coroutines.CancellationException
@Stable @Stable
class Urls( class Urls(
@@ -77,30 +79,35 @@ class UrlParser {
val blossom = mutableSetOf<String>() val blossom = mutableSetOf<String>()
urls.forEach { url -> urls.forEach { url ->
if (url.isValidTopLevelDomain()) { try {
if (url.wroteWithSchema()) { if (url.isValidTopLevelDomain()) {
if (url.originalUrl.startsWithAny(httpScheme)) { if (url.wroteWithSchema()) {
// quick exit if (url.originalUrl.startsWithAny(httpScheme)) {
completeUrls.add(url.originalUrl) // quick exit
} else if (url.originalUrl.startsWithAny(nostrScheme)) { completeUrls.add(url.originalUrl)
bech32.add(url.originalUrl) } else if (url.originalUrl.startsWithAny(nostrScheme)) {
} else if (url.originalUrl.startsWithAny(websocketScheme)) { bech32.add(url.originalUrl)
relays.add(url.originalUrl) } else if (url.originalUrl.startsWithAny(websocketScheme)) {
} else if (url.originalUrl.startsWithAny(blossomScheme)) { relays.add(url.originalUrl)
blossom.add(url.originalUrl) } else if (url.originalUrl.startsWithAny(blossomScheme)) {
} else { blossom.add(url.originalUrl)
completeUrls.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)
} }
} else { } 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)
} }
} }
+2 -4
View File
@@ -48,7 +48,6 @@ netUrlencoderLibVersion = "1.6.0"
navigationCompose = "2.9.7" navigationCompose = "2.9.7"
okhttp = "5.3.2" okhttp = "5.3.2"
runner = "1.7.0" runner = "1.7.0"
rfc3986 = "0.1.2"
secp256k1KmpJniAndroid = "0.23.0" secp256k1KmpJniAndroid = "0.23.0"
securityCryptoKtx = "1.1.0" securityCryptoKtx = "1.1.0"
slf4j = "2.0.17" slf4j = "2.0.17"
@@ -58,6 +57,7 @@ torAndroid = "0.4.9.5.1"
translate = "17.0.3" translate = "17.0.3"
jetbrainsCompose = "1.10.3" jetbrainsCompose = "1.10.3"
unifiedpush = "3.0.10" unifiedpush = "3.0.10"
uriReferenceKmp = "1.0"
vico-charts-compose = "3.0.3" vico-charts-compose = "3.0.3"
zelory = "3.0.1" zelory = "3.0.1"
zoomable = "2.11.1" zoomable = "2.11.1"
@@ -72,7 +72,6 @@ androidxExifinterface = "1.4.2"
kotlinTest = "2.3.0" kotlinTest = "2.3.0"
core = "1.7.0" core = "1.7.0"
mavenPublish = "0.36.0" mavenPublish = "0.36.0"
spmForKmpVersion = "1.6.1"
sqlite = "2.6.2" sqlite = "2.6.2"
[libraries] [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" } 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" } commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" }
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } 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" } 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" } 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" } 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" } 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" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", 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-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-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" } 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" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
frankois944-spmForKmp = { id = "io.github.frankois944.spmForKmp", version.ref = "spmForKmpVersion" }
+7 -25
View File
@@ -1,9 +1,5 @@
@file:OptIn(ExperimentalSpmForKmpFeature::class)
import com.vanniktech.maven.publish.KotlinMultiplatform import com.vanniktech.maven.publish.KotlinMultiplatform
import com.vanniktech.maven.publish.SourcesJar 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.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest
@@ -13,7 +9,6 @@ plugins {
alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.serialization) alias(libs.plugins.serialization)
alias(libs.plugins.vanniktech.mavenPublish) alias(libs.plugins.vanniktech.mavenPublish)
alias(libs.plugins.frankois944.spmForKmp)
} }
kotlin { kotlin {
@@ -26,7 +21,7 @@ kotlin {
} }
} }
androidLibrary { android {
namespace = "com.vitorpamplona.quartz" namespace = "com.vitorpamplona.quartz"
compileSdk = compileSdk =
libs.versions.android.compileSdk libs.versions.android.compileSdk
@@ -121,21 +116,6 @@ kotlin {
dependsOn(libsodiumDefFileGeneration) 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 { iosArm64 {
@@ -144,6 +124,7 @@ kotlin {
} }
binaries.framework { binaries.framework {
baseName = xcfName baseName = xcfName
isStatic = true
binaryOption("bundleId", "com.vitorpamplona.quartz") binaryOption("bundleId", "com.vitorpamplona.quartz")
} }
} }
@@ -154,6 +135,7 @@ kotlin {
} }
binaries.framework { binaries.framework {
baseName = xcfName baseName = xcfName
isStatic = true
binaryOption("bundleId", "com.vitorpamplona.quartz") binaryOption("bundleId", "com.vitorpamplona.quartz")
} }
} }
@@ -201,6 +183,9 @@ kotlin {
// SQLite KMP driver for event store // SQLite KMP driver for event store
api(libs.androidx.sqlite) api(libs.androidx.sqlite)
implementation(libs.androidx.sqlite.bundled) implementation(libs.androidx.sqlite.bundled)
// RFC3986 library(normalizes URLs)
api(libs.uri.reference.kmp)
} }
} }
@@ -221,9 +206,6 @@ kotlin {
dependsOn(commonMain.get()) dependsOn(commonMain.get())
dependencies { dependencies {
// Normalizes URLs
api(libs.rfc3986.normalizer)
// Performant Parser of JSONs into Events // Performant Parser of JSONs into Events
api(libs.jackson.module.kotlin) api(libs.jackson.module.kotlin)
@@ -368,7 +350,7 @@ mavenPublishing {
coordinates( coordinates(
groupId = "com.vitorpamplona.quartz", groupId = "com.vitorpamplona.quartz",
artifactId = "quartz", artifactId = "quartz",
version = "1.06.1", version = "1.06.3",
) )
// Configure publishing to Maven Central // Configure publishing to Maven Central
@@ -72,6 +72,15 @@ class TagArrayBuilder<T : IEvent> {
return this return this
} }
fun addUniqueValueIfNew(tag: Array<String>): TagArrayBuilder<T> {
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<Array<String>>): TagArrayBuilder<T> { fun addAll(tag: List<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::add) tag.forEach(::add)
return this return this
@@ -82,6 +91,16 @@ class TagArrayBuilder<T : IEvent> {
return this return this
} }
fun addAllUnique(tag: Array<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::addUnique)
return this
}
fun addAllUniqueValueIfNew(tag: List<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::addUniqueValueIfNew)
return this
}
fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray() fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray()
fun build() = toTypedArray() fun build() = toTypedArray()
@@ -52,6 +52,12 @@ class ReferenceTag {
fun assemble(url: String) = arrayOf(TAG_NAME, HttpUrlFormatter.normalize(url)) fun assemble(url: String) = arrayOf(TAG_NAME, HttpUrlFormatter.normalize(url))
fun assemble(urls: List<String>): List<Array<String>> = urls.mapTo(HashSet()) { HttpUrlFormatter.normalize(it) }.map { arrayOf(TAG_NAME, it) } fun assemble(urls: List<String>): List<Array<String>> =
urls
.mapTo(HashSet()) {
HttpUrlFormatter.normalize(it)
}.map {
arrayOf(TAG_NAME, it)
}
} }
} }
@@ -20,15 +20,25 @@
*/ */
package com.vitorpamplona.quartz.nip10Notes.content package com.vitorpamplona.quartz.nip10Notes.content
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.fastFindURLs 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<String>): List<Array<String>> = fun findURLs(text: String) =
urls UrlDetector(text).detect().mapNotNull {
.mapTo(HashSet()) { url -> if (it.originalUrl.startsWithAny(rejectSchemes)) {
HttpUrlFormatter.normalize(url) null
}.map { } else {
arrayOf("r", it) it.originalUrl
} }
}
@@ -31,18 +31,18 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
fun <T : Event> TagArrayBuilder<T>.quote(tag: QTag) = add(tag.toTagArray()) fun <T : Event> TagArrayBuilder<T>.quote(tag: QTag) = addUniqueValueIfNew(tag.toTagArray())
fun <T : Event> TagArrayBuilder<T>.quotes(tag: List<QTag>) = addAll(tag.map { it.toTagArray() }) fun <T : Event> TagArrayBuilder<T>.quotes(tag: List<QTag>) = addAllUniqueValueIfNew(tag.map { it.toTagArray() })
fun <T : Event> TagArrayBuilder<T>.quote(entity: Entity) = fun <T : Event> TagArrayBuilder<T>.quote(entity: Entity) =
when (entity) { when (entity) {
is NNote -> add(entity.toQuoteTagArray()) is NNote -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NEvent -> add(entity.toQuoteTagArray()) is NEvent -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NAddress -> add(entity.toQuoteTagArray()) is NAddress -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NEmbed -> add(entity.toQuoteTagArray()) is NEmbed -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NPub -> add(entity.toQuoteTagArray()) is NPub -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NProfile -> add(entity.toQuoteTagArray()) is NProfile -> addUniqueValueIfNew(entity.toQuoteTagArray())
else -> this else -> this
} }
@@ -21,6 +21,5 @@
package com.vitorpamplona.quartz.nip89AppHandlers.clientTag package com.vitorpamplona.quartz.nip89AppHandlers.clientTag
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
fun Event.client() = tags.zapraiserAmount() fun Event.client() = tags.client()
@@ -24,9 +24,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
fun TagArrayBuilder<Event>.client(name: String) = addUnique(ClientTag.assemble(name)) fun <T : Event> TagArrayBuilder<T>.client(name: String) = addUnique(ClientTag.assemble(name))
fun TagArrayBuilder<Event>.client( fun <T : Event> TagArrayBuilder<T>.client(
name: String, name: String,
address: AddressHint, address: AddressHint,
) = addUnique(ClientTag.assemble(name, address.addressId, address.relay)) ) = addUnique(ClientTag.assemble(name, address.addressId, address.relay))
@@ -20,12 +20,47 @@
*/ */
package com.vitorpamplona.quartz.utils package com.vitorpamplona.quartz.utils
expect object Rfc3986 { import io.kotlingeekdev.urireference.URIReference
fun normalize(uri: String): String
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()
} }
@@ -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<String>
@@ -298,11 +298,22 @@ class DomainNameReader(
topLevelLength = currentLabelLength 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 var isAscii = false
while (!done && !reader.eof()) { while (!done && !reader.eof()) {
val curr: Char = reader.read() val curr: Char = reader.read()
isAscii = curr.code < INTERNATIONAL_CHAR_START isAscii = curr.code < INTERNATIONAL_CHAR_START
if (lastWasAscii == null) { if (lastWasAscii == null) {
lastWasAscii = isAscii 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. * 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. * 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. * The maximum length of each label in the domain name.
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.utils.urldetector.detection
import com.vitorpamplona.quartz.utils.urldetector.Url import com.vitorpamplona.quartz.utils.urldetector.Url
import com.vitorpamplona.quartz.utils.urldetector.UrlMarker import com.vitorpamplona.quartz.utils.urldetector.UrlMarker
import com.vitorpamplona.quartz.utils.urldetector.UrlPart 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.math.max
import kotlin.text.deleteRange import kotlin.text.deleteRange
@@ -84,6 +85,9 @@ class UrlDetector(
var length = 0 var length = 0
var position = 0 var position = 0
var lastWasAscii: Boolean? = null
var isAscii = false
// until end of string read the contents // until end of string read the contents
while (!reader.eof()) { while (!reader.eof()) {
// read the next char to process. // read the next char to process.
@@ -96,6 +100,7 @@ class UrlDetector(
} }
readEnd(ReadEndState.InvalidUrl) readEnd(ReadEndState.InvalidUrl)
length = 0 length = 0
lastWasAscii = null
} }
'%' -> { '%' -> {
@@ -116,6 +121,7 @@ class UrlDetector(
length = 0 length = 0
} }
} }
lastWasAscii = null
} }
'\u3002', '\uFF0E', '\uFF61', '.' -> { '\u3002', '\uFF0E', '\uFF61', '.' -> {
@@ -125,6 +131,7 @@ class UrlDetector(
readEnd(ReadEndState.InvalidUrl) readEnd(ReadEndState.InvalidUrl)
} }
length = 0 length = 0
lastWasAscii = null
} }
'@' -> { '@' -> {
@@ -136,6 +143,7 @@ class UrlDetector(
} }
length = 0 length = 0
} }
lastWasAscii = null
} }
'[' -> { '[' -> {
@@ -153,6 +161,7 @@ class UrlDetector(
reader.seek(beginning) reader.seek(beginning)
} }
length = 0 length = 0
lastWasAscii = null
} }
'/' -> { '/' -> {
@@ -180,15 +189,29 @@ class UrlDetector(
hasScheme = readHtml5Root() hasScheme = readHtml5Root()
length = buffer.length length = buffer.length
} }
lastWasAscii = null
} }
':' -> { ':' -> {
// add the ":" to the url and check for scheme/username // add the ":" to the url and check for scheme/username
buffer.append(curr) buffer.append(curr)
length = processColon(length) length = processColon(length)
lastWasAscii = null
} }
else -> { 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) buffer.append(curr)
} }
} }
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip10Notes.urls package com.vitorpamplona.quartz.nip10Notes.urls
import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.utils.fastFindURLs
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertContains import kotlin.test.assertContains
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -31,7 +30,7 @@ class UrlsDetectorTest {
@Test @Test
fun detectUrlNumber() { fun detectUrlNumber() {
val detectedLinks = fastFindURLs(testSentence) val detectedLinks = findURLs(testSentence)
assertEquals(2, detectedLinks.size) assertEquals(2, detectedLinks.size)
} }
@@ -48,7 +47,7 @@ class UrlsDetectorTest {
*/ */
@Test @Test
fun doesNotCrashOnJapaneseText() { fun doesNotCrashOnJapaneseText() {
val detectedLinks = fastFindURLs("今北産業") val detectedLinks = findURLs("今北産業")
assertEquals(0, detectedLinks.size) assertEquals(0, detectedLinks.size)
} }
} }
@@ -59,6 +59,6 @@ class UrlTest {
assertNotNull(url.path) assertNotNull(url.path)
assertNotNull(url.query) assertNotNull(url.query)
assertNotNull(url.fragment) assertNotNull(url.fragment)
assertEquals(-1, url.port) // getPart(PORT) returns null → -1 assertEquals(443, url.port) // getPart(PORT) returns null → -1
} }
} }
@@ -739,6 +739,37 @@ class UriDetectionTest {
runTest("blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net", "blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net") 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 @Test
fun testFullText() { fun testFullText() {
val text = val text =
@@ -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()
}
@@ -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<String> {
val detectorInstance = UrlDetector()
return detectorInstance.findURLsWithText(text) as List<String>
}
@@ -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\"}"
}
@@ -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()
}
@@ -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<String> = UrlDetector(text).detect().map { it.originalUrl }
@@ -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!
}
}
@@ -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
}
}