mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f42881a48e | ||
|
|
6fc72f47bb | ||
|
|
041c788e21 | ||
|
|
1f2e24bc1f | ||
|
|
cfd6577029 | ||
|
|
976de28ece | ||
|
|
bc9b6dc912 | ||
|
|
c6fd65dbac | ||
|
|
b3f190410f | ||
|
|
6f7c550034 | ||
|
|
fb976d168a | ||
|
|
be4a6d4fc4 | ||
|
|
d90c868f32 | ||
|
|
e4f55c11f1 | ||
|
|
71242e50d2 | ||
|
|
5d0f9ac866 | ||
|
|
ad9596ab92 | ||
|
|
d847c06979 | ||
|
|
0d8b4e8baa | ||
|
|
70d034e50c | ||
|
|
f11172216d | ||
|
|
2ef8be8fdc | ||
|
|
cc850c5b7f | ||
|
|
728ff9a4cd | ||
|
|
6f9fc23a64 | ||
|
|
bcdaf8630c | ||
|
|
2d25bb2592 | ||
|
|
405d1d0519 | ||
|
|
88042f06d4 | ||
|
|
8437a819f8 | ||
|
|
cef07a2460 | ||
|
|
2a2c61973e | ||
|
|
349781d9be | ||
|
|
334aabbf02 | ||
|
|
2933454fb6 | ||
|
|
09054d6ac5 | ||
|
|
4d8be5e272 | ||
|
|
0ad3180fc5 | ||
|
|
c913c0e1ab | ||
|
|
30ff9ab09b | ||
|
|
159f1a121b | ||
|
|
9b5b50f237 | ||
|
|
898e6ac677 | ||
|
|
d6d4bdc5e3 | ||
|
|
c39eca5172 | ||
|
|
3d9efa9938 | ||
|
|
11dabd46ba | ||
|
|
94e708b5fc | ||
|
|
8d81d6d57c | ||
|
|
8c4b2fe62b | ||
|
|
514e90ff17 | ||
|
|
4c14554263 | ||
|
|
22142d17f9 | ||
|
|
e976bf9e2e | ||
|
|
4e43b14b09 | ||
|
|
6f3308e45c | ||
|
|
1dbe552821 | ||
|
|
6436668808 | ||
|
|
346a7ecac2 | ||
|
|
c67d66981c | ||
|
|
6e0e8cf00f | ||
|
|
8525f2b930 | ||
|
|
ac38a73b5e | ||
|
|
4672e6b587 | ||
|
|
bddff36e6b | ||
|
|
03c00b9fba | ||
|
|
b495f3f409 | ||
|
|
cfecfe2130 | ||
|
|
f2bdb24ee5 | ||
|
|
a9c41e1d55 |
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans
|
||||
|
||||
## Overview
|
||||
|
||||
Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs a table ready for translation.
|
||||
Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
|
||||
|
||||
## When to Use
|
||||
|
||||
@@ -15,6 +15,17 @@ Extract string resource keys from the default `values/strings.xml` that are abse
|
||||
- Preparing a batch of strings for a translator
|
||||
- Checking translation coverage after adding new features
|
||||
|
||||
## Target Locales
|
||||
|
||||
The default set of locales (unless the user specifies otherwise):
|
||||
|
||||
| Locale | Language | Directory |
|
||||
|--------|----------|-----------|
|
||||
| `cs-rCZ` | Czech | `values-cs-rCZ` |
|
||||
| `pt-rBR` | Brazilian Portuguese | `values-pt-rBR` |
|
||||
| `sv-rSE` | Swedish | `values-sv-rSE` |
|
||||
| `de-rDE` | German | `values-de-rDE` |
|
||||
|
||||
## Technique
|
||||
|
||||
### 1. Identify files
|
||||
@@ -24,11 +35,9 @@ Default: amethyst/src/main/res/values/strings.xml
|
||||
Target: amethyst/src/main/res/values-<locale>/strings.xml
|
||||
```
|
||||
|
||||
Default locale: `cs-rCZ` if none specified. User may override (e.g., `pt-rBR`, `ja`).
|
||||
### 2. Find missing keys using cs-rCZ as reference
|
||||
|
||||
### 2. Extract and diff keys
|
||||
|
||||
Use a single bash pipeline to extract translatable keys from both files and diff them:
|
||||
Always diff against `cs-rCZ` first — it is the most complete locale and serves as the reference. Any keys missing in `cs-rCZ` will also be missing in the other target locales.
|
||||
|
||||
```bash
|
||||
# Extract translatable keys from default (exclude translatable="false")
|
||||
@@ -36,11 +45,11 @@ comm -23 \
|
||||
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
|
||||
| grep -v 'translatable="false"' \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||||
<(grep '<string name=' amethyst/src/main/res/values-<LOCALE>/strings.xml \
|
||||
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
|
||||
```
|
||||
|
||||
This gives the list of missing key names.
|
||||
This gives the list of missing key names. Do NOT diff each locale separately — assume the same keys are missing in all target locales.
|
||||
|
||||
### 3. Get English values for missing keys
|
||||
|
||||
@@ -54,13 +63,13 @@ done < <(comm -23 \
|
||||
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
|
||||
| grep -v 'translatable="false"' \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||||
<(grep '<string name=' amethyst/src/main/res/values-<LOCALE>/strings.xml \
|
||||
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
|
||||
```
|
||||
|
||||
### 4. Present results
|
||||
### 4. Present results and ask to translate
|
||||
|
||||
Output the missing entries as raw XML resource lines (copy-paste ready for the locale file):
|
||||
Output the missing entries as raw XML resource lines (copy-paste ready):
|
||||
|
||||
```xml
|
||||
<string name="attestation_valid">Valid</string>
|
||||
@@ -70,8 +79,18 @@ Output the missing entries as raw XML resource lines (copy-paste ready for the l
|
||||
|
||||
Also check `<string-array>` and `<plurals>` tags using the same approach if the project uses them.
|
||||
|
||||
**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?"
|
||||
|
||||
### 5. Adding translations (if approved)
|
||||
|
||||
When adding translated strings to locale files:
|
||||
|
||||
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
|
||||
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- **Forgetting `translatable="false"`** — these should never appear in locale files
|
||||
- **Not checking string-arrays/plurals** — only checking `<string>` misses other resource types
|
||||
- **Modifying files** — this is a read-only research task unless the user asks to add entries
|
||||
- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere
|
||||
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -50,7 +50,7 @@ fun RecordAudioBox(
|
||||
modifier: Modifier,
|
||||
onRecordTaken: (RecordingResult) -> Unit,
|
||||
maxDurationSeconds: Int? = null,
|
||||
content: @Composable (Boolean, Int) -> Unit,
|
||||
content: @Composable (Boolean, Int, () -> Unit) -> Unit,
|
||||
) {
|
||||
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
|
||||
val context = LocalContext.current
|
||||
@@ -79,7 +79,8 @@ fun RecordAudioBox(
|
||||
}
|
||||
|
||||
fun stopRecording() {
|
||||
val result = mediaRecorder.value?.stop()
|
||||
val recorder = mediaRecorder.value ?: return
|
||||
val result = recorder.stop()
|
||||
mediaRecorder.value = null
|
||||
if (result != null) {
|
||||
onRecordTaken(result)
|
||||
@@ -136,6 +137,10 @@ fun RecordAudioBox(
|
||||
}
|
||||
}
|
||||
},
|
||||
content = { active -> content(active, elapsedSeconds) },
|
||||
content = { active ->
|
||||
content(active, elapsedSeconds) {
|
||||
stopRecording()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,15 +50,17 @@ fun RecordVoiceButton(
|
||||
) {
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
var elapsedSeconds by remember { mutableIntStateOf(0) }
|
||||
var onStopRecording: (() -> Unit)? by remember { mutableStateOf(null) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// Floating recording indicator at the top
|
||||
// Floating recording indicator at the top (outside ToggleableBox to avoid scale/circle)
|
||||
FloatingRecordingIndicator(
|
||||
modifier = Modifier.height(50.dp),
|
||||
isRecording = isRecording,
|
||||
elapsedSeconds = elapsedSeconds,
|
||||
onClick = onStopRecording,
|
||||
)
|
||||
|
||||
RecordAudioBox(
|
||||
@@ -69,15 +71,11 @@ fun RecordVoiceButton(
|
||||
onVoiceTaken(recording)
|
||||
},
|
||||
maxDurationSeconds = maxDurationSeconds,
|
||||
) { recordingState, elapsed ->
|
||||
// Update parent state after composition completes
|
||||
) { recordingState, elapsed, onStop ->
|
||||
SideEffect {
|
||||
if (isRecording != recordingState) {
|
||||
isRecording = recordingState
|
||||
}
|
||||
if (elapsedSeconds != elapsed) {
|
||||
elapsedSeconds = elapsed
|
||||
}
|
||||
isRecording = recordingState
|
||||
elapsedSeconds = elapsed
|
||||
onStopRecording = onStop
|
||||
}
|
||||
|
||||
Box(
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -176,6 +177,7 @@ fun FloatingRecordingIndicator(
|
||||
isRecording: Boolean,
|
||||
elapsedSeconds: Int,
|
||||
isCompact: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
if (!isRecording) return
|
||||
|
||||
@@ -199,6 +201,12 @@ fun FloatingRecordingIndicator(
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
).then(
|
||||
if (onClick != null) {
|
||||
Modifier.clickable(onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
||||
@@ -215,7 +215,7 @@ private fun ReRecordButton(
|
||||
modifier = Modifier,
|
||||
onRecordTaken = onRecordTaken,
|
||||
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
|
||||
) { isRecording, elapsedSeconds ->
|
||||
) { isRecording, elapsedSeconds, _ ->
|
||||
val contentColor =
|
||||
if (isRecording) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -672,7 +672,7 @@ fun ReplyViaVoiceReaction(
|
||||
}
|
||||
},
|
||||
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
|
||||
) { isRecording, elapsedSeconds ->
|
||||
) { isRecording, elapsedSeconds, onStop ->
|
||||
if (voiceRecordingState != null) {
|
||||
SideEffect {
|
||||
if (voiceRecordingState.value != isRecording) {
|
||||
@@ -689,6 +689,7 @@ fun ReplyViaVoiceReaction(
|
||||
isRecording = true,
|
||||
elapsedSeconds = elapsedSeconds,
|
||||
isCompact = true,
|
||||
onClick = onStop,
|
||||
)
|
||||
} else {
|
||||
VoiceReplyIcon(iconSizeModifier, grayTint)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -290,6 +290,7 @@
|
||||
<string name="nip_05">Nostr Adresa</string>
|
||||
<string name="never">nikdy</string>
|
||||
<string name="now">nyní</string>
|
||||
<string name="seconds">sekundy</string>
|
||||
<string name="h">h</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">d</string>
|
||||
@@ -536,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -294,6 +294,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="nip_05">Nostr-Adresse</string>
|
||||
<string name="never">nie</string>
|
||||
<string name="now">jetzt</string>
|
||||
<string name="seconds">Sekunden</string>
|
||||
<string name="h">s</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">t</string>
|
||||
@@ -542,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>
|
||||
|
||||
@@ -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">(0–100)%</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 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_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">Jā</string>
|
||||
<string name="no">Nē</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>
|
||||
|
||||
@@ -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">(0–100)%</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 & 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>
|
||||
|
||||
@@ -290,6 +290,7 @@
|
||||
<string name="nip_05">Endereço Nostr</string>
|
||||
<string name="never">nunca</string>
|
||||
<string name="now">agora</string>
|
||||
<string name="seconds">segundos</string>
|
||||
<string name="h">h</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">d</string>
|
||||
@@ -536,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>
|
||||
|
||||
@@ -290,6 +290,7 @@
|
||||
<string name="nip_05">Nostr-adress</string>
|
||||
<string name="never">aldrig</string>
|
||||
<string name="now">nu</string>
|
||||
<string name="seconds">sekunder</string>
|
||||
<string name="h">t</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">d</string>
|
||||
@@ -536,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>
|
||||
|
||||
@@ -290,6 +290,7 @@
|
||||
<string name="nip_05">Nostr 地址</string>
|
||||
<string name="never">从不</string>
|
||||
<string name="now">现在</string>
|
||||
<string name="seconds">秒</string>
|
||||
<string name="h">时</string>
|
||||
<string name="m">分</string>
|
||||
<string name="d">天</string>
|
||||
@@ -1104,6 +1105,13 @@
|
||||
<string name="new_community_note">新社区笔记</string>
|
||||
<string name="new_product">新产品</string>
|
||||
<string name="new_exclusive_geo_note">新建地理位置限定帖文</string>
|
||||
<string name="new_long_form_post">新文章</string>
|
||||
<string name="article_title">标题</string>
|
||||
<string name="article_summary">摘要(选填)</string>
|
||||
<string name="article_cover_image_url">封面图片URL (可选)</string>
|
||||
<string name="write_your_article_in_markdown">用 markdown 格式撰写文章…</string>
|
||||
<string name="markdown_preview">预览</string>
|
||||
<string name="markdown_edit">编辑</string>
|
||||
<string name="open_all_reactions_to_this_post">展开对此帖子的所有回应</string>
|
||||
<string name="close_all_reactions_to_this_post">收起对此帖子的所有回应</string>
|
||||
<string name="reply_description">回复</string>
|
||||
@@ -1173,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">插入 1–3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入</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>
|
||||
@@ -1241,6 +1249,7 @@
|
||||
<string name="existed_since">OTS:%1$s</string>
|
||||
<string name="ots_info_title">OpenTimestamps 证明</string>
|
||||
<string name="ots_info_description">%1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。</string>
|
||||
<string name="edit_article">编辑文章</string>
|
||||
<string name="edit_post">编辑帖子</string>
|
||||
<string name="proposal_to_edit">提议改进帖子</string>
|
||||
<string name="message_to_author">变动摘要</string>
|
||||
@@ -1664,4 +1673,11 @@
|
||||
<string name="attestor_proficiency_for_kinds">熟练验证类型:%1$s</string>
|
||||
<string name="attestation_attests_to">证明</string>
|
||||
<string name="attestation_requests_attestation_to">请求证明</string>
|
||||
<string name="event_sync_date_filter_title">日期范围</string>
|
||||
<string name="event_sync_date_filter_since">自</string>
|
||||
<string name="event_sync_date_filter_until">到</string>
|
||||
<string name="event_sync_date_filter_now">刚刚</string>
|
||||
<string name="event_sync_date_filter_all_time">全部时间</string>
|
||||
<string name="event_sync_date_filter_last_sync">上次同步: %1$s</string>
|
||||
<string name="event_sync_date_filter_since_last_sync">自上次同步后</string>
|
||||
</resources>
|
||||
|
||||
@@ -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">(0–100)%</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 & 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user