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"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
versionCode = 434
versionName = "1.06.1"
versionCode = 435
versionName = "1.06.3"
vectorDrawables {
useSupportLibrary = true
+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.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.1` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.3` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.06.1"
quartz = "1.06.3"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.1")
implementation("com.vitorpamplona.quartz:quartz:1.06.3")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.06.1
com.vitorpamplona.quartz:quartz:1.06.3
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.06.1"
quartz = "1.06.3"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.1")
implementation("com.vitorpamplona.quartz:quartz:1.06.3")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.1")
implementation("com.vitorpamplona.quartz:quartz:1.06.3")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
+1 -1
View File
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
## Current artifact
```
com.vitorpamplona.quartz:quartz:1.06.1
com.vitorpamplona.quartz:quartz:1.06.3
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
+1
View File
@@ -268,6 +268,7 @@ Updated translations:
- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t
- Chinese by hypnotichemionus4
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
- Russian by Anton Zhao
<a id="v1.05.1"></a>
# [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"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 433
versionName = generateVersionName("1.06.1")
versionCode = 435
versionName = generateVersionName("1.06.3")
buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -139,6 +139,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.quartz.nip01Core.tags.references.references
@@ -1292,6 +1293,30 @@ class Account(
return event
}
suspend fun <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.
* Returns the event, target relays, and extra events to broadcast.
@@ -47,7 +47,7 @@ class ConnectivityManager(
val isMobileOrNull: StateFlow<Boolean?> =
status
.map {
(status.value as? ConnectivityStatus.Active)?.isMobile
(it as? ConnectivityStatus.Active)?.isMobile
}.stateIn(
scope,
SharingStarted.WhileSubscribed(2000),
@@ -57,7 +57,7 @@ class ConnectivityManager(
val isMobileOrFalse: StateFlow<Boolean> =
status
.map {
(status.value as? ConnectivityStatus.Active)?.isMobile ?: false
(it as? ConnectivityStatus.Active)?.isMobile ?: false
}.stateIn(
scope,
SharingStarted.WhileSubscribed(2000),
@@ -47,7 +47,7 @@ fun VideoViewInner(
authorName: String? = null,
nostrUriCallback: String? = null,
automaticallyStartPlayback: Boolean,
controllerVisible: MutableState<Boolean> = mutableStateOf(true),
controllerVisible: MutableState<Boolean> = mutableStateOf(false),
onZoom: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
) {
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -30,7 +31,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -103,21 +106,43 @@ private fun HorizontalLinearProgressIndicator(
scrubberColor: Color = playedColor,
rectHeightDp: Dp = 4.dp,
) {
var isDragging by remember { mutableStateOf(false) }
var dragProgress by remember { mutableFloatStateOf(0f) }
Canvas(
Modifier
.fillMaxWidth()
.padding(horizontal = rectHeightDp * 2.5f)
.pointerInput(Unit) {
detectTapGestures { offset ->
// Capture the exact position
onSeek(offset.x / this.size.width.toFloat())
}
}.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
isDragging = true
dragProgress = (offset.x / this.size.width.toFloat()).coerceIn(0f, 1f)
},
onDrag = { change, _ ->
change.consume()
dragProgress = (change.position.x / this.size.width.toFloat()).coerceIn(0f, 1f)
},
onDragEnd = {
onSeek(dragProgress)
isDragging = false
},
onDragCancel = {
isDragging = false
},
)
}.padding(vertical = rectHeightDp * 2)
.height(rectHeightDp)
.onSizeChanged { (w, _) -> onLayoutWidthChanged(w) },
) {
val positionX = (currentPositionProgress() * size.width).coerceAtLeast(0f)
val displayProgress = if (isDragging) dragProgress else currentPositionProgress()
val positionX = (displayProgress * size.width).coerceAtLeast(0f)
val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f)
val scrubberRadius = if (isDragging) size.height * 3f else size.height * 2f
drawRect(unplayedColor, size = Size(size.width, size.height))
drawRect(bufferedColor, size = Size(bufferX, size.height))
@@ -125,7 +150,7 @@ private fun HorizontalLinearProgressIndicator(
drawCircle(
color = scrubberColor,
radius = size.height * 2f,
radius = scrubberRadius,
center = Offset(x = positionX, y = size.height / 2.0f),
)
}
@@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
@@ -426,41 +425,6 @@ fun observeUserIsFollowingChannel(
return flow.collectAsStateWithLifecycle(channel.roomId in account.ephemeralChatList.liveEphemeralChatList.value)
}
@Composable
fun observeUserReports(
user: User,
accountViewModel: AccountViewModel,
onUpdate: () -> Unit,
) {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user, onUpdate) {
user
.reports()
.receivedReportsByAuthor
.onEach { onUpdate() }
.onStart { onUpdate() }
}.collectAsStateWithLifecycle(emptyMap())
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserReportCount(
user: User,
accountViewModel: AccountViewModel,
): State<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)
@Composable
fun observeUserContactCardsScore(
@@ -124,7 +124,6 @@ import okhttp3.coroutines.executeAsync
import okio.sink
import java.io.File
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
// Delay before cleaning up shared video temp files.
// Allows time for receiving app to copy the file after user confirms share.
@@ -224,12 +223,7 @@ fun TwoSecondController(
content: BaseMediaContent,
inner: @Composable (controllerVisible: MutableState<Boolean>) -> Unit,
) {
val controllerVisible = remember(content) { mutableStateOf(true) }
LaunchedEffect(content) {
delay(2.seconds)
controllerVisible.value = false
}
val controllerVisible = remember(content) { mutableStateOf(false) }
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.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -209,11 +206,7 @@ fun AppNavigation(
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ImportFollowsPickFollows> {
ImportFollowListPickFollowsScreen(
accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(it.userHex)),
accountViewModel,
nav,
)
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
}
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.PublicChatChannel> {
PublicChatChannelScreen(
it.id,
it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) },
it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) },
accountViewModel,
nav,
)
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
}
composableFromEndArgs<Route.LiveActivityChannel> {
LiveActivityChannelScreen(
Address(it.kind, it.pubKeyHex, it.dTag),
draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyTo = it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) },
draftId = it.draftId,
replyToId = it.replyTo,
accountViewModel,
nav,
)
}
composableFromEndArgs<Route.EphemeralChat> {
RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.let { relay ->
EphemeralChatScreen(
channelId = RoomId(it.id, relay),
draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyTo = it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) },
accountViewModel = accountViewModel,
nav = nav,
)
}
EphemeralChatScreen(
id = it.id,
relayUrl = it.relayUrl,
draftId = it.draftId,
replyToId = it.replyTo,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
@@ -280,9 +266,9 @@ fun AppNavigation(
geohash = it.geohash,
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyId = it.replyTo,
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
nav,
)
@@ -291,8 +277,8 @@ fun AppNavigation(
composableFromBottomArgs<Route.NewPublicMessage> {
NewPublicMessageScreen(
to = it.toKey(),
reply = it.replyId?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyId = it.replyId,
draftId = it.draftId,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -303,9 +289,9 @@ fun AppNavigation(
hashtag = it.hashtag,
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyId = it.replyTo,
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
nav,
)
@@ -313,11 +299,11 @@ fun AppNavigation(
composableFromBottomArgs<Route.GenericCommentPost> {
ReplyCommentPostScreen(
reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyId = it.replyTo,
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
nav,
)
@@ -327,8 +313,8 @@ fun AppNavigation(
NewProductScreen(
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
nav,
)
@@ -336,8 +322,8 @@ fun AppNavigation(
composableFromBottomArgs<Route.NewLongFormPost> {
LongFormPostScreen(
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draftId = it.draft,
versionId = it.version,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -347,11 +333,11 @@ fun AppNavigation(
ShortNotePostScreen(
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
baseReplyTo = it.baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
fork = it.fork?.let { hex -> accountViewModel.getNoteIfExists(hex) },
version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
baseReplyToId = it.baseReplyTo,
quoteId = it.quote,
forkId = it.fork,
versionId = it.version,
draftId = it.draft,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -86,7 +86,11 @@ fun routeForInner(
): Route? =
when (noteEvent) {
is AppDefinitionEvent -> {
Route.ContentDiscovery(noteEvent.id)
if (noteEvent.includeKind(5300)) {
Route.ContentDiscovery(noteEvent.id)
} else {
Route.Note(noteEvent.id)
}
}
is IsInPublicChatChannel -> {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -59,11 +60,12 @@ fun RelayCompose(
accountViewModel: AccountViewModel,
onAddRelay: () -> Unit,
onRemoveRelay: () -> Unit,
onClick: (() -> Unit)? = null,
) {
val context = LocalContext.current
Row(
modifier = StdPadding,
modifier = StdPadding.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
@@ -18,19 +18,41 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports
package com.vitorpamplona.amethyst.ui.note.creators.anonymous
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReports
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
@Composable
fun WatchReportsAndUpdateFeed(
baseUser: User,
feedViewModel: UserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
fun AnonymousPostButton(
isActive: Boolean,
onClick: () -> Unit,
) {
observeUserReports(baseUser, accountViewModel) { feedViewModel.invalidateData() }
IconButton(
onClick = { onClick() },
) {
if (!isActive) {
Icon(
imageVector = Icons.Default.PersonOff,
contentDescription = stringRes(R.string.post_anonymously),
modifier = Size19Modifier,
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Default.PersonOff,
contentDescription = stringRes(R.string.post_anonymously),
modifier = Size19Modifier,
tint = Color.Red,
)
}
}
}
@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.creators.polls
import android.annotation.SuppressLint
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -32,6 +34,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -47,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.utils.RandomInstance
@Composable
@@ -55,6 +59,13 @@ fun PollOptionsField(postViewModel: ShortNotePostViewModel) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
PollTypeSelector(
selectedType = postViewModel.pollType,
onTypeSelected = { postViewModel.pollType = it },
)
Spacer(Modifier.height(4.dp))
optionsList.forEach { option ->
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
@@ -118,6 +129,27 @@ fun PollOptionsField(postViewModel: ShortNotePostViewModel) {
}
}
@Composable
fun PollTypeSelector(
selectedType: PollType,
onTypeSelected: (PollType) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = selectedType == PollType.SINGLE_CHOICE,
onClick = { onTypeSelected(PollType.SINGLE_CHOICE) },
label = { Text(stringRes(R.string.poll_single_choice)) },
)
FilterChip(
selected = selectedType == PollType.MULTI_CHOICE,
onClick = { onTypeSelected(PollType.MULTI_CHOICE) },
label = { Text(stringRes(R.string.poll_multiple_choice)) },
)
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview
@Composable
@@ -30,23 +30,33 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.WatchAndDisplayNip05Row
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.nip05
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -145,3 +155,51 @@ fun UserLine(
},
)
}
@Composable
private fun WatchAndDisplayNip05Row(
user: User,
accountViewModel: AccountViewModel,
) {
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
when (val nip05State = nip05StateMetadata) {
is Nip05State.Exists -> {
NonClickableObserveAndDisplayNIP05(nip05State, accountViewModel)
}
else -> {
Text(
text = user.pubkeyDisplayHex(),
fontSize = Font14SP,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun NonClickableObserveAndDisplayNIP05(
nip05State: Nip05State.Exists,
accountViewModel: AccountViewModel,
) {
if (nip05State.nip05.name != "_") {
Text(
text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) },
fontSize = Font14SP,
color = MaterialTheme.colorScheme.nip05,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ObserveAndRenderNIP05VerifiedSymbol(nip05State, 1, NIP05IconSize, accountViewModel)
Text(
text = nip05State.nip05.domain,
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP),
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
@@ -194,6 +194,8 @@ open class CommentPostViewModel :
var wantsZapraiser by mutableStateOf(false)
override val zapRaiserAmount = mutableStateOf<Long?>(null)
var wantsAnonymousPost by mutableStateOf(false)
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
@@ -342,10 +344,15 @@ open class CommentPostViewModel :
}
val version = draftTag.current
val anonymous = wantsAnonymousPost
cancel()
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
if (anonymous) {
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
} else {
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
}
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
@@ -588,6 +595,7 @@ open class CommentPostViewModel :
contentWarningDescription = ""
wantsToAddGeoHash = false
wantsSecretEmoji = false
wantsAnonymousPost = false
forwardZapTo.value = SplitBuilder()
forwardZapToEditting.value = TextFieldValue("")
@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -35,6 +37,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@@ -47,7 +51,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
@@ -80,13 +83,17 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -94,11 +101,11 @@ import kotlinx.coroutines.withContext
@Composable
fun ReplyCommentPostScreen(
reply: Note? = null,
replyId: HexKey? = null,
message: String? = null,
attachment: Uri? = null,
quote: Note? = null,
draft: Note? = null,
quoteId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -108,13 +115,13 @@ fun ReplyCommentPostScreen(
val context = LocalContext.current
LaunchedEffect(postViewModel, accountViewModel) {
reply?.let {
replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it)
}
draft?.let {
draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it)
}
quote?.let {
quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
@@ -241,11 +248,32 @@ private fun GenericCommentPostBody(
Row(
modifier = Modifier.padding(vertical = Size10dp),
) {
BaseUserPicture(
accountViewModel.userProfile(),
Size35dp,
accountViewModel = accountViewModel,
)
if (postViewModel.wantsAnonymousPost) {
IconButton(
modifier = Size35Modifier,
onClick = { postViewModel.wantsAnonymousPost = false },
) {
Icon(
painter = painterRes(resourceId = R.drawable.incognito, 1),
contentDescription = stringRes(R.string.post_anonymously),
modifier = Size30Modifier,
tint = MaterialTheme.colorScheme.onBackground,
)
}
} else {
Box(
modifier =
Modifier.clickable {
postViewModel.wantsAnonymousPost = true
},
) {
BaseUserPicture(
accountViewModel.userProfile(),
Size35dp,
accountViewModel = accountViewModel,
)
}
}
MessageField(
R.string.what_s_on_your_mind,
postViewModel,
@@ -34,13 +34,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Approval
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.RemoveDone
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -591,13 +595,13 @@ private fun attestationColor(
validity: Validity?,
): Color =
when {
status == AttestationStatus.REVOKED -> Color(0xFFB71C1C)
status == AttestationStatus.REJECTED -> Color(0xFFB71C1C)
validity == Validity.INVALID -> Color(0xFFB71C1C)
status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32)
validity == Validity.VALID -> Color(0xFF2E7D32)
status == AttestationStatus.VERIFYING -> Color(0xFFF57F17)
status == AttestationStatus.ACCEPTED -> Color(0xFF1565C0)
status == AttestationStatus.REVOKED -> Color(0xFFB21CB7)
status == AttestationStatus.REJECTED -> Color(0xFFB71C1C)
status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32)
status == AttestationStatus.VERIFYING -> Color(0xFF173CF5)
status == AttestationStatus.ACCEPTED -> Color(0xFF15C0C0)
else -> Color(0xFF757575)
}
@@ -606,14 +610,14 @@ private fun attestationIcon(
validity: Validity?,
): ImageVector =
when {
status == AttestationStatus.REVOKED -> Icons.Default.Close
status == AttestationStatus.REJECTED -> Icons.Default.Close
validity == Validity.INVALID -> Icons.Default.Close
status == AttestationStatus.VERIFIED -> Icons.Default.VerifiedUser
validity == Validity.VALID -> Icons.Default.CheckCircle
status == AttestationStatus.REVOKED -> Icons.Default.RemoveDone
status == AttestationStatus.REJECTED -> Icons.Default.Delete
status == AttestationStatus.VERIFIED -> Icons.Default.Approval
status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop
status == AttestationStatus.ACCEPTED -> Icons.Default.CheckCircle
else -> Icons.Default.VerifiedUser
status == AttestationStatus.ACCEPTED -> Icons.Default.HourglassEmpty
else -> Icons.Default.ErrorOutline
}
@Composable
@@ -622,11 +626,11 @@ private fun attestationStatusLabel(
validity: Validity?,
): String =
when {
validity == Validity.INVALID -> stringRes(R.string.attestation_invalid)
validity == Validity.VALID -> stringRes(R.string.attestation_valid)
status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked)
status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected)
validity == Validity.INVALID -> stringRes(R.string.attestation_invalid)
status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified)
validity == Validity.VALID -> stringRes(R.string.attestation_valid)
status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying)
status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted)
else -> stringRes(R.string.attestation)
@@ -412,8 +412,15 @@ private fun RenderResults(
resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit),
) {
val showGallery =
remember {
card.options.all {
it.label.length < 50
}
}
card.options.forEach { pollItem ->
RenderClosedItem(pollItem, resultContent) {
RenderClosedItem(pollItem, showGallery, resultContent) {
labelContent(pollItem.code, pollItem.label)
}
}
@@ -422,18 +429,20 @@ private fun RenderResults(
@Composable
private fun RenderClosedItem(
item: PollItemCard,
showGallery: Boolean,
resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable ColumnScope.() -> Unit,
) {
val tally by item.results.collectAsStateWithLifecycle(item.currentResults())
RenderClosedItem(tally, item.label, resultContent, labelContent)
RenderClosedItem(tally, item.label, showGallery, resultContent, labelContent)
}
@Composable
private fun RenderClosedItem(
tally: TallyResults,
label: String,
showGallery: Boolean,
resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable ColumnScope.() -> Unit,
) {
@@ -496,7 +505,7 @@ private fun RenderClosedItem(
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (label.length < 30) {
if (showGallery) {
UserGallery(tally, resultContent)
}
@@ -106,7 +106,7 @@ fun ArticleList(
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(articles, key = { _, item -> item.toNAddr() }) { _, item ->
itemsIndexed(articles, key = { _, item -> item.address }) { _, item ->
NoteCompose(
baseNote = item,
modifier = Modifier.animateContentSize(),
@@ -28,6 +28,9 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
@@ -43,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import kotlinx.coroutines.launch
@Composable
fun RefreshingChatroomFeedView(
@@ -131,6 +135,18 @@ fun ChatFeedLoaded(
}
}
val scope = rememberCoroutineScope()
val highlightedNoteId = remember { mutableStateOf<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(
contentPadding = FeedPadding,
modifier = Modifier.fillMaxSize(),
@@ -147,6 +163,9 @@ fun ChatFeedLoaded(
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
shouldHighlight = highlightedNoteId.value == item.idHex,
onHighlightFinished = { highlightedNoteId.value = null },
)
NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item)
@@ -95,6 +95,9 @@ fun ChatroomMessageCompose(
nav: INav,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
shouldHighlight: Boolean = false,
onHighlightFinished: (() -> Unit)? = null,
) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) {
WatchBlockAndReport(
@@ -114,6 +117,9 @@ fun ChatroomMessageCompose(
nav,
onWantsToReply,
onWantsToEditDraft,
onScrollToNote,
shouldHighlight,
onHighlightFinished,
)
}
}
@@ -130,6 +136,9 @@ fun NormalChatNote(
nav: INav,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
shouldHighlight: Boolean = false,
onHighlightFinished: (() -> Unit)? = null,
) {
val isLoggedInUser =
remember(note.author) {
@@ -167,10 +176,15 @@ fun NormalChatNote(
hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(),
drawAuthorInfo = drawAuthorInfo,
parentBackgroundColor = parentBackgroundColor,
shouldHighlight = shouldHighlight,
onHighlightFinished = onHighlightFinished,
onClick = {
if (note.event is ChannelCreateEvent) {
nav.nav(Route.PublicChatChannel(note.idHex))
true
} else if (innerQuote && onScrollToNote != null) {
onScrollToNote(note)
true
} else {
false
}
@@ -253,6 +267,7 @@ fun NormalChatNote(
canPreview,
accountViewModel,
nav,
onScrollToNote,
)
}
}
@@ -288,6 +303,7 @@ private fun MessageBubbleLines(
canPreview: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
onScrollToNote: ((Note) -> Unit)? = null,
) {
if (baseNote.event !is DraftWrapEvent) {
RenderReplyRow(
@@ -298,6 +314,7 @@ private fun MessageBubbleLines(
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
)
}
@@ -334,9 +351,10 @@ fun RenderReplyRow(
nav: INav,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
) {
if (!innerQuote && note.replyTo?.lastOrNull() != null) {
RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft)
RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft, onScrollToNote)
}
}
@@ -348,6 +366,7 @@ private fun RenderReply(
nav: INav,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
@Suppress("ProduceStateDoesNotAssignValue")
@@ -368,6 +387,7 @@ private fun RenderReply(
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
)
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -36,7 +38,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -58,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.theme.chatBackground
import com.vitorpamplona.amethyst.ui.theme.chatDraftBackground
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits
import kotlinx.coroutines.delay
private const val RELAYS_AND_ACTIONS_TEXT = "Relays and Actions"
@@ -71,6 +76,8 @@ fun ChatBubbleLayout(
hasDetailsToShow: Boolean,
drawAuthorInfo: Boolean,
parentBackgroundColor: MutableState<Color>? = null,
shouldHighlight: Boolean = false,
onHighlightFinished: (() -> Unit)? = null,
onClick: () -> Boolean,
onAuthorClick: () -> Unit,
actionMenu: @Composable (onDismiss: () -> Unit) -> Unit,
@@ -100,6 +107,24 @@ fun ChatBubbleLayout(
}
}
val highlightActive = remember { mutableStateOf(false) }
LaunchedEffect(shouldHighlight) {
if (shouldHighlight) {
highlightActive.value = true
delay(1500)
highlightActive.value = false
onHighlightFinished?.invoke()
}
}
val highlightColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f)
val animatedColor by animateColorAsState(
targetValue = if (highlightActive.value) highlightColor.compositeOver(bgColor.value) else bgColor.value,
animationSpec = tween(durationMillis = if (highlightActive.value) 300 else 800),
label = "highlightAnimation",
)
Row(
modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier,
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
@@ -136,7 +161,7 @@ fun ChatBubbleLayout(
modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier,
) {
Surface(
color = bgColor.value,
color = animatedColor,
shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem,
modifier = clickableModifier,
) {
@@ -23,22 +23,30 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephem
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@Composable
fun EphemeralChatScreen(
channelId: RoomId,
draft: Note? = null,
replyTo: Note? = null,
id: HexKey,
relayUrl: String,
draftId: HexKey? = null,
replyToId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val channelId = remember(id, relay) { RoomId(id, relay) }
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
@@ -35,13 +35,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Composable
fun PublicChatChannelScreen(
channelId: HexKey?,
draft: Note?,
replyTo: Note? = null,
draftId: HexKey? = null,
replyToId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
@@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -33,17 +33,21 @@ import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Composable
fun LiveActivityChannelScreen(
channelId: Address?,
draft: Note? = null,
replyTo: Note? = null,
draftId: HexKey? = null,
replyToId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
@@ -61,7 +61,6 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
@@ -98,12 +97,13 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongFormPostScreen(
draft: Note? = null,
version: Note? = null,
draftId: HexKey? = null,
versionId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -111,6 +111,8 @@ fun LongFormPostScreen(
postViewModel.init(accountViewModel)
LaunchedEffect(postViewModel, accountViewModel) {
val draft = draftId?.let { accountViewModel.getNoteIfExists(it) }
val version = versionId?.let { accountViewModel.getNoteIfExists(it) }
postViewModel.load(draft, version)
}
@@ -46,7 +46,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
@@ -83,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -93,8 +93,8 @@ import kotlinx.coroutines.withContext
fun NewProductScreen(
message: String? = null,
attachment: Uri? = null,
quote: Note? = null,
draft: Note? = null,
quoteId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -105,10 +105,10 @@ fun NewProductScreen(
LaunchedEffect(postViewModel, accountViewModel) {
postViewModel.reloadRelaySet()
draft?.let {
draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it)
}
quote?.let {
quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
@@ -26,12 +26,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel
import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -42,9 +42,9 @@ fun GeoHashPostScreen(
geohash: String? = null,
message: String? = null,
attachment: Uri? = null,
reply: Note? = null,
quote: Note? = null,
draft: Note? = null,
replyId: HexKey? = null,
quoteId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -57,13 +57,13 @@ fun GeoHashPostScreen(
geohash?.let {
postViewModel.newPostFor(GeohashId(it))
}
reply?.let {
replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it)
}
draft?.let {
draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it)
}
quote?.let {
quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
@@ -26,12 +26,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel
import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -42,9 +42,9 @@ fun HashtagPostScreen(
hashtag: String? = null,
message: String? = null,
attachment: Uri? = null,
reply: Note? = null,
quote: Note? = null,
draft: Note? = null,
replyId: HexKey? = null,
quoteId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -57,13 +57,13 @@ fun HashtagPostScreen(
hashtag?.let {
postViewModel.newPostFor(HashtagId(it))
}
reply?.let {
replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it)
}
draft?.let {
draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it)
}
quote?.let {
quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
@@ -25,7 +25,9 @@ import android.content.Intent
import android.net.Uri
import android.os.Parcelable
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -59,7 +61,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.util.Consumer
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
@@ -107,11 +108,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -123,11 +127,11 @@ import kotlinx.coroutines.withContext
fun ShortNotePostScreen(
message: String? = null,
attachment: Uri? = null,
baseReplyTo: Note? = null,
quote: Note? = null,
fork: Note? = null,
version: Note? = null,
draft: Note? = null,
baseReplyToId: HexKey? = null,
quoteId: HexKey? = null,
forkId: HexKey? = null,
versionId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -138,6 +142,11 @@ fun ShortNotePostScreen(
val activity = context.getActivity()
LaunchedEffect(postViewModel, accountViewModel) {
val baseReplyTo = baseReplyToId?.let { accountViewModel.getNoteIfExists(it) }
val quote = quoteId?.let { accountViewModel.getNoteIfExists(it) }
val fork = forkId?.let { accountViewModel.getNoteIfExists(it) }
val version = versionId?.let { accountViewModel.getNoteIfExists(it) }
val draft = draftId?.let { accountViewModel.getNoteIfExists(it) }
postViewModel.load(baseReplyTo, quote, fork, version, draft)
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
@@ -283,11 +292,32 @@ private fun NewPostScreenBody(
Row(
modifier = Modifier.padding(vertical = Size10dp),
) {
BaseUserPicture(
accountViewModel.userProfile(),
Size35dp,
accountViewModel = accountViewModel,
)
if (postViewModel.wantsAnonymousPost) {
IconButton(
modifier = Size35Modifier,
onClick = { postViewModel.wantsAnonymousPost = false },
) {
Icon(
painter = painterRes(resourceId = R.drawable.incognito, 1),
contentDescription = stringRes(R.string.post_anonymously),
modifier = Size30Modifier,
tint = MaterialTheme.colorScheme.onBackground,
)
}
} else {
Box(
modifier =
Modifier.clickable {
postViewModel.wantsAnonymousPost = true
},
) {
BaseUserPicture(
accountViewModel.userProfile(),
Size35dp,
accountViewModel = accountViewModel,
)
}
}
MessageField(
R.string.what_s_on_your_mind,
postViewModel,
@@ -117,6 +117,7 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.alt
@@ -238,6 +239,7 @@ open class ShortNotePostViewModel :
var canUsePoll by mutableStateOf(false)
var wantsPoll by mutableStateOf(false)
var pollOptions: SnapshotStateMap<Int, OptionTag> = newStateMapPollOptions()
var pollType by mutableStateOf(PollType.SINGLE_CHOICE)
var closedAt by mutableLongStateOf(TimeUtils.oneDayAhead())
// ZapPolls
@@ -283,6 +285,9 @@ open class ShortNotePostViewModel :
var wantsZapRaiser by mutableStateOf(false)
override val zapRaiserAmount = mutableStateOf<Long?>(null)
// Anonymous Reply
var wantsAnonymousPost by mutableStateOf(false)
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
@@ -336,6 +341,7 @@ open class ShortNotePostViewModel :
val currentMentions =
(replyNote.event as? TextNoteEvent)
?.mentions()
?.toSet()
?.map { LocalCache.getOrCreateUser(it.pubKey) }
?: emptyList()
@@ -490,8 +496,8 @@ open class ShortNotePostViewModel :
}
pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map {
LocalCache.getOrCreateUser(it[1])
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.checkGetOrCreateUser(it[1])
}
draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "fork" }.forEach {
@@ -576,8 +582,8 @@ open class ShortNotePostViewModel :
}
pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map {
LocalCache.getOrCreateUser(it[1])
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.checkGetOrCreateUser(it[1])
}
canUsePoll = originalNote == null
@@ -596,6 +602,7 @@ open class ShortNotePostViewModel :
pollOptions[index] = tag
}
pollType = draftEvent.pollType() ?: PollType.SINGLE_CHOICE
closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead()
message = TextFieldValue(draftEvent.content)
@@ -647,8 +654,8 @@ open class ShortNotePostViewModel :
}
pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map {
LocalCache.getOrCreateUser(it[1])
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.checkGetOrCreateUser(it[1])
}
canUsePoll = originalNote == null
@@ -712,9 +719,12 @@ open class ShortNotePostViewModel :
}
val version = draftTag.current
val anonymous = wantsAnonymousPost
cancel()
if (accountViewModel.settings.isCompleteUIMode()) {
if (anonymous) {
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
} else if (accountViewModel.settings.isCompleteUIMode()) {
// Tracked broadcasting with progress feedback (non-blocking)
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
@@ -828,7 +838,7 @@ open class ShortNotePostViewModel :
accountViewModel.account.nip65RelayList.outboxFlow.value
.toList()
PollEvent.build(tagger.message, options, closedAt, relays) {
PollEvent.build(tagger.message, options, closedAt, relays, pollType) {
pTags(tagger.directMentionsUsers.map { it.toPTag() })
quotes(quotes)
hashtags(findHashtags(tagger.message))
@@ -1054,6 +1064,7 @@ open class ShortNotePostViewModel :
wantsPoll = false
pollOptions = newStateMapPollOptions()
pollType = PollType.SINGLE_CHOICE
closedAt = TimeUtils.oneDayAhead()
wantsZapPoll = false
@@ -1073,6 +1084,7 @@ open class ShortNotePostViewModel :
wantsToAddGeoHash = false
wantsExclusiveGeoPost = false
wantsSecretEmoji = false
wantsAnonymousPost = false
forwardZapTo.value = SplitBuilder()
forwardZapToEditting.value = TextFieldValue("")
@@ -68,16 +68,22 @@ import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine
import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@Composable
fun ImportFollowListPickFollowsScreen(
contactListNote: AddressableNote,
userHex: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
val contactListNote =
remember(userHex) {
accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(userHex))
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -125,6 +131,7 @@ fun DisplayFollowList(
val contactsState by observeNoteEventAndMap<ContactListEvent, ImmutableList<User>?>(contactListNote, accountViewModel) { contactList ->
contactList
?.unverifiedFollowKeySet()
?.toSet()
?.mapNotNull {
accountViewModel.checkGetOrCreateUser(it)
}?.toPersistentList()
@@ -55,7 +55,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
@@ -108,8 +107,8 @@ import kotlinx.coroutines.withContext
@Composable
fun NewPublicMessageScreen(
to: Set<HexKey>? = null,
reply: Note? = null,
draft: Note? = null,
replyId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -121,10 +120,10 @@ fun NewPublicMessageScreen(
to?.let {
postViewModel.load(it)
}
reply?.let {
replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it)
}
draft?.let {
draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it)
}
}
@@ -229,6 +229,7 @@ fun PrepareViewModels(
factory =
UserProfileReportFeedViewModel.Factory(
baseUser,
accountViewModel.account,
),
)
@@ -273,7 +274,6 @@ fun ProfileScreen(
WatchLifecycleAndUpdateModel(appRecommendations)
WatchLifecycleAndUpdateModel(bookmarksFeedViewModel)
WatchLifecycleAndUpdateModel(galleryFeedViewModel)
WatchLifecycleAndUpdateModel(reportsFeedViewModel)
UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile)
@@ -521,7 +521,7 @@ private fun CreateAndRenderTabs(
{ ZapTabHeader(zapFeedViewModel, accountViewModel) },
{ BookmarkTabHeader(baseUser, accountViewModel) },
{ FollowedTagsTabHeader(baseUser, accountViewModel) },
{ ReportsTabHeader(baseUser, accountViewModel) },
{ ReportsTabHeader(baseUser, reportsFeedViewModel, accountViewModel) },
{ RelaysTabHeader(baseUser, accountViewModel) },
)
@@ -54,8 +54,8 @@ class UserProfileFollowersUserFeedViewModel(
{ it.pubkeyHex },
)
fun List<Event>.toNonHiddenOwners(): List<User> =
mapNotNull { event ->
fun List<Event>.toNonHiddenOwners(): Set<User> =
mapNotNullTo(mutableSetOf()) { event ->
if (!account.isHidden(event.pubKey)) {
account.cache.getOrCreateUser(event.pubKey)
} else {
@@ -105,6 +105,9 @@ private fun RenderRelayRow(
onRemoveRelay = {
nav.nav(Route.EditRelays)
},
onClick = {
nav.nav(Route.RelayInfo(relay.url.url))
},
)
HorizontalDivider(
thickness = DividerThickness,
@@ -23,18 +23,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReportCount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun ReportsTabHeader(
baseUser: User,
reportsFeedViewModel: UserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
) {
val reportCount by observeUserReportCount(baseUser, accountViewModel)
val reportCount by reportsFeedViewModel.followerCount.collectAsStateWithLifecycle()
if (reportCount > 0) {
Text(text = stringRes(R.string.number_reports, reportCount))
@@ -21,14 +21,25 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@Composable
fun TabReports(
@@ -37,15 +48,36 @@ fun TabReports(
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchReportsAndUpdateFeed(baseUser, feedViewModel, accountViewModel)
Column(Modifier.fillMaxHeight()) {
RefresheableFeedView(
feedViewModel,
null,
enablePullRefresh = false,
accountViewModel = accountViewModel,
nav = nav,
)
val items by feedViewModel.followersFlow.collectAsStateWithLifecycle()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
state = rememberLazyListState(),
) {
itemsIndexed(
items,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
NoteCompose(
item,
modifier = Modifier.fillMaxWidth(),
routeForLastRead = null,
isBoostedNote = false,
isHiddenFeed = false,
quotesLeft = 3,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
@@ -23,17 +23,59 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
@Stable
class UserProfileReportFeedViewModel(
val user: User,
) : AndroidFeedViewModel(UserProfileReportsFeedFilter(user)) {
val account: Account,
) : ViewModel() {
val sortingModel: Comparator<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(
val user: User,
val account: Account,
) : ViewModelProvider.Factory {
@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 Nip05EmailColor = Color(0xFFb198ec)
val Nip05EmailColorDark = Color(0xFF6e5490)
val Nip05EmailColorDark = Color(0xFF765AA2)
val Nip05EmailColorLight = Color(0xFFa770f3)
val DarkerGreen = Color.Green.copy(alpha = 0.32f)
@@ -81,7 +81,7 @@ class TorManager(
val activePortOrNull: StateFlow<Int?> =
status
.map {
(status.value as? TorServiceStatus.Active)?.port
(it as? TorServiceStatus.Active)?.port
}.stateIn(
scope,
SharingStarted.WhileSubscribed(2000),
@@ -31,12 +31,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.torproject.jni.TorService
import org.torproject.jni.TorService.LocalBinder
private const val SOCKS_PORT_POLL_INTERVAL_MS = 100L
class TorService(
val context: Context,
) {
@@ -46,9 +47,7 @@ class TorService(
trySend(TorServiceStatus.Connecting)
val currentIntent = Intent(context, TorService::class.java)
context.bindService(
currentIntent,
val serviceConnection: ServiceConnection =
object : ServiceConnection {
override fun onServiceConnected(
name: ComponentName,
@@ -59,7 +58,7 @@ class TorService(
val torService = (service as LocalBinder).service
while (torService.socksPort < 0) {
delay(100)
delay(SOCKS_PORT_POLL_INTERVAL_MS)
}
val active = TorServiceStatus.Active(torService.socksPort)
@@ -74,16 +73,25 @@ class TorService(
Log.d("TorService", "Tor Service Disconnected")
trySend(TorServiceStatus.Off)
}
},
}
context.bindService(
currentIntent,
serviceConnection,
BIND_AUTO_CREATE,
)
awaitClose {
Log.d("TorService", "Stopping Tor Service")
try {
context.unbindService(serviceConnection)
} catch (e: Exception) {
Log.d("TorService", "Failed to unbind Tor Service: ${e.message}")
}
launch {
context.stopService(currentIntent)
}
trySend(TorServiceStatus.Off)
}
}.distinctUntilChanged().flowOn(Dispatchers.IO)
}.flowOn(Dispatchers.IO)
}
@@ -537,7 +537,7 @@
<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_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_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>
+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_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_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_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>
@@ -543,7 +543,7 @@ anz der Bedingungen ist erforderlich</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_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_private_presence_indicator">%1$s ist ein privates Mitglied</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_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_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_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>
@@ -290,6 +290,7 @@
<string name="nip_05">Nostr-cím</string>
<string name="never">soha</string>
<string name="now">most</string>
<string name="seconds">másodperc</string>
<string name="h">ó</string>
<string name="m">p</string>
<string name="d">n</string>
@@ -447,6 +448,8 @@
<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_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_in">A szavazás lezárul %1$s múlva</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_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_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="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>
@@ -115,7 +115,7 @@
<string name="yes"></string>
<string name="no"></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_dialog_title">Jaunais %1$s saraksts</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_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="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="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>
@@ -287,6 +287,7 @@
<string name="nip_05">Adres Nostr</string>
<string name="never">nigdy</string>
<string name="now">teraz</string>
<string name="seconds">sekund</string>
<string name="h">godz.</string>
<string name="m">m</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="wallet_connect_connect_app">Podłącz portfel</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="poll_heading_required">Wymagane pola:</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_consensus_threshold">Konsensus</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_in">Ankieta zostanie zamknięta za %1$s</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_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_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_private_presence_indicator">%1$s jest uczestnikiem prywatnym</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_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_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_for">dla %1$s</string>
<string name="app_notification_reply_label">Odpowiedz</string>
@@ -668,9 +671,9 @@
<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="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_target_amount_in_sats">Docelowa kwota w Satach</string>
<string name="sats_to_complete">Zapraiser przy: %1$s. %2$s satach do celu</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 Satoszach</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="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>
@@ -878,7 +881,7 @@
<string name="copy_to_clipboard">Kopiuj 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="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_the_note_id_to_the_clipboard">Kopiuj ID wpisu do schowka</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_placeholder">Nick lub Login</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_placeholder">25</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_already_spent">Tokeny Cashu zostały już wydane.</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="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>
@@ -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="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="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_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>
@@ -992,7 +995,7 @@
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Stan</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_location">Lokalizacja</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="broadcasting">Przekaz</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="bradcasting_result_partial">Niektóre akcje nie powiodły się</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_no_events">brak wydarzeń</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="profiles">profile</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_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_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_private_presence_indicator">%1$s é um membro privado</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="broadcasting">Oddajam</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="bradcasting_result_partial">Nekaterim dogodkom ni 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_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_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_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>
@@ -1181,7 +1181,7 @@
<string name="outbox_relays_title">发件箱中继</string>
<string name="outbox_relays_not_found">设置您的公共发件箱中继来发布内容</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="inbox_relays_title">收件箱中继</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_consensus_threshold">Consensus</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_in">Poll closes in %1$s</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="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_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 com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.startsWithAny
import com.vitorpamplona.quartz.utils.urldetector.Url
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
import kotlinx.coroutines.CancellationException
@Stable
class Urls(
@@ -77,30 +79,35 @@ class UrlParser {
val blossom = mutableSetOf<String>()
urls.forEach { url ->
if (url.isValidTopLevelDomain()) {
if (url.wroteWithSchema()) {
if (url.originalUrl.startsWithAny(httpScheme)) {
// quick exit
completeUrls.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(nostrScheme)) {
bech32.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(websocketScheme)) {
relays.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(blossomScheme)) {
blossom.add(url.originalUrl)
} else {
completeUrls.add(url.originalUrl)
}
} else {
// emails are understood as urls from the detector.
if (url.isEmail()) {
Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach {
emails.add(it.value)
try {
if (url.isValidTopLevelDomain()) {
if (url.wroteWithSchema()) {
if (url.originalUrl.startsWithAny(httpScheme)) {
// quick exit
completeUrls.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(nostrScheme)) {
bech32.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(websocketScheme)) {
relays.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(blossomScheme)) {
blossom.add(url.originalUrl)
} else {
completeUrls.add(url.originalUrl)
}
} else {
urlsWithoutScheme.add(url.originalUrl)
// emails are understood as urls from the detector.
if (url.isEmail()) {
Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach {
emails.add(it.value)
}
} else {
urlsWithoutScheme.add(url.originalUrl)
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("UrlParser", "Trying to parse url `${url.originalUrl}` from `$content`", e)
}
}
+2 -4
View File
@@ -48,7 +48,6 @@ netUrlencoderLibVersion = "1.6.0"
navigationCompose = "2.9.7"
okhttp = "5.3.2"
runner = "1.7.0"
rfc3986 = "0.1.2"
secp256k1KmpJniAndroid = "0.23.0"
securityCryptoKtx = "1.1.0"
slf4j = "2.0.17"
@@ -58,6 +57,7 @@ torAndroid = "0.4.9.5.1"
translate = "17.0.3"
jetbrainsCompose = "1.10.3"
unifiedpush = "3.0.10"
uriReferenceKmp = "1.0"
vico-charts-compose = "3.0.3"
zelory = "3.0.1"
zoomable = "2.11.1"
@@ -72,7 +72,6 @@ androidxExifinterface = "1.4.2"
kotlinTest = "2.3.0"
core = "1.7.0"
mavenPublish = "0.36.0"
spmForKmpVersion = "1.6.1"
sqlite = "2.6.2"
[libraries]
@@ -128,6 +127,7 @@ coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versio
coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" }
commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" }
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
uri-reference-kmp = { module = "io.github.kotlingeekdev:uri-reference-kmp", version.ref = "uriReferenceKmp" }
vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" }
dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" }
drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" }
@@ -165,7 +165,6 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" }
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
@@ -199,4 +198,3 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref =
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
frankois944-spmForKmp = { id = "io.github.frankois944.spmForKmp", version.ref = "spmForKmpVersion" }
+7 -25
View File
@@ -1,9 +1,5 @@
@file:OptIn(ExperimentalSpmForKmpFeature::class)
import com.vanniktech.maven.publish.KotlinMultiplatform
import com.vanniktech.maven.publish.SourcesJar
import io.github.frankois944.spmForKmp.swiftPackageConfig
import io.github.frankois944.spmForKmp.utils.ExperimentalSpmForKmpFeature
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest
@@ -13,7 +9,6 @@ plugins {
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.serialization)
alias(libs.plugins.vanniktech.mavenPublish)
alias(libs.plugins.frankois944.spmForKmp)
}
kotlin {
@@ -26,7 +21,7 @@ kotlin {
}
}
androidLibrary {
android {
namespace = "com.vitorpamplona.quartz"
compileSdk =
libs.versions.android.compileSdk
@@ -121,21 +116,6 @@ kotlin {
dependsOn(libsodiumDefFileGeneration)
}
}
target.swiftPackageConfig(cinteropName = "swiftbridge") {
minIos = "17"
minMacos = "14"
dependency {
remotePackageVersion(
url = uri("https://github.com/swift-standards/swift-rfc-3986.git"),
packageName = "swift-rfc-3986",
products = {
add("RFC 3986")
},
version = "0.1.0",
)
}
}
}
iosArm64 {
@@ -144,6 +124,7 @@ kotlin {
}
binaries.framework {
baseName = xcfName
isStatic = true
binaryOption("bundleId", "com.vitorpamplona.quartz")
}
}
@@ -154,6 +135,7 @@ kotlin {
}
binaries.framework {
baseName = xcfName
isStatic = true
binaryOption("bundleId", "com.vitorpamplona.quartz")
}
}
@@ -201,6 +183,9 @@ kotlin {
// SQLite KMP driver for event store
api(libs.androidx.sqlite)
implementation(libs.androidx.sqlite.bundled)
// RFC3986 library(normalizes URLs)
api(libs.uri.reference.kmp)
}
}
@@ -221,9 +206,6 @@ kotlin {
dependsOn(commonMain.get())
dependencies {
// Normalizes URLs
api(libs.rfc3986.normalizer)
// Performant Parser of JSONs into Events
api(libs.jackson.module.kotlin)
@@ -368,7 +350,7 @@ mavenPublishing {
coordinates(
groupId = "com.vitorpamplona.quartz",
artifactId = "quartz",
version = "1.06.1",
version = "1.06.3",
)
// Configure publishing to Maven Central
@@ -72,6 +72,15 @@ class TagArrayBuilder<T : IEvent> {
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> {
tag.forEach(::add)
return this
@@ -82,6 +91,16 @@ class TagArrayBuilder<T : IEvent> {
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 build() = toTypedArray()
@@ -52,6 +52,12 @@ class ReferenceTag {
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
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter
import com.vitorpamplona.quartz.utils.fastFindURLs
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.startsWithAny
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
fun findURLs(text: String) = fastFindURLs(text)
val rejectSchemes =
listOf(
DualCase("ftp:"),
DualCase("ftps:"),
DualCase("ws:"),
DualCase("wss:"),
DualCase("nostr:"),
DualCase("blossom:"),
)
fun buildUrlRefs(urls: List<String>): List<Array<String>> =
urls
.mapTo(HashSet()) { url ->
HttpUrlFormatter.normalize(url)
}.map {
arrayOf("r", it)
fun findURLs(text: String) =
UrlDetector(text).detect().mapNotNull {
if (it.originalUrl.startsWithAny(rejectSchemes)) {
null
} else {
it.originalUrl
}
}
@@ -31,18 +31,18 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
fun <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) =
when (entity) {
is NNote -> add(entity.toQuoteTagArray())
is NEvent -> add(entity.toQuoteTagArray())
is NAddress -> add(entity.toQuoteTagArray())
is NEmbed -> add(entity.toQuoteTagArray())
is NPub -> add(entity.toQuoteTagArray())
is NProfile -> add(entity.toQuoteTagArray())
is NNote -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NEvent -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NAddress -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NEmbed -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NPub -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NProfile -> addUniqueValueIfNew(entity.toQuoteTagArray())
else -> this
}
@@ -21,6 +21,5 @@
package com.vitorpamplona.quartz.nip89AppHandlers.clientTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
fun Event.client() = tags.zapraiserAmount()
fun Event.client() = tags.client()
@@ -24,9 +24,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
fun TagArrayBuilder<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,
address: AddressHint,
) = addUnique(ClientTag.assemble(name, address.addressId, address.relay))
@@ -20,12 +20,47 @@
*/
package com.vitorpamplona.quartz.utils
expect object Rfc3986 {
fun normalize(uri: String): String
import io.kotlingeekdev.urireference.URIReference
fun isValidUrl(url: String): Boolean
object Rfc3986 {
fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString()
fun normalizeAndRemoveFragment(url: String): String
fun isValidUrl(url: String): Boolean =
runCatching {
URIReference.parse(url)
}.isSuccess
fun host(url: String): String
fun normalizeAndRemoveFragment(url: String): String =
URIReference
.parse(url)
.normalize()!!
.toStringNoFragment()
.internIfPossible()
fun host(url: String): String =
URIReference
.parse(url)
.host
?.value
.toString()
}
fun URIReference.toStringSchemeHost(): String {
val sb = StringBuilder()
if (scheme != null) sb.append(scheme).append(":")
if (authority != null) sb.append("//").append(authority.toString())
return sb.toString()
}
fun URIReference.toStringNoFragment(): String {
val sb = StringBuilder()
if (scheme != null) sb.append(scheme).append(":")
if (host != null) sb.append("//").append(host.toString())
if (path != null) sb.append(path)
if (query != null) sb.append("?").append(query)
return sb.toString()
}
@@ -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
}
var lastWasAscii: Boolean? = null
var lastWasAscii: Boolean? =
if (current.isNullOrEmpty()) {
null
} else {
val last = current.last()
if (isDot(last)) {
null
} else {
last.code < INTERNATIONAL_CHAR_START
}
}
var isAscii = false
while (!done && !reader.eof()) {
val curr: Char = reader.read()
isAscii = curr.code < INTERNATIONAL_CHAR_START
if (lastWasAscii == null) {
lastWasAscii = isAscii
@@ -765,7 +776,7 @@ class DomainNameReader(
* The start of the utf character code table which indicates that this character is an international character.
* Everything below this value is either a-z,A-Z,0-9 or symbols that are not included in domain name.
*/
private const val INTERNATIONAL_CHAR_START = 192
const val INTERNATIONAL_CHAR_START = 192
/**
* The maximum length of each label in the domain name.
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.utils.urldetector.detection
import com.vitorpamplona.quartz.utils.urldetector.Url
import com.vitorpamplona.quartz.utils.urldetector.UrlMarker
import com.vitorpamplona.quartz.utils.urldetector.UrlPart
import com.vitorpamplona.quartz.utils.urldetector.detection.DomainNameReader.Companion.INTERNATIONAL_CHAR_START
import kotlin.math.max
import kotlin.text.deleteRange
@@ -84,6 +85,9 @@ class UrlDetector(
var length = 0
var position = 0
var lastWasAscii: Boolean? = null
var isAscii = false
// until end of string read the contents
while (!reader.eof()) {
// read the next char to process.
@@ -96,6 +100,7 @@ class UrlDetector(
}
readEnd(ReadEndState.InvalidUrl)
length = 0
lastWasAscii = null
}
'%' -> {
@@ -116,6 +121,7 @@ class UrlDetector(
length = 0
}
}
lastWasAscii = null
}
'\u3002', '\uFF0E', '\uFF61', '.' -> {
@@ -125,6 +131,7 @@ class UrlDetector(
readEnd(ReadEndState.InvalidUrl)
}
length = 0
lastWasAscii = null
}
'@' -> {
@@ -136,6 +143,7 @@ class UrlDetector(
}
length = 0
}
lastWasAscii = null
}
'[' -> {
@@ -153,6 +161,7 @@ class UrlDetector(
reader.seek(beginning)
}
length = 0
lastWasAscii = null
}
'/' -> {
@@ -180,15 +189,29 @@ class UrlDetector(
hasScheme = readHtml5Root()
length = buffer.length
}
lastWasAscii = null
}
':' -> {
// add the ":" to the url and check for scheme/username
buffer.append(curr)
length = processColon(length)
lastWasAscii = null
}
else -> {
isAscii = curr.code < INTERNATIONAL_CHAR_START
if (lastWasAscii == null) {
lastWasAscii = isAscii
} else if (isAscii != lastWasAscii) {
// threat changes in char as a space
if (buffer.isNotEmpty() && hasScheme) {
reader.goBack()
readDomainName(buffer.substring(length))
}
length = 0
}
buffer.append(curr)
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip10Notes.urls
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.utils.fastFindURLs
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
@@ -31,7 +30,7 @@ class UrlsDetectorTest {
@Test
fun detectUrlNumber() {
val detectedLinks = fastFindURLs(testSentence)
val detectedLinks = findURLs(testSentence)
assertEquals(2, detectedLinks.size)
}
@@ -48,7 +47,7 @@ class UrlsDetectorTest {
*/
@Test
fun doesNotCrashOnJapaneseText() {
val detectedLinks = fastFindURLs("今北産業")
val detectedLinks = findURLs("今北産業")
assertEquals(0, detectedLinks.size)
}
}
@@ -59,6 +59,6 @@ class UrlTest {
assertNotNull(url.path)
assertNotNull(url.query)
assertNotNull(url.fragment)
assertEquals(-1, url.port) // getPart(PORT) returns null → -1
assertEquals(443, url.port) // getPart(PORT) returns null → -1
}
}
@@ -739,6 +739,37 @@ class UriDetectionTest {
runTest("blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net", "blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net")
}
@Test
fun testBrokenCaseInProduction() {
runTest("今北産業")
runTest("http://test.com今北産業", "http://test.com")
runTest("ftp://test.com今北産業", "ftp://test.com")
runTest("test.com今北産業", "test.com")
runTest("wss://test.com今北産業", "wss://test.com")
runTest("blossom:test.com今北産業", "blossom:test.com")
runTest("nostr:test.com今北産業", "nostr:test.com")
runTest("nostr:test今北産業", "nostr:test")
runTest("nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m")
runTest("今北産業http://test.com", "http://test.com")
runTest("今北産業ftp://test.com", "ftp://test.com")
runTest("今北産業test.com", "test.com")
runTest("今北産業wss://test.com", "wss://test.com")
runTest("今北産業blossom:test.com", "blossom:test.com")
runTest("今北産業nostr:test.com", "nostr:test.com")
runTest("今北産業nostr:test", "nostr:test")
runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m")
runTest("今北産業http://test.com今北産業", "http://test.com")
runTest("今北産業ftp://test.com今北産業", "ftp://test.com")
runTest("今北産業test.com今北産業", "test.com")
runTest("今北産業wss://test.com今北産業", "wss://test.com")
runTest("今北産業blossom:test.com今北産業", "blossom:test.com")
runTest("今北産業nostr:test.com今北産業", "nostr:test.com")
runTest("今北産業nostr:test今北産業", "nostr:test")
runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m")
}
@Test
fun testFullText() {
val text =
@@ -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
}
}