Merge branch 'main' into claude/remove-libsodium-dependency-DjtWa

This commit is contained in:
Vitor Pamplona
2026-03-25 11:24:55 -04:00
committed by GitHub
170 changed files with 6727 additions and 1054 deletions
+2 -2
View File
@@ -745,8 +745,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
versionCode = 430
versionName = "1.04.2"
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
+3 -3
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.05.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.05.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.05.1")
implementation("com.vitorpamplona.quartz:quartz:1.06.3")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.05.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.05.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.05.1")
implementation("com.vitorpamplona.quartz:quartz:1.06.3")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.05.1")
implementation("com.vitorpamplona.quartz:quartz:1.06.3")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
+1 -1
View File
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
## Current artifact
```
com.vitorpamplona.quartz:quartz:1.05.1
com.vitorpamplona.quartz:quartz:1.06.3
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
+2 -1
View File
@@ -7,6 +7,7 @@
<option name="jvmTarget" value="21" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.3.10" />
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.3.20" />
</component>
</project>
+1
View File
@@ -268,6 +268,7 @@ Updated translations:
- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t
- Chinese by hypnotichemionus4
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
- Russian by Anton Zhao
<a id="v1.05.1"></a>
# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08
+18 -18
View File
@@ -405,24 +405,24 @@ to `onPause` methods.
### Feature Parity Table
| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
| :--- | :--- | :---: | :---: | :--- |
| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ❌ No | Core Nostr signing/verification is missing on iOS. |
| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ❌ No | AEAD and stream ciphers are unimplemented. |
| | AES Encryption (CBC & GCM) | ✅ Full | ❌ No | `AESCBC` and `AESGCM` are stubs on iOS. |
| | Hashing (SHA-256, etc.) | ✅ Full | ❌ No | `DigestInstance` is unimplemented. |
| | MAC (HmacSHA256, etc.) | ✅ Full | ❌ No | `MacInstance` is unimplemented. |
| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ❌ No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. |
| | GZip Compression | ✅ Full | ❌ No | `GZip` implementation is missing. |
| | BitSet | ✅ Full | ❌ No | `BitSet` utility is unimplemented. |
| | LargeCache | ✅ Full | ❌ No | `LargeCache` methods (get, keys, size, etc.) are stubs. |
| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ❌ No | `ServerInfoParser` is unimplemented. |
| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. |
| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. |
| **Utilities** | URL Encoding / Decoding | ✅ Full | ❌ No | `UrlEncoder` and `URLs.ios.kt` are unimplemented. |
| | Unicode Normalization | ✅ Full | ❌ No | `UnicodeNormalizer` is a stub. |
| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
|:-------------------------|:-------------------------------|:---------------------:|:-----------:|:-----------------------------------------------------------------------|
| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ✅ Full | |
| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ✅ Full | |
| | AES Encryption (CBC & GCM) | ✅ Full | ✅ Full | |
| | Hashing (SHA-256, etc.) | ✅ Full | ✅ Full | |
| | MAC (HmacSHA256, etc.) | ✅ Full | ✅ Full | |
| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ✅ Full | A fully custom implementation exists in `commonMain`. |
| | GZip Compression | ✅ Full | ✅ Full | |
| | BitSet | ✅ Full | ✅ Full | |
| | LargeCache | ✅ Full | ✅ Full | |
| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ✅ Full | |
| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. |
| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. |
| **Utilities** | URL Encoding / Decoding | ✅ Full | ✅ Full | |
| | Unicode Normalization | ✅ Full | ✅ Full | |
| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
## Contributing
+3 -4
View File
@@ -5,7 +5,6 @@ plugins {
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.stability.analyzer)
}
def getCurrentBranch() {
@@ -55,9 +54,9 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 432
versionName = generateVersionName("1.05.1")
buildConfigField "String", "RELEASE_NOTES_ID", "\"b457a20195ffcf501389fcb708f0ef73f4ee263e3bba63f1b893a896129e4c79\""
versionCode = 435
versionName = generateVersionName("1.06.3")
buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -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),
@@ -24,8 +24,6 @@ import android.os.Build
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
import com.vitorpamplona.amethyst.BuildConfig
class Logging {
companion object {
@@ -60,9 +58,6 @@ class Logging {
)
// Looper.getMainLooper().setMessageLogging(LogMonitor())
// ChoreographerHelper.start()
// Enable recomposition tracking ONLY in debug builds
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
}
}
}
@@ -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(
@@ -193,11 +193,15 @@ fun uriToRoute(
}
if (isWalletConnectRoute(uri)) {
val url = UriParser(uri)
val nip47Uri = url.getQueryParameter("value")
if (nip47Uri != null) {
Nip47WalletConnect.parse(nip47Uri)
return Route.Nip47NWCSetup(nip47Uri)
try {
val url = UriParser(uri)
val nip47Uri = url.getQueryParameter("value")
if (nip47Uri != null) {
Nip47WalletConnect.parse(nip47Uri)
return Route.Nip47NWCSetup(nip47Uri)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
@@ -45,6 +45,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg
import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg2
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -72,6 +73,27 @@ fun InformationDialog(
InformationDialog(title = stringRes(obj.titleResId), textContent = str, moreInfo = stack, buttonColors, onDismiss)
}
@Composable
fun InformationDialog(
obj: ThrowableToastMsg2,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onDismiss: () -> Unit,
) {
val str = stringRes(obj.description)
val stack =
remember(obj) {
val writer = StringWriter()
writer.append("\n")
obj.throwable.printStackTrace(PrintWriter(writer))
writer.toString()
}
InformationDialog(title = stringRes(obj.titleResId), textContent = str, moreInfo = stack, buttonColors, onDismiss)
}
@Composable
fun InformationDialog(
title: String,
@@ -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)
}
@@ -82,6 +82,12 @@ fun DisplayErrorMessages(
}
}
is ThrowableToastMsg2 -> {
InformationDialog(obj) {
toastManager.clearToasts()
}
}
is MultiErrorToastMsg -> {
MultiUserErrorMessageDialog(obj, accountViewModel, nav)
}
@@ -28,3 +28,10 @@ class ThrowableToastMsg(
val msg: String? = null,
val throwable: Throwable,
) : ToastMsg()
@Immutable
class ThrowableToastMsg2(
val titleResId: Int,
val description: Int,
val throwable: Throwable,
) : ToastMsg()
@@ -62,6 +62,14 @@ class ToastManager {
toasts.tryEmit(ThrowableToastMsg(titleResId, message, throwable))
}
fun toast(
titleResId: Int,
description: Int,
throwable: Throwable,
) {
toasts.tryEmit(ThrowableToastMsg2(titleResId, description, throwable))
}
fun toast(
titleResId: Int,
resourceId: Int,
@@ -53,7 +53,11 @@ fun FeedLoaded(
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
NoteCompose(
item,
@@ -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(
@@ -45,9 +45,14 @@ var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
fun timeAgo(
time: Long?,
context: Context,
prefix: String = "",
seconds: Int = R.string.now,
minutes: Int = R.string.m,
hours: Int = R.string.h,
days: Int = R.string.d,
): String {
if (time == null) return " "
if (time == 0L) return "${stringRes(context, R.string.never)}"
if (time == 0L) return prefix + stringRes(context, R.string.never)
val timeDifference = TimeUtils.now() - time
@@ -60,7 +65,7 @@ fun timeAgo(
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
"" + yearFormatter.format(time * 1000)
prefix + yearFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_MONTH) {
// Dec 12
if (locale != Locale.getDefault()) {
@@ -69,16 +74,16 @@ fun timeAgo(
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
"" + monthFormatter.format(time * 1000)
prefix + monthFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_DAY) {
// 2 days
"" + (timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, R.string.d)
prefix + (timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, days)
} else if (timeDifference > TimeUtils.ONE_HOUR) {
"" + (timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, R.string.h)
prefix + (timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, hours)
} else if (timeDifference > TimeUtils.ONE_MINUTE) {
"" + (timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, R.string.m)
prefix + (timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, minutes)
} else {
"" + stringRes(context, R.string.now)
prefix + stringRes(context, seconds)
}
}
@@ -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,
@@ -36,11 +36,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ErrorOutline
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
@@ -80,7 +81,6 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
@@ -102,8 +102,7 @@ fun RenderAttestationPreview() {
arrayOf(
arrayOf("d", "af5aa898:fe108febb997:1773941524"),
arrayOf("e", "fe108febb99796c4091775e00aa1fc3ffc489ad22fdf1f8c559b2472815c09c7"),
arrayOf("s", "verified"),
arrayOf("v", "valid"),
arrayOf("s", "valid"),
arrayOf("client", "attestr.xyz"),
),
)
@@ -157,19 +156,17 @@ fun RenderAttestation(
accountViewModel: AccountViewModel,
nav: INav,
) {
val validity = remember(noteEvent) { noteEvent.validity() }
val status = remember(noteEvent) { noteEvent.status() }
val validFrom = remember(noteEvent) { noteEvent.validFrom() }
val validTo = remember(noteEvent) { noteEvent.validTo() }
val content = remember(noteEvent) { noteEvent.content.ifBlank { null } }
val statusColor = remember(status, validity) { attestationColor(status, validity) }
val statusIcon = remember(status, validity) { attestationIcon(status, validity) }
val statusLabel = attestationStatusLabel(status, validity)
val statusColor = remember(status) { attestationColor(status) }
val statusIcon = remember(status) { attestationIcon(status) }
val statusLabel = attestationStatusLabel(status)
val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() }
val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() }
val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() }
Column(
modifier =
@@ -253,13 +250,6 @@ fun RenderAttestation(
)
}
}
} else if (aboutPubkey != null) {
LoadUser(aboutPubkey, accountViewModel) {
if (it != null) {
Spacer(modifier = DoubleVertSpacer)
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
}
}
}
}
@@ -586,49 +576,31 @@ fun RenderAttestorProficiency(
}
}
private fun attestationColor(
status: AttestationStatus?,
validity: Validity?,
): Color =
private fun attestationColor(status: AttestationStatus?): 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.INVALID -> Color(0xFFB71C1C)
status == AttestationStatus.VALID -> Color(0xFF2E7D32)
status == AttestationStatus.REVOKED -> Color(0xFFB21CB7)
status == AttestationStatus.VERIFYING -> Color(0xFF173CF5)
else -> Color(0xFF757575)
}
private fun attestationIcon(
status: AttestationStatus?,
validity: Validity?,
): ImageVector =
private fun attestationIcon(status: AttestationStatus?): 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.INVALID -> Icons.Default.Close
status == AttestationStatus.VALID -> Icons.Default.CheckCircle
status == AttestationStatus.REVOKED -> Icons.Default.RemoveDone
status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop
status == AttestationStatus.ACCEPTED -> Icons.Default.CheckCircle
else -> Icons.Default.VerifiedUser
else -> Icons.Default.ErrorOutline
}
@Composable
private fun attestationStatusLabel(
status: AttestationStatus?,
validity: Validity?,
): String =
private fun attestationStatusLabel(status: AttestationStatus?): String =
when {
status == AttestationStatus.INVALID -> stringRes(R.string.attestation_invalid)
status == AttestationStatus.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)
}
@@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
@@ -56,7 +57,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -82,11 +89,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BigPadding
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
@@ -178,7 +184,7 @@ fun InnerRenderPoll(
TranslatableRichTextViewer(
content = label,
canPreview = canPreview,
quotesLeft = 1,
quotesLeft = if (quotesLeft > 0) 1 else 0,
modifier = Modifier.fillMaxWidth(),
tags = tags,
backgroundColor = backgroundColor,
@@ -328,10 +334,13 @@ private fun ColumnScope.RenderSingleChoiceOptions(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val hasSpaceToClick =
remember {
it.label.contains(' ') || it.label.contains('\n')
}
Column(
modifier =
Modifier
.fillMaxWidth(),
modifier = if (hasSpaceToClick) Modifier.fillMaxWidth() else Modifier.fillMaxWidth(0.9f),
) {
labelContent(it.code, it.label)
}
@@ -403,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)
}
}
@@ -413,17 +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, 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,
) {
@@ -481,18 +500,21 @@ private fun RenderClosedItem(
content = labelContent,
)
Spacer(StdHorzSpacer)
Spacer(DoubleHorzSpacer)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy10dp,
) {
UserGallery(tally, resultContent)
if (showGallery) {
UserGallery(tally, resultContent)
}
Text(
text = "${(tally.percent * 100).toInt()}%",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.End,
modifier = measure100PercentWidthModifier(MaterialTheme.typography.bodyMedium),
maxLines = 1,
)
}
@@ -500,6 +522,24 @@ private fun RenderClosedItem(
}
}
@Composable
fun measure100PercentWidthModifier(textStyle: TextStyle): Modifier {
val fontFamilyResolver = LocalFontFamilyResolver.current
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
return remember(fontFamilyResolver, density, textStyle) {
val widthPx =
TextMeasurer(fontFamilyResolver, density, layoutDirection, 1)
.measure("100%", style = textStyle.copy(fontWeight = FontWeight.Bold))
.size
.width
with(density) {
Modifier.width(widthPx.toDp())
}
}
}
@Composable
fun UserGallery(
tally: TallyResults,
@@ -510,13 +550,13 @@ fun UserGallery(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy((-10).dp),
) {
tally.users.take(6).forEach {
tally.users.take(4).forEach {
key(it.pubkeyHex) {
galleryUser(it)
}
}
if (tally.users.size > 6) {
if (tally.users.size > 4) {
Box(
contentAlignment = Alignment.Center,
modifier =
@@ -526,7 +566,7 @@ fun UserGallery(
.background(MaterialTheme.colorScheme.secondaryContainer),
) {
Text(
text = "+" + showCount(tally.users.size - 6),
text = "+" + showCount(tally.users.size - 4),
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -584,6 +624,54 @@ fun RenderPollManualPreview() {
}
}
@Preview
@Composable
fun RenderPollManualLongPreview() {
val poll =
PollCard(
options =
listOf(
PollItemCard(
code = "1",
label = "Yes".repeat(300),
results = flow {},
currentResults = {
TallyResults(
percent = 1.0f,
isWinning = true,
)
},
),
PollItemCard(
code = "2",
label = "No".repeat(300),
results = flow {},
currentResults = {
TallyResults(
percent = 0.0f,
isWinning = false,
)
},
),
),
type = PollType.SINGLE_CHOICE,
endsAt = null,
isMyPoll = true,
haveIVotedFlow = flow {},
haveIVoted = { true },
)
ThemeComparisonColumn {
Column(Modifier.padding(10.dp)) {
RenderPollCard(poll, {}, {}) { _, label ->
Text(
text = label,
)
}
}
}
}
@SuppressLint("StateFlowValueCalledInComposition")
@Preview
@Composable
@@ -962,6 +962,12 @@ class AccountViewModel(
Log.w("AccountViewModel", "AutomaticallyUnauthorizedException", e)
} catch (e: SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException) {
Log.w("AccountViewModel", "TimedOutRunningOnBackgroundWithoutAutomaticPermissionExceptionException", e)
} catch (e: IllegalStateException) {
toastManager.toast(
R.string.signer_not_found_exception,
R.string.signer_illegal_state_exception_description,
e,
)
}
}
}
@@ -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,13 +135,25 @@ 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(),
reverseLayout = true,
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { index, item ->
itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { index, item ->
val noteEvent = item.event
if (avoidDraft == null || noteEvent !is DraftWrapEvent || noteEvent.dTag() !in avoidDraft.usedDraftTags) {
ChatroomMessageCompose(
@@ -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 {
@@ -313,7 +313,7 @@ fun FeedLoaded(
Spacer(StdVertSpacer)
}
}
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { _, item ->
Row(
Modifier
.fillMaxWidth()
@@ -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 {
@@ -309,7 +309,7 @@ fun DisplayLastSeen(
lastSeen?.let { timestamp ->
val context = LocalContext.current
Text(
text = stringRes(R.string.last_seen, timeAgo(timestamp, context)),
text = stringRes(R.string.last_seen, timeAgo(timestamp, context, prefix = "", seconds = R.string.seconds)),
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -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
}
@@ -155,11 +155,21 @@ class EventSync(
val inboxTargets: Map<NormalizedRelayUrl, DestinationRelayInfo> = emptyMap(),
val dmTargets: Map<NormalizedRelayUrl, DestinationRelayInfo> = emptyMap(),
) {
companion object {
val DefaultOrder = compareByDescending<SourceRelayInfo> { it.eventsFound.value }.thenByDescending { it.status.value == ConnectionStatus.Completed }
}
val sortedCompletedRelays =
completedRelays.values.let {
// precompute to avoid: Comparison method violates its general contract
val sortedCompletedRelays = completedRelays.values.sortedWith(DefaultOrder)
val orderCacheEventsFound = it.associateWith { it.eventsFound.value }
val orderCacheCompleted = it.associateWith { it.status.value == ConnectionStatus.Completed }
val orderComparator =
compareByDescending<SourceRelayInfo> {
orderCacheEventsFound[it]
}.thenByDescending {
orderCacheCompleted[it]
}
it.sortedWith(orderComparator)
}
constructor(
runningRelays: List<SourceRelayInfo>,
@@ -330,7 +330,11 @@ fun RenderThreadFeed(
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { index, item ->
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { index, _ -> if (index == 0) "master" else "reply" },
) { index, item ->
val level = viewModel.levelFlowForItem(item).collectAsStateWithLifecycle(0)
val modifier =
@@ -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)
}
@@ -71,7 +71,7 @@
<string name="following">" অনুসরণ"</string>
<string name="followers">" অনুসারী"</string>
<string name="number_following">"%1$s অনুসরণ"</string>
<string name="number_followers">""</string>
<string name="number_followers">"%1$s অনুসারী"</string>
<string name="profile">প্রোফাইল</string>
<string name="security_filters">নিরাপত্তা-ফিল্টার</string>
<string name="log_out">লগ আউট</string>
@@ -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>
@@ -1099,6 +1100,13 @@
<string name="new_community_note">Nová poznámka komunity</string>
<string name="new_product">Nový produkt</string>
<string name="new_exclusive_geo_note">Nový geoexkluzivní příspěvek</string>
<string name="new_long_form_post">Nový článek</string>
<string name="article_title">Název</string>
<string name="article_summary">Shrnutí (volitelné)</string>
<string name="article_cover_image_url">URL obrázku obálky (volitelné)</string>
<string name="write_your_article_in_markdown">Napište svůj článek v markdownu…</string>
<string name="markdown_preview">Náhled</string>
<string name="markdown_edit">Upravit</string>
<string name="open_all_reactions_to_this_post">Otevřít všechny reakce pro tento příspěvek</string>
<string name="close_all_reactions_to_this_post">Zavřít všechny reakce na tento příspěvek</string>
<string name="reply_description">Odpověď</string>
@@ -1236,6 +1244,7 @@
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Důkaz časového razítka</string>
<string name="ots_info_description">Existuje důkaz, že tento příspěvek byl podepsán někdy před %1$s. Důkaz byl označen v Bitcoin blockchainu v tomto datu a čase.</string>
<string name="edit_article">Upravit článek</string>
<string name="edit_post">Upravit příspěvek</string>
<string name="proposal_to_edit">Návrh na vylepšení vašeho příspěvku</string>
<string name="message_to_author">Souhrn změn</string>
@@ -1659,4 +1668,11 @@
<string name="attestor_proficiency_for_kinds">Odborník na ověřování druhů: %1$s</string>
<string name="attestation_attests_to">Potvrzuje</string>
<string name="attestation_requests_attestation_to">Žádosti o atestaci pro</string>
<string name="event_sync_date_filter_title">Časové rozmezí</string>
<string name="event_sync_date_filter_since">Od</string>
<string name="event_sync_date_filter_until">Do</string>
<string name="event_sync_date_filter_now">Nyní</string>
<string name="event_sync_date_filter_all_time">Vždy</string>
<string name="event_sync_date_filter_last_sync">Poslední synchronizace: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Od poslední synchronizace</string>
</resources>
+1 -1
View File
@@ -961,7 +961,7 @@
<string name="follow_set_empty_feed_msg">Zdá se, že zatím nemáte žádné sady sledování.\nKlepněte níže pro obnovení nebo použijte tlačítko přidat k vytvoření nové.</string>
<string name="follow_set_add_author_from_note_action">Přidat autora do sady sledování</string>
<string name="follow_set_profile_actions_menu_description">Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem.</string>
<string name="follow_set_icon_description">Ikona pro seznam %1$s</string>
<string name="follow_set_icon_description">Ikona pro seznam</string>
<string name="follow_set_absence_indicator">%1$s není v tomto seznamu</string>
<string name="follow_set_man_dialog_title">Vaše sady sledování</string>
<string name="follow_set_empty_dialog_msg">Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové.</string>
@@ -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>
@@ -1104,6 +1105,13 @@ anz der Bedingungen ist erforderlich</string>
<string name="new_community_note">Neue Community-Notiz</string>
<string name="new_product">Neues Produkt</string>
<string name="new_exclusive_geo_note">Neuer Geo-Exklusiver Beitrag</string>
<string name="new_long_form_post">Neuer Artikel</string>
<string name="article_title">Titel</string>
<string name="article_summary">Zusammenfassung (optional)</string>
<string name="article_cover_image_url">Cover-Bild-URL (optional)</string>
<string name="write_your_article_in_markdown">Schreib deinen Artikel in Markdown…</string>
<string name="markdown_preview">Vorschau</string>
<string name="markdown_edit">Bearbeiten</string>
<string name="open_all_reactions_to_this_post">Alle Reaktionen auf diesen Beitrag öffnen</string>
<string name="close_all_reactions_to_this_post">Alle Reaktionen auf diesen Beitrag schließen</string>
<string name="reply_description">Antworten</string>
@@ -1241,6 +1249,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Zeitstempel Beweis</string>
<string name="ots_info_description">Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt.</string>
<string name="edit_article">Artikel bearbeiten</string>
<string name="edit_post">Beitrag bearbeiten</string>
<string name="proposal_to_edit">Vorschlag zur Verbesserung Ihres Beitrags</string>
<string name="message_to_author">Zusammenfassung der Änderungen</string>
@@ -1664,4 +1673,11 @@ anz der Bedingungen ist erforderlich</string>
<string name="attestor_proficiency_for_kinds">Kompetent für die Verifizierung von Arten: %1$s</string>
<string name="attestation_attests_to">Bestätigt</string>
<string name="attestation_requests_attestation_to">Beantragt Attestierung für</string>
<string name="event_sync_date_filter_title">Zeitraum</string>
<string name="event_sync_date_filter_since">Von</string>
<string name="event_sync_date_filter_until">Bis</string>
<string name="event_sync_date_filter_now">Jetzt</string>
<string name="event_sync_date_filter_all_time">Gesamter Zeitraum</string>
<string name="event_sync_date_filter_last_sync">Letzte Synchronisierung: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Seit letzter Synchronisierung</string>
</resources>
+1 -1
View File
@@ -1001,7 +1001,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_set_empty_feed_msg">Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen.</string>
<string name="follow_set_add_author_from_note_action">Autor zum Folge-Set hinzufügen</string>
<string name="follow_set_profile_actions_menu_description">Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen.</string>
<string name="follow_set_icon_description">Symbol für %1$s-Liste</string>
<string name="follow_set_icon_description">Symbol für Liste</string>
<string name="follow_set_absence_indicator">%1$s ist nicht in dieser Liste</string>
<string name="follow_set_man_dialog_title">Deine Folge-Sets</string>
<string name="follow_set_empty_dialog_msg">Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen.</string>
@@ -290,6 +290,7 @@
<string name="nip_05">Nostr-cím</string>
<string name="never">soha</string>
<string name="now">most</string>
<string name="seconds">másodperc</string>
<string name="h">ó</string>
<string name="m">p</string>
<string name="d">n</string>
@@ -447,6 +448,8 @@
<string name="poll_zap_value_max">Maximum Zap</string>
<string name="poll_consensus_threshold">Együttműködés</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Egyetlen lehetőség</string>
<string name="poll_multiple_choice">Több lehetőség</string>
<string name="poll_closing_date_time">Szavazás lezárásának dátuma és ideje</string>
<string name="poll_closing_in">A szavazás lezárul %1$s múlva</string>
<string name="poll_closing_time">Szavazás lezárása</string>
@@ -1180,7 +1183,7 @@
<string name="outbox_relays_title">Átjátszók a kimenő üzenetkhez</string>
<string name="outbox_relays_not_found">Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez</string>
<string name="outbox_relays_not_found_description">A tartalom fogadására kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez, és ez az egyetlen módja annak, hogy a követői megtalálják Önt. </string>
<string name="outbox_relays_not_found_editing">Adjon meg 1-3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért</string>
<string name="outbox_relays_not_found_editing">Adjon meg 13 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért</string>
<string name="outbox_relays_not_found_examples">Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Átjátszók a bejövő üzenetkhez</string>
<string name="inbox_relays_not_found">Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához</string>
@@ -115,7 +115,7 @@
<string name="yes"></string>
<string name="no"></string>
<string name="follow_list_selection">Sekot saraksts</string>
<string name="follow_set_icon_description">Ikona %1$s sarakstam</string>
<string name="follow_set_icon_description">Ikona sarakstam</string>
<string name="follow_set_creation_menu_title">Izveidot jaunu sarakstu</string>
<string name="follow_set_creation_dialog_title">Jaunais %1$s saraksts</string>
<string name="follow_set_creation_name_label">Kolekcijas nosaukums</string>
+35 -16
View File
@@ -71,7 +71,7 @@
<string name="quote">Zacytuj</string>
<string name="fork">Sklonuj</string>
<string name="propose_an_edit">Zaproponuj zmianę</string>
<string name="new_amount_in_sats">Nowa kwota w Satsach</string>
<string name="new_amount_in_sats">Nowa kwota w satoszach</string>
<string name="add">Dodaj</string>
<string name="replying_to">"odpowiadając do "</string>
<string name="and">" i "</string>
@@ -92,7 +92,7 @@
<string name="lightning_tips">Lightning transfer</string>
<string name="note_to_receiver">Wiadomość dla odbiorcy</string>
<string name="thank_you_so_much">Dziękuję bardzo!</string>
<string name="amount_in_sats">Kwota w Satsach</string>
<string name="amount_in_sats">Kwota w satoszach</string>
<string name="send_sats">Wyślij</string>
<string name="secret_emoji_maker">Kreator tajnych emoji</string>
<string name="secret_emoji_maker_explainer">Dodaj emoji z ukrytą wiadomością do wpisu</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">(0100)%</string>
<string name="poll_single_choice">Pojedynczy wybór</string>
<string name="poll_multiple_choice">Wielokrotny wybór</string>
<string name="poll_closing_date_time">Data &amp; godzina zakończenia ankiety</string>
<string name="poll_closing_in">Ankieta zostanie zamknięta za %1$s</string>
<string name="poll_closing_time">Zamknij po</string>
@@ -537,7 +540,7 @@
<string name="follow_set_create_btn_label">Nowa</string>
<string name="follow_set_add_author_from_note_action">Dodaj autora do listy obserwowanych</string>
<string name="follow_set_profile_actions_menu_description">Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem.</string>
<string name="follow_set_icon_description">Ikona dla listy %1$s</string>
<string name="follow_set_icon_description">Ikona dla listy</string>
<string name="follow_set_public_presence_indicator">%1$s jest uczestnikiem publicznym</string>
<string name="follow_set_private_presence_indicator">%1$s jest uczestnikiem prywatnym</string>
<string name="follow_set_public_member_add_label">Dodaj jako uczestnika publicznego</string>
@@ -633,7 +636,7 @@
<string name="app_notification_dms_channel_description">Powiadamia Cię, gdy nadejdzie prywatna wiadomość</string>
<string name="app_notification_zaps_channel_name">Otrzymano Zapy</string>
<string name="app_notification_zaps_channel_description">Powiadamia Cię, gdy ktoś prześle ci zapy</string>
<string name="app_notification_zaps_channel_message">%1$s Satsów</string>
<string name="app_notification_zaps_channel_message">%1$s Satoszy</string>
<string name="app_notification_zaps_channel_message_from">Od %1$s</string>
<string name="app_notification_zaps_channel_message_for">dla %1$s</string>
<string name="app_notification_reply_label">Odpowiedz</string>
@@ -668,9 +671,9 @@
<string name="new_reaction_symbol">Nowy Symbol Odzewu</string>
<string name="no_reaction_type_setup_long_press_to_change">Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">Dodaje docelową liczbę satsów do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn</string>
<string name="zapraiser_target_amount_in_sats">Docelowa kwota w Satach</string>
<string name="sats_to_complete">Zapraiser przy: %1$s. %2$s satach do celu</string>
<string name="zapraiser_explainer">Dodaje docelową liczbę satoszy do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn</string>
<string name="zapraiser_target_amount_in_sats">Docelowa kwota w Satoszach</string>
<string name="sats_to_complete">Zapraiser przy: %1$s. %2$s satoszach do celu</string>
<string name="read_from_relay">Odczytaj z Transmitera</string>
<string name="write_to_relay">Zapisz do Transmitera</string>
<string name="write_to_relay_description">Ilość w bajtach, która została wysłana do tego transmitera, w tym filtry i wydarzenia</string>
@@ -878,7 +881,7 @@
<string name="copy_to_clipboard">Kopiuj do schowka</string>
<string name="copy_nprofile_to_clipboard">Skopiuj nprofile do schowka</string>
<string name="copy_npub_to_clipboard">Kopiuj npub do schowka</string>
<string name="share_or_save">Udostępnij lub Zapisz</string>
<string name="share_or_save">Udostępnij lub zapisz</string>
<string name="copy_url_to_clipboard">Kopiuj adres URL do schowka</string>
<string name="copy_the_note_id_to_the_clipboard">Kopiuj ID wpisu do schowka</string>
<string name="add_media_to_gallery">Dodaj pliki do Galerii</string>
@@ -916,7 +919,7 @@
<string name="zap_split_search_and_add_user">Szukaj i dodaj użytkownika</string>
<string name="zap_split_search_and_add_user_placeholder">Nick lub Login</string>
<string name="missing_lud16">Brakująca konfiguracja LN</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satsy</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satosze</string>
<string name="zap_split_weight">Procentowo</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Podziel zapsy z</string>
@@ -947,7 +950,7 @@
<string name="cashu_failed_redemption_explainer_error_msg">Mint dostarczył następujący komunikat błędu: %1$s</string>
<string name="cashu_failed_redemption_explainer_already_spent">Tokeny Cashu zostały już wydane.</string>
<string name="cashu_successful_redemption">Cashu odebrano</string>
<string name="cashu_successful_redemption_explainer">%1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satsów)</string>
<string name="cashu_successful_redemption_explainer">%1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satoszy)</string>
<string name="cashu_no_wallet_found">W systemie nie znaleziono kompatybilnego portfela Cashu</string>
<string name="error_unable_to_fetch_invoice">Nie można pobrać faktury z serwerów odbiorcy</string>
<string name="wallet_connect_pay_invoice_error_error">Twój dostawca połączenia z portfelem zwrócił następujący błąd: %1$s</string>
@@ -965,7 +968,7 @@
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">Nie znaleziono zwrotnego adresu URL z odpowiedzi %1$s</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">Wystąpił błąd podczas analizowania JSON z pobierania faktury z Lightning Adresu. Sprawdź konfigurację lightning użytkownika</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika</string>
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">Nieprawidłowa kwota faktury (%1$s satsów) od %2$s. Powinieno być %3$s</string>
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">Nieprawidłowa kwota faktury (%1$s satoszy) od %2$s. Powinieno być %3$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">Nie można utworzyć faktury przed wysłaniem zapa. Portfel odbiorcy wysłał następujący błąd: %1$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Nie można utworzyć faktury. Wiadomość od %1$s: %2$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">Nie można utworzyć faktury przed wysłaniem zapa. Element pr nie został znaleziony w powstałym JSON.</string>
@@ -992,7 +995,7 @@
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Stan</string>
<string name="classifieds_category">Kategoria</string>
<string name="classifieds_price">Cena (w Satach)</string>
<string name="classifieds_price">Cena (w Satoszach)</string>
<string name="classifieds_price_placeholder">1000</string>
<string name="classifieds_location">Lokalizacja</string>
<string name="classifieds_location_placeholder">Miasto, Województwo, Kraj</string>
@@ -1101,6 +1104,13 @@
<string name="new_community_note">Nowy wpis w społeczności</string>
<string name="new_product">Nowy produkt</string>
<string name="new_exclusive_geo_note">Nowy GEO-ekskluzywny Wpis</string>
<string name="new_long_form_post">Nowy artykuł</string>
<string name="article_title">Tytuł</string>
<string name="article_summary">Podsumowanie (opcjonalnie)</string>
<string name="article_cover_image_url">Adres URL miniaturki (opcjonalnie)</string>
<string name="write_your_article_in_markdown">Napisz artykuł w formacie markdown…</string>
<string name="markdown_preview">Podgląd</string>
<string name="markdown_edit">Edytuj</string>
<string name="open_all_reactions_to_this_post">Otwórz wszystkie odzewy na ten post</string>
<string name="close_all_reactions_to_this_post">Zamknij wszystkie odzewy na ten post</string>
<string name="reply_description">Odpowiedź</string>
@@ -1238,6 +1248,7 @@
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Potwierdzenie znacznika czasu</string>
<string name="ots_info_description">Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie.</string>
<string name="edit_article">Redaguj artykuł</string>
<string name="edit_post">Edytuj wpis</string>
<string name="proposal_to_edit">Propozycja ulepszenia wpisu</string>
<string name="message_to_author">Podsumowanie zmian</string>
@@ -1397,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>
@@ -1560,6 +1571,7 @@
<string name="name_search_npub1_alice_example_com">wyszukaj, npub1…, alicja@domena.pl</string>
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Obsługuje npub, nprofile, NIP-05, hex, i namecoin (.bit, d/, id/)</string>
<string name="look_up_follow_list">Sprawdź listę obserwowanych</string>
<string name="tip">Porada</string>
<string name="accounts_found">Znaleziono %1$d kont(a)</string>
<string name="num_selected">Wybrano: %1$d</string>
<string name="resolved_via_namecoin">Rozwiązane przez Namecoin</string>
@@ -1616,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>
@@ -1659,4 +1671,11 @@
<string name="attestor_proficiency_for_kinds">Biegłość w sprawdzaniu typów: %1$s</string>
<string name="attestation_attests_to">Certyfikat dla</string>
<string name="attestation_requests_attestation_to">Żądanie certyfikatu do</string>
<string name="event_sync_date_filter_title">Przedział czasu</string>
<string name="event_sync_date_filter_since">Od</string>
<string name="event_sync_date_filter_until">Do</string>
<string name="event_sync_date_filter_now">Teraz</string>
<string name="event_sync_date_filter_all_time">Cały czas</string>
<string name="event_sync_date_filter_last_sync">Ostatnia synchronizacja %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Od ostatniej synchronizacji</string>
</resources>
@@ -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>
@@ -1099,6 +1100,13 @@
<string name="new_community_note">Nova Nota da Comunidade</string>
<string name="new_product">Produto Novo</string>
<string name="new_exclusive_geo_note">Nova Postagem Geo-Exclusiva</string>
<string name="new_long_form_post">Novo artigo</string>
<string name="article_title">Título</string>
<string name="article_summary">Resumo (opcional)</string>
<string name="article_cover_image_url">URL da imagem de capa (opcional)</string>
<string name="write_your_article_in_markdown">Escreva seu artigo em markdown…</string>
<string name="markdown_preview">Pré-visualização</string>
<string name="markdown_edit">Editar</string>
<string name="open_all_reactions_to_this_post">Abrir todas as reações a esta postagem</string>
<string name="close_all_reactions_to_this_post">Fechar todas as reações a esta postagem</string>
<string name="reply_description">Responder</string>
@@ -1236,6 +1244,7 @@
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Prova de Carimbo de data/hora</string>
<string name="ots_info_description">Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora.</string>
<string name="edit_article">Editar artigo</string>
<string name="edit_post">Editar postagem</string>
<string name="proposal_to_edit">Proposta para melhorar sua postagem</string>
<string name="message_to_author">Resumo das alterações</string>
@@ -1659,4 +1668,11 @@
<string name="attestor_proficiency_for_kinds">Competente na verificação de tipos: %1$s</string>
<string name="attestation_attests_to">Atesta</string>
<string name="attestation_requests_attestation_to">Solicita atestação para</string>
<string name="event_sync_date_filter_title">Intervalo de datas</string>
<string name="event_sync_date_filter_since">De</string>
<string name="event_sync_date_filter_until">Até</string>
<string name="event_sync_date_filter_now">Agora</string>
<string name="event_sync_date_filter_all_time">Todo o período</string>
<string name="event_sync_date_filter_last_sync">Última sincronização: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Desde a última sincronização</string>
</resources>
@@ -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>
@@ -1098,6 +1099,13 @@
<string name="new_community_note">Nytt Community-meddelande</string>
<string name="new_product">Ny produkt</string>
<string name="new_exclusive_geo_note">Nytt Geo-Exklusivt inlägg</string>
<string name="new_long_form_post">Ny artikel</string>
<string name="article_title">Titel</string>
<string name="article_summary">Sammanfattning (valfritt)</string>
<string name="article_cover_image_url">URL för omslagsbild (valfritt)</string>
<string name="write_your_article_in_markdown">Skriv din artikel i markdown…</string>
<string name="markdown_preview">Förhandsgranskning</string>
<string name="markdown_edit">Redigera</string>
<string name="open_all_reactions_to_this_post">Öppna alla reaktioner på detta inlägg</string>
<string name="close_all_reactions_to_this_post">Stäng alla reaktioner på detta inlägg</string>
<string name="reply_description">Svara</string>
@@ -1235,6 +1243,7 @@
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Tidsstämpel Bevis</string>
<string name="ots_info_description">Det finns bevis på att detta inlägg signerades någon gång före %1$s. Beviset stämplades i Bitcoin-blockchainen vid det datumet och den tiden.</string>
<string name="edit_article">Redigera artikel</string>
<string name="edit_post">Redigera inlägg</string>
<string name="proposal_to_edit">Förslag till att förbättra ditt inlägg</string>
<string name="message_to_author">Sammanfattning av ändringar</string>
@@ -1658,4 +1667,11 @@
<string name="attestor_proficiency_for_kinds">Kompetent att verifiera typer: %1$s</string>
<string name="attestation_attests_to">Intygar</string>
<string name="attestation_requests_attestation_to">Begär attestering till</string>
<string name="event_sync_date_filter_title">Datumintervall</string>
<string name="event_sync_date_filter_since">Från</string>
<string name="event_sync_date_filter_until">Till</string>
<string name="event_sync_date_filter_now">Nu</string>
<string name="event_sync_date_filter_all_time">All tid</string>
<string name="event_sync_date_filter_last_sync">Senaste synkronisering: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Sedan senaste synkronisering</string>
</resources>
@@ -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>
@@ -447,6 +448,8 @@
<string name="poll_zap_value_max">打闪最高金额</string>
<string name="poll_consensus_threshold">共识</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">单选</string>
<string name="poll_multiple_choice">多选</string>
<string name="poll_closing_date_time">投票结束日期 &amp; 时间</string>
<string name="poll_closing_in">投票结束于 %1$s</string>
<string name="poll_closing_time">后关闭</string>
@@ -486,6 +489,9 @@
<string name="zap_type_anonymous_explainer">接收方和公众不知道谁发送了付款</string>
<string name="zap_type_nonzap">非打闪</string>
<string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</string>
<string name="post_anonymously">匿名</string>
<string name="post_anonymously_explainer">使用新的一次性身份发布。您的帐户将不会被链接到这个回复。</string>
<string name="anonymous_reply_warning">此回复将从新的匿名身份发布</string>
<string name="file_server">文件服务器</string>
<string name="file_server_description">选择上传文件时使用的服务器</string>
<string name="zap_forward_lnAddress">闪电地址或 @User</string>
@@ -785,7 +791,7 @@
<string name="preferences">偏好设置</string>
<string name="user_preferences">用户首选项</string>
<string name="translations">翻译</string>
<string name="reactions"></string>
<string name="reactions"></string>
<string name="settings">设置</string>
<string name="account_settings">账户设置</string>
<string name="app_settings">应用程序设置</string>
@@ -1104,6 +1110,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>
@@ -1111,8 +1124,8 @@
<string name="like_description">点赞</string>
<string name="zap_description">打闪</string>
<string name="change_reaction">修改快速回应</string>
<string name="reactions_settings">应设置</string>
<string name="reactions_settings_description">配置显示的应按钮、它们的顺序及是否显示计数</string>
<string name="reactions_settings">应设置</string>
<string name="reactions_settings_description">配置显示的应按钮、按钮顺序及是否显示回应计数。</string>
<string name="reactions_settings_enabled">已启用</string>
<string name="reactions_settings_show_counter">显示计数</string>
<string name="reactions_settings_reorder">调整顺序</string>
@@ -1121,7 +1134,7 @@
<string name="reactions_settings_boost">Boost</string>
<string name="reactions_settings_boost_description">转发或引用此笔记</string>
<string name="reactions_settings_like">点赞</string>
<string name="reactions_settings_like_description">用表情符号对此笔记进行反应</string>
<string name="reactions_settings_like_description">使用表情符号回应笔记</string>
<string name="reactions_settings_zap">打闪</string>
<string name="reactions_settings_zap_description">给作者发送 Lightning 网络付款</string>
<string name="reactions_settings_share">分享</string>
@@ -1173,7 +1186,7 @@
<string name="outbox_relays_title">发件箱中继</string>
<string name="outbox_relays_not_found">设置您的公共发件箱中继来发布内容</string>
<string name="outbox_relays_not_found_description">创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 </string>
<string name="outbox_relays_not_found_editing">插入 1-3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入</string>
<string name="outbox_relays_not_found_editing">插入 13 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入</string>
<string name="outbox_relays_not_found_examples">好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">收件箱中继</string>
<string name="inbox_relays_not_found">设置您的公共收件箱中继来接收通知</string>
@@ -1241,6 +1254,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>
@@ -1532,7 +1546,7 @@
<string name="kind_private_relays">私密中继</string>
<string name="kind_proxy_relays">代理中继</string>
<string name="kind_public_message">公开消息</string>
<string name="kind_reactions"></string>
<string name="kind_reactions"></string>
<string name="kind_contact_card">名片</string>
<string name="kind_relay_auth">中继认证</string>
<string name="kind_relay_discovery">中继发现</string>
@@ -1664,4 +1678,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>
+9 -1
View File
@@ -68,7 +68,8 @@
<string name="signer_not_found_exception">Signer not found</string>
<string name="signer_not_found_exception_description">Was the Signer app uninstalled? Check if the signer is installed and has this account. Log off and Log in again of the signer app has changed.</string>
<string name="signer_illegal_state_exception">Signer misbehaved</string>
<string name="signer_illegal_state_exception_description">External signer returned a payload that is strange for the request. There might be a bug on either Amethyst or the Signer.</string>
<string name="zaps">Zaps</string>
<string name="view_count">View count</string>
@@ -309,6 +310,7 @@
<string name="lnurl" translatable="false">LNURL…</string>
<string name="never">never</string>
<string name="now">now</string>
<string name="seconds">seconds</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
@@ -482,6 +484,8 @@
<string name="poll_zap_value_max">Zap maximum</string>
<string name="poll_consensus_threshold">Consensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Single choice</string>
<string name="poll_multiple_choice">Multiple choice</string>
<string name="poll_closing_date_time">Poll Closing Date &amp; Time</string>
<string name="poll_closing_in">Poll closes in %1$s</string>
<string name="poll_closing_time">Close after</string>
@@ -538,6 +542,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>
@@ -27,9 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.mac.FixedKey
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
@RunWith(AndroidJUnit4::class)
class ChaCha20Benchmark {
@@ -64,4 +67,38 @@ class ChaCha20Benchmark {
chaCha.decrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
}
}
@Test
fun encryptNative() {
benchmarkRule.measureRepeated {
encryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
}
}
@Test
fun decryptNative() {
benchmarkRule.measureRepeated {
decryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
}
}
fun encryptNative(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
val cipher = Cipher.getInstance("ChaCha20")
cipher.init(Cipher.ENCRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce))
return cipher.doFinal(message)
}
fun decryptNative(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
val cipher = Cipher.getInstance("ChaCha20")
cipher.init(Cipher.DECRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce))
return cipher.doFinal(message)
}
}
-1
View File
@@ -10,7 +10,6 @@ plugins {
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
alias(libs.plugins.serialization)
alias(libs.plugins.stability.analyzer) apply false
}
allprojects {
+5
View File
@@ -72,6 +72,11 @@ kotlin {
// Compose Multiplatform Resources
implementation(libs.jetbrains.compose.components.resources)
// Markdown rendering (richtext-commonmark)
implementation(libs.markdown.commonmark)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
}
}
@@ -0,0 +1,120 @@
/*
* 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.commons.compose.article
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
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
@Composable
fun ArticleHeader(
title: String,
authorName: String?,
authorPicture: String?,
publishedAt: String?,
readingTimeMinutes: Int?,
bannerUrl: String?,
modifier: Modifier = Modifier,
onAuthorClick: (() -> Unit)? = null,
) {
Column(modifier = modifier.fillMaxWidth()) {
// Banner image
if (!bannerUrl.isNullOrBlank() &&
(bannerUrl.startsWith("https://") || bannerUrl.startsWith("http://"))
) {
AsyncImage(
model = bannerUrl,
contentDescription = "Article banner",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(300.dp),
)
Spacer(Modifier.height(24.dp))
}
// Title
Text(
text = title,
style =
MaterialTheme.typography.headlineLarge.copy(
fontSize = 34.sp,
fontWeight = FontWeight.Bold,
lineHeight = 40.sp,
letterSpacing = (-0.5).sp,
),
)
Spacer(Modifier.height(16.dp))
// Author + metadata row
Row(verticalAlignment = Alignment.CenterVertically) {
if (!authorPicture.isNullOrBlank() &&
(authorPicture.startsWith("https://") || authorPicture.startsWith("http://"))
) {
AsyncImage(
model = authorPicture,
contentDescription = "Author",
modifier = Modifier.size(40.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)
Spacer(Modifier.width(12.dp))
}
Column {
if (!authorName.isNullOrBlank()) {
Text(
text = authorName,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
val metaParts = mutableListOf<String>()
readingTimeMinutes?.let { metaParts.add("$it min read") }
publishedAt?.let { metaParts.add(it) }
if (metaParts.isNotEmpty()) {
Text(
text = metaParts.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.height(24.dp))
}
}
@@ -0,0 +1,143 @@
/*
* 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.commons.compose.article
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val HEADING_REGEX = Regex("^(#{1,6})\\s+(.+)")
private val TRAILING_HASHES_REGEX = Regex("#+$")
data class TocEntry(
val level: Int,
val text: String,
val index: Int,
)
/**
* Extracts table of contents entries from markdown content.
* Parses ATX headings (# H1, ## H2, etc.), skipping code blocks.
*/
fun extractTableOfContents(markdown: String): List<TocEntry> {
val entries = mutableListOf<TocEntry>()
var inCodeBlock = false
var headingIndex = 0
markdown.lines().forEach { line ->
val trimmed = line.trim()
if (trimmed.startsWith("```")) {
inCodeBlock = !inCodeBlock
return@forEach
}
if (inCodeBlock) return@forEach
val match = HEADING_REGEX.find(trimmed)
if (match != null) {
val level = match.groupValues[1].length
val text =
match.groupValues[2]
.trim()
.replace(TRAILING_HASHES_REGEX, "")
.trim()
if (text.isNotEmpty() && level <= 3) {
entries.add(TocEntry(level = level, text = text, index = headingIndex))
}
headingIndex++
}
}
return entries
}
@Composable
fun TableOfContents(
entries: List<TocEntry>,
activeEntryIndex: Int?,
onEntryClick: (TocEntry) -> Unit,
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
Column(
modifier =
modifier
.width(240.dp)
.verticalScroll(scrollState)
.padding(vertical = 16.dp),
) {
entries.forEach { entry ->
val isActive = entry.index == activeEntryIndex
val accentColor = MaterialTheme.colorScheme.primary
Text(
text = entry.text,
style =
MaterialTheme.typography.bodySmall.copy(
fontSize = 13.sp,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
color =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clickable { onEntryClick(entry) }
.padding(
start = ((entry.level - 1) * 16).dp,
top = 4.dp,
bottom = 4.dp,
end = 8.dp,
).then(
if (isActive) {
Modifier.drawBehind {
drawLine(
color = accentColor,
start = Offset(0f, 0f),
end = Offset(0f, size.height),
strokeWidth = 3.dp.toPx(),
)
}
} else {
Modifier
},
),
)
}
}
}
@@ -0,0 +1,363 @@
/*
* 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.commons.compose.editor
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
/**
* State holder for a markdown editor with selection-aware formatting.
*
* Solves the focus/selection bug: toolbar buttons steal focus from TextField,
* collapsing the selection. We cache the last known selection on every value
* change, and toolbar operations use the cached selection.
*/
class MarkdownEditorState(
initial: String = "",
) {
var value by mutableStateOf(TextFieldValue(initial))
private set
/** Cached selection — updated on every onValueChange, survives focus loss. */
var lastSelection: TextRange = TextRange.Zero
private set
fun onValueChange(newValue: TextFieldValue) {
value = newValue
// Only cache non-zero selections (focus loss sends collapsed range)
if (newValue.selection.length > 0 || lastSelection == TextRange.Zero) {
lastSelection = newValue.selection
}
// Also cache cursor position when no selection
if (newValue.selection.collapsed) {
lastSelection = newValue.selection
}
}
fun loadContent(content: String) {
value = TextFieldValue(content)
lastSelection = TextRange.Zero
}
val text: String get() = value.text
// --- Active state detection (uses current value.selection for display) ---
val isBold: Boolean
get() = isWrapped("**", "**")
val isItalic: Boolean
get() = isWrappedItalic()
val isStrikethrough: Boolean
get() = isWrapped("~~", "~~")
val isInlineCode: Boolean
get() = isWrapped("`", "`")
val isBlockquote: Boolean
get() = isLinePrefix("> ")
val isUnorderedList: Boolean
get() = isLinePrefix("- ")
val isOrderedList: Boolean
get() {
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
val line = text.substring(lineStart)
return line.matches(Regex("^\\d+\\.\\s.*"))
}
val isTaskList: Boolean
get() = isLinePrefix("- [ ] ") || isLinePrefix("- [x] ")
val headingLevel: Int?
get() {
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
val line = text.substring(lineStart)
return when {
line.startsWith("### ") -> 3
line.startsWith("## ") -> 2
line.startsWith("# ") -> 1
else -> null
}
}
// --- Formatting operations (use lastSelection to survive focus loss) ---
fun toggleBold() {
applyToggleWrap("**", "**")
}
fun toggleItalic() {
applyToggleWrapItalic()
}
fun toggleStrikethrough() {
applyToggleWrap("~~", "~~")
}
fun toggleInlineCode() {
applyToggleWrap("`", "`")
}
fun setHeading(level: Int?) {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
val line = text.substring(lineStart)
// Remove existing heading prefix
val stripped =
when {
line.startsWith("### ") -> line.removePrefix("### ")
line.startsWith("## ") -> line.removePrefix("## ")
line.startsWith("# ") -> line.removePrefix("# ")
else -> line
}
val oldPrefixLen =
when {
line.startsWith("### ") -> 4
line.startsWith("## ") -> 3
line.startsWith("# ") -> 2
else -> 0
}
val newPrefix =
when (level) {
1 -> "# "
2 -> "## "
3 -> "### "
else -> ""
}
val lineEnd = text.indexOf('\n', lineStart).let { if (it == -1) text.length else it }
val newText = text.substring(0, lineStart) + newPrefix + stripped + text.substring(lineEnd)
val shift = newPrefix.length - oldPrefixLen
value =
TextFieldValue(
text = newText,
selection = TextRange((sel.min + shift).coerceAtLeast(lineStart), (sel.max + shift).coerceAtLeast(lineStart)),
)
lastSelection = value.selection
}
fun toggleBlockquote() {
applyToggleLinePrefix("> ")
}
fun toggleUnorderedList() {
applyToggleLinePrefix("- ")
}
fun toggleOrderedList() {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
val line = text.substring(lineStart)
if (line.matches(Regex("^\\d+\\.\\s.*"))) {
// Remove ordered list prefix
val prefixEnd = line.indexOf(". ") + 2
val newText = text.substring(0, lineStart) + line.substring(prefixEnd) + text.substring(lineStart + line.indexOf('\n').let { if (it == -1) line.length else it })
value =
TextFieldValue(
text = text.substring(0, lineStart) + line.substring(prefixEnd),
selection = TextRange((sel.min - prefixEnd).coerceAtLeast(lineStart)),
)
} else {
applyToggleLinePrefix("1. ")
}
lastSelection = value.selection
}
fun toggleTaskList() {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
when {
text.startsWith("- [ ] ", lineStart) -> {
// Remove task list prefix
val newText = text.substring(0, lineStart) + text.substring(lineStart + 6)
val shift = 6
value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart)))
lastSelection = value.selection
}
text.startsWith("- [x] ", lineStart) -> {
val newText = text.substring(0, lineStart) + text.substring(lineStart + 6)
val shift = 6
value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart)))
lastSelection = value.selection
}
else -> {
applyToggleLinePrefix("- [ ] ")
}
}
}
fun toggleCodeBlock() {
applyToggleWrap("```\n", "\n```")
}
fun insertHorizontalRule() {
val sel = lastSelection
val insert = "\n---\n"
val newText = text.substring(0, sel.min) + insert + text.substring(sel.max)
value = TextFieldValue(text = newText, selection = TextRange(sel.min + insert.length))
lastSelection = value.selection
}
fun insertLink() {
val sel = lastSelection
val selected = text.substring(sel.min, sel.max)
if (selected.isNotEmpty()) {
val newText = text.substring(0, sel.min) + "[$selected](url)" + text.substring(sel.max)
val urlStart = sel.min + selected.length + 3
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
} else {
val newText = text.substring(0, sel.min) + "[](url)" + text.substring(sel.min)
value = TextFieldValue(text = newText, selection = TextRange(sel.min + 1))
}
lastSelection = value.selection
}
fun insertImage() {
val sel = lastSelection
val selected = text.substring(sel.min, sel.max)
if (selected.isNotEmpty()) {
val newText = text.substring(0, sel.min) + "![$selected](url)" + text.substring(sel.max)
val urlStart = sel.min + selected.length + 4
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
} else {
val newText = text.substring(0, sel.min) + "![alt](url)" + text.substring(sel.min)
val urlStart = sel.min + 7
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
}
lastSelection = value.selection
}
// --- Private helpers ---
private fun isWrapped(
prefix: String,
suffix: String,
): Boolean {
val sel = value.selection
val start = sel.min
val end = sel.max
return start >= prefix.length &&
end + suffix.length <= text.length &&
text.substring(start - prefix.length, start) == prefix &&
text.substring(end, end + suffix.length) == suffix
}
private fun isWrappedItalic(): Boolean {
val sel = value.selection
val start = sel.min
val end = sel.max
if (start < 1 || end + 1 > text.length) return false
if (text[start - 1] != '*' || text[end] != '*') return false
val hasBoldBefore = start >= 2 && text[start - 2] == '*'
val hasBoldAfter = end + 1 < text.length && text[end + 1] == '*'
return !hasBoldBefore && !hasBoldAfter
}
private fun isLinePrefix(prefix: String): Boolean {
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
return text.startsWith(prefix, lineStart)
}
private fun applyToggleWrap(
prefix: String,
suffix: String,
) {
val sel = lastSelection
val start = sel.min
val end = sel.max
val wrapped =
start >= prefix.length &&
end + suffix.length <= text.length &&
text.substring(start - prefix.length, start) == prefix &&
text.substring(end, end + suffix.length) == suffix
value =
if (wrapped) {
val newText =
text.substring(0, start - prefix.length) +
text.substring(start, end) +
text.substring(end + suffix.length)
TextFieldValue(newText, TextRange(start - prefix.length, end - prefix.length))
} else if (start == end) {
val newText = text.substring(0, start) + prefix + suffix + text.substring(start)
TextFieldValue(newText, TextRange(start + prefix.length))
} else {
val newText = text.substring(0, start) + prefix + text.substring(start, end) + suffix + text.substring(end)
TextFieldValue(newText, TextRange(start + prefix.length, end + prefix.length))
}
lastSelection = value.selection
}
private fun applyToggleWrapItalic() {
val sel = lastSelection
val start = sel.min
val end = sel.max
val isItalic =
start >= 1 &&
end + 1 <= text.length &&
text[start - 1] == '*' &&
text[end] == '*' &&
!(start >= 2 && text[start - 2] == '*') &&
!(end + 1 < text.length && text[end + 1] == '*')
if (isItalic) {
val newText = text.substring(0, start - 1) + text.substring(start, end) + text.substring(end + 1)
value = TextFieldValue(newText, TextRange(start - 1, end - 1))
} else {
applyToggleWrap("*", "*")
return
}
lastSelection = value.selection
}
private fun applyToggleLinePrefix(prefix: String) {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
value =
if (text.startsWith(prefix, lineStart)) {
val newText = text.substring(0, lineStart) + text.substring(lineStart + prefix.length)
val shift = prefix.length
TextFieldValue(newText, TextRange((sel.min - shift).coerceAtLeast(lineStart), (sel.max - shift).coerceAtLeast(lineStart)))
} else {
val newText = text.substring(0, lineStart) + prefix + text.substring(lineStart)
TextFieldValue(newText, TextRange(sel.min + prefix.length, sel.max + prefix.length))
}
lastSelection = value.selection
}
}
@@ -0,0 +1,184 @@
/*
* 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.commons.compose.editor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.FormatBold
import androidx.compose.material.icons.filled.FormatItalic
import androidx.compose.material.icons.filled.FormatListBulleted
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material.icons.filled.FormatStrikethrough
import androidx.compose.material.icons.filled.HorizontalRule
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Markdown toolbar with Material icons, grouped formatting buttons, and active state.
* Uses [MarkdownEditorState] for selection-aware toggle behavior.
*
* Buttons use `focusProperties { canFocus = false }` to prevent stealing focus
* from the editor TextField, preserving the user's text selection.
*/
@Composable
fun MarkdownToolbar(
state: MarkdownEditorState,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// --- Headings ---
ToolbarButton(label = "H1", active = state.headingLevel == 1) { state.setHeading(if (state.headingLevel == 1) null else 1) }
ToolbarButton(label = "H2", active = state.headingLevel == 2) { state.setHeading(if (state.headingLevel == 2) null else 2) }
ToolbarButton(label = "H3", active = state.headingLevel == 3) { state.setHeading(if (state.headingLevel == 3) null else 3) }
Separator()
// --- Inline formatting ---
ToolbarIconButton(Icons.Default.FormatBold, "Bold", state.isBold) { state.toggleBold() }
ToolbarIconButton(Icons.Default.FormatItalic, "Italic", state.isItalic) { state.toggleItalic() }
ToolbarIconButton(Icons.Default.FormatStrikethrough, "Strikethrough", state.isStrikethrough) { state.toggleStrikethrough() }
ToolbarIconButton(Icons.Default.Code, "Inline code", state.isInlineCode) { state.toggleInlineCode() }
Separator()
// --- Lists ---
ToolbarIconButton(Icons.Default.FormatListBulleted, "Bullet list", state.isUnorderedList) { state.toggleUnorderedList() }
ToolbarIconButton(Icons.Default.FormatListNumbered, "Numbered list", state.isOrderedList) { state.toggleOrderedList() }
ToolbarIconButton(Icons.Default.Checklist, "Task list", state.isTaskList) { state.toggleTaskList() }
Separator()
// --- Block elements ---
ToolbarIconButton(Icons.Default.FormatQuote, "Blockquote", state.isBlockquote) { state.toggleBlockquote() }
ToolbarButton(label = "```", active = false) { state.toggleCodeBlock() }
ToolbarIconButton(Icons.Default.HorizontalRule, "Horizontal rule", false) { state.insertHorizontalRule() }
Separator()
// --- Insert ---
ToolbarIconButton(Icons.Default.Link, "Link", false) { state.insertLink() }
ToolbarIconButton(Icons.Default.Image, "Image", false) { state.insertImage() }
}
}
@Composable
private fun Separator() {
VerticalDivider(
modifier = Modifier.height(24.dp).padding(horizontal = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
@Composable
private fun ToolbarIconButton(
icon: ImageVector,
contentDescription: String,
active: Boolean,
onClick: () -> Unit,
) {
val containerColor =
if (active) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val contentColor =
if (active) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
SmallFloatingActionButton(
onClick = onClick,
containerColor = containerColor,
contentColor = contentColor,
modifier =
Modifier
.size(32.dp)
.focusProperties { canFocus = false },
) {
Icon(
icon,
contentDescription = contentDescription,
modifier = Modifier.size(18.dp),
)
}
}
@Composable
private fun ToolbarButton(
label: String,
active: Boolean,
onClick: () -> Unit,
) {
val containerColor =
if (active) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val contentColor =
if (active) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
SmallFloatingActionButton(
onClick = onClick,
containerColor = containerColor,
contentColor = contentColor,
modifier =
Modifier
.size(32.dp)
.focusProperties { canFocus = false },
) {
Text(
text = label,
fontSize = 11.sp,
color = contentColor,
)
}
}
@@ -0,0 +1,160 @@
/*
* 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.commons.compose.editor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.unit.dp
/**
* Form fields for article metadata: title, summary, banner, tags, slug.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MetadataPanel(
title: String,
onTitleChange: (String) -> Unit,
summary: String,
onSummaryChange: (String) -> Unit,
bannerUrl: String,
onBannerUrlChange: (String) -> Unit,
tags: List<String>,
onTagsChange: (List<String>) -> Unit,
slug: String,
onSlugChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var tagInput by remember { mutableStateOf("") }
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = title,
onValueChange = { if (it.length <= 256) onTitleChange(it) },
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("${title.length}/256") },
)
OutlinedTextField(
value = summary,
onValueChange = { if (it.length <= 1024) onSummaryChange(it) },
label = { Text("Summary") },
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("${summary.length}/1024") },
)
OutlinedTextField(
value = bannerUrl,
onValueChange = onBannerUrlChange,
label = { Text("Banner Image URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Tags chip input
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = tagInput,
onValueChange = { tagInput = it },
label = { Text("Add tag (Enter to add)") },
singleLine = true,
modifier =
Modifier.weight(1f).onKeyEvent { event ->
if (event.key == Key.Enter && tagInput.isNotBlank()) {
val newTag = tagInput.trim().lowercase()
if (newTag !in tags) {
onTagsChange(tags + newTag)
}
tagInput = ""
true
} else {
false
}
},
)
}
if (tags.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
tags.forEach { tag ->
AssistChip(
onClick = { onTagsChange(tags - tag) },
label = { Text(tag) },
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = "Remove $tag",
modifier = Modifier.size(16.dp),
)
},
)
}
}
}
}
OutlinedTextField(
value = slug,
onValueChange = onSlugChange,
label = { Text("Slug (d-tag)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Used as the unique identifier for this article") },
)
}
}
@@ -0,0 +1,116 @@
/*
* 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.commons.compose.markdown
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.unit.Density
import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText
private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning", "highlight")
/**
* Escapes markdown special characters inside highlighted text so it doesn't
* break the markdown parser when wrapped in a link.
*/
private fun escapeMarkdownInLink(text: String): String =
text
.replace("[", "\\[")
.replace("]", "\\]")
.replace("(", "\\(")
.replace(")", "\\)")
@Composable
fun RenderMarkdown(
content: String,
onLinkClick: (String) -> Unit,
modifier: Modifier = Modifier,
fontScale: Float = 1.0f,
highlightedTexts: List<String> = emptyList(),
) {
val processedContent =
remember(content, highlightedTexts) {
if (highlightedTexts.isEmpty()) {
content
} else {
var result = content
highlightedTexts.sortedByDescending { it.length }.forEachIndexed { index, text ->
val idx = result.indexOf(text)
if (idx >= 0) {
val escaped = escapeMarkdownInLink(text)
result = result.replaceFirst(text, "[$escaped](highlight://$index)")
}
}
result
}
}
val astNode =
remember(processedContent) {
CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(processedContent)
}
val uriHandler =
remember(onLinkClick) {
object : UriHandler {
override fun openUri(uri: String) {
val scheme = uri.substringBefore(":").lowercase()
if (scheme in ALLOWED_SCHEMES) {
onLinkClick(uri)
}
}
}
}
val currentDensity = LocalDensity.current
val scaledDensity =
remember(fontScale, currentDensity) {
if (fontScale == 1.0f) {
currentDensity
} else {
Density(
density = currentDensity.density * fontScale,
fontScale = currentDensity.fontScale,
)
}
}
CompositionLocalProvider(
LocalUriHandler provides uriHandler,
LocalDensity provides scaledDensity,
) {
RichText(
modifier = modifier,
style = RichTextStyle(),
) {
BasicMarkdown(astNode)
}
}
}
@@ -90,8 +90,7 @@ class AddressableNote(
override fun address() = address
override fun createdAt(): Long? {
val currentEvent = event
if (currentEvent == null) return null
val currentEvent = event ?: return null
if (currentEvent is PublishedAtProvider) return currentEvent.publishedAt() ?: currentEvent.createdAt
return currentEvent.createdAt
}
@@ -204,7 +203,7 @@ open class Note(
is IsInPublicChatChannel -> {
inGatherers?.forEach {
if (it is com.vitorpamplona.amethyst.commons.model.Channel) {
if (it is Channel) {
it.relays().firstOrNull()?.let { return it }
}
}
@@ -216,7 +215,7 @@ open class Note(
is LiveActivitiesChatMessageEvent -> {
inGatherers?.forEach {
if (it is com.vitorpamplona.amethyst.commons.model.Channel) {
if (it is Channel) {
it.relays().firstOrNull()?.let { return it }
}
}
@@ -230,14 +229,14 @@ open class Note(
val currentOutbox = author?.outboxRelays()?.toSet()
return if (relays.isNotEmpty()) {
if (currentOutbox != null && currentOutbox.isNotEmpty()) {
if (!currentOutbox.isNullOrEmpty()) {
val relayMatchesOutbox = relays.firstOrNull { it in currentOutbox }
if (relayMatchesOutbox != null) {
return relayMatchesOutbox
}
}
return relays.firstOrNull()
relays.firstOrNull()
} else {
currentOutbox?.firstOrNull() ?: author?.mostUsedNonLocalRelay()
}
@@ -313,14 +312,14 @@ open class Note(
zapPayments.keys +
zapPayments.values.filterNotNull()
replies = listOf<Note>()
reactions = mapOf<String, List<Note>>()
boosts = listOf<Note>()
reports = mapOf<User, List<Note>>()
zaps = mapOf<Note, Note?>()
zapPayments = mapOf<Note, Note?>()
replies = listOf()
reactions = mapOf()
boosts = listOf()
reports = mapOf()
zaps = mapOf()
zapPayments = mapOf()
zapsAmount = BigDecimal.ZERO
relays = listOf<NormalizedRelayUrl>()
relays = listOf()
if (repliesChanged) flowSet?.replies?.invalidateData()
if (reactionsChanged) flowSet?.reactions?.invalidateData()
@@ -990,11 +989,11 @@ class NoteState(
fun List<AddressableNote>.eventIdSet() = mapNotNullTo(mutableSetOf<HexKey>()) { it.event?.id }
fun <T : Event> Array<NoteState>.events() = mapNotNull { it.note.event as? T }
inline fun <reified T : Event> Array<NoteState>.events() = mapNotNull { it.note.event as? T }
fun <T : Event> List<AddressableNote>.events() = mapNotNull { it.event as? T }
inline fun <reified T : Event> List<AddressableNote>.events() = mapNotNull { it.event as? T }
fun <T : Event> List<AddressableNote>.updateFlow(): Flow<List<T>> =
inline fun <reified T : Event> List<AddressableNote>.updateFlow(): Flow<List<T>> =
if (this.isEmpty()) {
MutableStateFlow(emptyList())
} else {
@@ -1005,7 +1004,7 @@ fun <T : Event> List<AddressableNote>.updateFlow(): Flow<List<T>> =
}
}
public inline fun <T> Iterable<Note>.anyEvent(predicate: (T) -> Boolean): Boolean {
inline fun <reified T : Event> Iterable<Note>.anyEvent(predicate: (T) -> Boolean): Boolean {
if (this is Collection && isEmpty()) return false
for (note in this) {
val noteEvent = note.event as? T
@@ -1014,7 +1013,7 @@ public inline fun <T> Iterable<Note>.anyEvent(predicate: (T) -> Boolean): Boolea
return false
}
public inline fun <T> Iterable<Note>.filterEvents(predicate: (T) -> Boolean): List<T> {
inline fun <reified T : Event> Iterable<Note>.filterEvents(predicate: (T) -> Boolean): List<T> {
if (this is Collection && isEmpty()) return emptyList()
val dest = ArrayList<T>()
@@ -1027,7 +1026,7 @@ public inline fun <T> Iterable<Note>.filterEvents(predicate: (T) -> Boolean): Li
return dest
}
public fun <T> Iterable<Note>.filterAuthoredEvents(pubkey: HexKey): List<T> {
inline fun <reified T : Event> Iterable<Note>.filterAuthoredEvents(pubkey: HexKey): List<T> {
if (this is Collection && isEmpty()) return emptyList()
val dest = ArrayList<T>()
@@ -1042,7 +1041,7 @@ public fun <T> Iterable<Note>.filterAuthoredEvents(pubkey: HexKey): List<T> {
return dest
}
public inline fun Iterable<Note>.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean {
inline fun Iterable<Note>.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean {
if (this is Collection && isEmpty()) return false
for (note in this) {
val noteEvent = note.event
@@ -1051,7 +1050,7 @@ public inline fun Iterable<Note>.anyNotNullEvent(predicate: (Event) -> Boolean):
return false
}
fun <T : Event> List<Note>.latestByAuthor(): Map<User, T> {
inline fun <reified T : Event> List<Note>.latestByAuthor(): Map<User, T> {
val oneResponsePerUser = mutableMapOf<User, T>()
forEach { note ->
@@ -18,6 +18,15 @@
* 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
package com.vitorpamplona.amethyst.commons.model.highlights
expect fun fastFindURLs(text: String): List<String>
data class HighlightData(
val id: String,
val text: String,
val note: String? = null,
val articleAddressTag: String,
val articleTitle: String? = null,
val createdAt: Long,
val published: Boolean = false,
val eventId: String? = null,
)
@@ -0,0 +1,79 @@
/*
* 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.commons.model.nip23LongContent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Shared action for publishing long-form content (NIP-23 kind 30023).
* Handles title, summary, image, tags, and d-tag for addressable events.
*/
object LongFormPublishAction {
private const val MAX_CONTENT_BYTES = 100_000
/**
* Publishes a long-form text note (NIP-23 kind 30023).
*
* @param title The article title
* @param content The markdown body content
* @param summary Optional article summary
* @param image Optional banner image URL
* @param tags List of hashtag topics
* @param dTag Unique identifier for this addressable event (slug)
* @param signer The NostrSigner to sign the event
* @return Signed LongTextNoteEvent ready to broadcast
* @throws IllegalStateException if signer is not writeable
*/
suspend fun publish(
title: String,
content: String,
summary: String?,
image: String?,
tags: List<String>,
dTag: String,
signer: NostrSigner,
): LongTextNoteEvent {
if (!signer.isWriteable()) {
throw IllegalStateException("Cannot publish: signer is not writeable")
}
if (content.toByteArray().size > MAX_CONTENT_BYTES) {
throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes")
}
val template =
LongTextNoteEvent.build(
description = content,
title = title,
summary = summary,
image = image,
publishedAt = TimeUtils.now(),
dTag = dTag,
) {
tags.forEach { hashtag(it) }
}
return signer.sign(template)
}
}
@@ -0,0 +1,84 @@
/*
* 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.commons.model.nip23LongContent
import kotlin.math.ceil
import kotlin.math.max
/**
* Calculates estimated reading time for markdown content.
* Uses 238 WPM for prose (Brysbaert 2019), 80 WPM for code blocks,
* and Medium's image decay formula (12 sec first, -1 each, min 3).
*/
object ReadingTimeCalculator {
private const val PROSE_WPM = 238.0
private const val CODE_WPM = 80.0
private val WHITESPACE_REGEX = "\\s+".toRegex()
private val IMAGE_REGEX = Regex("!\\[.*?]\\(.*?\\)")
private val IMAGE_STRIP_REGEX = Regex("!\\[.*?]\\(.*?\\)")
private val LINK_REGEX = Regex("\\[([^]]*)]\\([^)]*\\)")
private val FORMATTING_REGEX = Regex("[*_~`#>]")
private val LIST_MARKER_REGEX = Regex("^-\\s+|^\\d+\\.\\s+")
private val HORIZONTAL_RULE_REGEX = Regex("^---+$|^\\*\\*\\*+$")
fun calculate(markdownContent: String): Int {
var proseWords = 0
var codeWords = 0
var imageCount = 0
var inCodeBlock = false
markdownContent.lines().forEach { line ->
val trimmed = line.trim()
if (trimmed.startsWith("```")) {
inCodeBlock = !inCodeBlock
return@forEach
}
if (inCodeBlock) {
codeWords += trimmed.split(WHITESPACE_REGEX).count { it.isNotBlank() }
return@forEach
}
// Count images
val imageMatches = IMAGE_REGEX.findAll(trimmed)
imageCount += imageMatches.count()
// Strip markdown syntax for word counting
val stripped =
trimmed
.replace(IMAGE_STRIP_REGEX, "") // images
.replace(LINK_REGEX, "$1") // links -> text only
.replace(FORMATTING_REGEX, "") // formatting
.replace(LIST_MARKER_REGEX, "") // list markers
.replace(HORIZONTAL_RULE_REGEX, "") // horizontal rules
proseWords += stripped.split(WHITESPACE_REGEX).count { it.isNotBlank() }
}
// Medium's image time decay: 12 sec first, -1 each, min 3
val imageSeconds = (0 until imageCount).sumOf { max(12 - it, 3) }
val totalMinutes = (proseWords / PROSE_WPM) + (codeWords / CODE_WPM) + (imageSeconds / 60.0)
return max(1, ceil(totalMinutes).toInt())
}
}
@@ -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)
}
}
@@ -281,6 +281,18 @@ class UrlParserTest {
Urls(withScheme = emptySet()),
)
/**
* Regression test for PR #1907: parsing a note whose content is only the Japanese phrase
* "今北産業" (a common internet abbreviation) must not throw a StringIndexOutOfBoundsException
* from Url.getPart() and must produce no detected URLs.
*/
@Test
fun testImakitaSangyo() =
test(
"今北産業",
Urls(),
)
@Test
fun testHour() =
test(
+5 -1
View File
@@ -63,7 +63,7 @@ dependencies {
implementation(libs.androidx.collection)
// SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps
implementation("org.slf4j:slf4j-nop:2.0.16")
implementation(libs.slf4j.nop)
// QR code generation (ZXing core)
implementation(libs.zxing)
@@ -115,3 +115,7 @@ vlcSetup {
pathToCopyVlcMacosFilesTo.set(file("src/jvmMain/appResources/macos/vlc"))
pathToCopyVlcWindowsFilesTo.set(file("src/jvmMain/appResources/windows/vlc"))
}
tasks.named("spotlessKotlin") {
inputs.files(tasks.named("vlcSetup"))
}
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -143,6 +144,16 @@ sealed class DesktopScreen {
val noteId: String,
) : DesktopScreen()
data class Article(
val addressTag: String,
) : DesktopScreen()
data class Editor(
val draftSlug: String? = null,
) : DesktopScreen()
data object Drafts : DesktopScreen()
data object Settings : DesktopScreen()
}
@@ -369,6 +380,8 @@ fun main() {
Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) })
Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) })
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) })
Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) })
Item("Highlights", onClick = { deckState.addColumn(DeckColumnType.MyHighlights) })
Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) })
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
@@ -597,6 +610,13 @@ fun MainContent(
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
}
val highlightStore = remember { DesktopHighlightStore(appScope) }
val draftStore =
remember {
com.vitorpamplona.amethyst.desktop.service.drafts
.DesktopDraftStore(appScope)
}
// Subscribe to incoming DMs and process into chatroomList
LaunchedEffect(account) {
relayManager.connectedRelays.first { it.isNotEmpty() }
@@ -716,6 +736,8 @@ fun MainContent(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -753,6 +775,8 @@ fun MainContent(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -0,0 +1,289 @@
/*
* 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.desktop.service.drafts
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.nio.file.attribute.PosixFilePermission
import java.time.Instant
data class DraftMetadata(
val title: String = "",
val summary: String? = null,
val image: String? = null,
val tags: List<String> = emptyList(),
val createdAt: String = Instant.now().toString(),
val updatedAt: String = Instant.now().toString(),
val published: Boolean = false,
)
data class DraftEntry(
val slug: String,
val metadata: DraftMetadata,
)
/**
* Local draft storage for long-form articles.
* Stores markdown content as .md files and metadata in index.json.
* Uses atomic writes and restrictive file permissions.
*/
class DesktopDraftStore(
private val scope: CoroutineScope,
) {
private val mapper = jacksonObjectMapper()
private val mutex = Mutex()
private var cachedIndex: MutableMap<String, DraftMetadata>? = null
private val _drafts = MutableStateFlow<List<DraftEntry>>(emptyList())
val drafts: StateFlow<List<DraftEntry>> = _drafts.asStateFlow()
private val draftsDir: File by lazy {
val dir = File(System.getProperty("user.home"), ".amethyst/drafts")
if (!dir.exists()) {
dir.mkdirs()
setDirPermissions(dir)
}
dir
}
private val indexFile: File get() = File(draftsDir, "index.json")
init {
scope.launch(Dispatchers.IO) {
cachedIndex = null
loadIndex()
}
}
/**
* Sanitizes a slug to prevent path traversal and ensure safe filenames.
*/
private fun sanitizeSlug(slug: String): String {
val sanitized =
slug
.replace("/", "")
.replace("\\", "")
.replace("\u0000", "")
.trim()
.lowercase()
.replace(Regex("[^a-z0-9_-]"), "-")
.replace(Regex("-+"), "-")
.trimStart('-')
.trimEnd('-')
.take(128)
require(sanitized.isNotEmpty()) { "Slug cannot be empty after sanitization" }
// Validate canonical path stays within drafts dir
val resolved = File(draftsDir, "$sanitized.md").canonicalPath
require(resolved.startsWith(draftsDir.canonicalPath)) {
"Slug resolves outside drafts directory"
}
return sanitized
}
/**
* Generates a slug from a title. Falls back to timestamp if title is blank.
*/
fun slugFromTitle(title: String): String {
if (title.isBlank()) return "untitled-${Instant.now().epochSecond}"
return sanitizeSlug(title)
}
/**
* Saves or updates a draft. Creates content file and updates index atomically.
*/
suspend fun saveDraft(
slug: String,
content: String,
metadata: DraftMetadata,
) {
val safeSlug = sanitizeSlug(slug)
mutex.withLock {
// Write content file atomically
val contentFile = File(draftsDir, "$safeSlug.md")
atomicWrite(contentFile, content)
// Update index
val index = loadIndexMap()
index[safeSlug] = metadata.copy(updatedAt = Instant.now().toString())
atomicWriteIndex(index)
cachedIndex = index
// Refresh state
_drafts.value =
index.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
/**
* Loads a draft's content by slug.
*/
suspend fun loadContent(slug: String): String? {
val safeSlug = sanitizeSlug(slug)
val file = File(draftsDir, "$safeSlug.md")
return if (file.exists()) file.readText() else null
}
/**
* Loads a draft's metadata by slug.
*/
suspend fun loadMetadata(slug: String): DraftMetadata? {
val safeSlug = sanitizeSlug(slug)
return mutex.withLock {
loadIndexMap()[safeSlug]
}
}
/**
* Deletes a draft by slug.
*/
suspend fun deleteDraft(slug: String) {
val safeSlug = sanitizeSlug(slug)
mutex.withLock {
File(draftsDir, "$safeSlug.md").delete()
val index = loadIndexMap()
index.remove(safeSlug)
atomicWriteIndex(index)
cachedIndex = index
_drafts.value =
index.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
/**
* Marks a draft as published.
*/
suspend fun markPublished(slug: String) {
val safeSlug = sanitizeSlug(slug)
mutex.withLock {
val index = loadIndexMap()
val existing = index[safeSlug] ?: return
index[safeSlug] = existing.copy(published = true, updatedAt = Instant.now().toString())
atomicWriteIndex(index)
cachedIndex = index
_drafts.value =
index.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
private fun loadIndexMap(): MutableMap<String, DraftMetadata> {
cachedIndex?.let { return it }
val loaded: MutableMap<String, DraftMetadata> =
if (!indexFile.exists()) {
mutableMapOf()
} else {
try {
mapper.readValue<MutableMap<String, DraftMetadata>>(indexFile)
} catch (e: Exception) {
System.err.println("Failed to read drafts index: ${e.message}")
mutableMapOf()
}
}
cachedIndex = loaded
return loaded
}
private suspend fun loadIndex() {
mutex.withLock {
_drafts.value =
loadIndexMap()
.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
private fun atomicWrite(
file: File,
content: String,
) {
val tempFile = File(file.parentFile, "${file.name}.tmp")
try {
tempFile.writeText(content)
setFilePermissions(tempFile)
Files.move(
tempFile.toPath(),
file.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} finally {
if (tempFile.exists()) tempFile.delete()
}
}
private fun atomicWriteIndex(index: Map<String, DraftMetadata>) {
val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(index)
atomicWrite(indexFile, json)
}
private fun setDirPermissions(dir: File) {
try {
Files.setPosixFilePermissions(
dir.toPath(),
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
),
)
} catch (_: UnsupportedOperationException) {
// Windows
}
}
private fun setFilePermissions(file: File) {
try {
Files.setPosixFilePermissions(
file.toPath(),
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
),
)
} catch (_: UnsupportedOperationException) {
// Windows
}
}
}
@@ -0,0 +1,193 @@
/*
* 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.desktop.service.highlights
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Instant
import java.util.UUID
/**
* Local highlight storage for article annotations.
* Stores highlights as JSON in ~/.amethyst/highlights/index.json.
* Uses atomic writes following the same pattern as DesktopDraftStore.
*/
class DesktopHighlightStore(
private val scope: CoroutineScope,
) {
private val mapper = jacksonObjectMapper()
private val mutex = Mutex()
private val _highlights = MutableStateFlow<Map<String, List<HighlightData>>>(emptyMap())
val highlights: StateFlow<Map<String, List<HighlightData>>> = _highlights.asStateFlow()
private val highlightsDir: File by lazy {
val dir = File(System.getProperty("user.home"), ".amethyst/highlights")
if (!dir.exists()) {
dir.mkdirs()
}
dir
}
private val indexFile: File get() = File(highlightsDir, "index.json")
init {
scope.launch(Dispatchers.IO) {
loadIndex()
}
}
suspend fun addHighlight(
articleAddressTag: String,
text: String,
note: String?,
articleTitle: String?,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
val articleHighlights = current.getOrDefault(articleAddressTag, emptyList()).toMutableList()
// Avoid duplicate highlights of the same text
if (articleHighlights.any { it.text == text }) return
articleHighlights.add(
HighlightData(
id = UUID.randomUUID().toString(),
text = text,
note = note,
articleAddressTag = articleAddressTag,
articleTitle = articleTitle,
createdAt = Instant.now().epochSecond,
),
)
current[articleAddressTag] = articleHighlights
_highlights.value = current
saveIndex(current)
}
}
suspend fun updateNote(
highlightId: String,
note: String,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val idx = list.indexOfFirst { it.id == highlightId }
if (idx >= 0) {
current[key] =
list.toMutableList().apply {
set(idx, get(idx).copy(note = note))
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
suspend fun removeHighlight(highlightId: String) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val filtered = list.filter { it.id != highlightId }
if (filtered.size != list.size) {
if (filtered.isEmpty()) {
current.remove(key)
} else {
current[key] = filtered
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
suspend fun markPublished(
highlightId: String,
eventId: String,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val idx = list.indexOfFirst { it.id == highlightId }
if (idx >= 0) {
current[key] =
list.toMutableList().apply {
set(idx, get(idx).copy(published = true, eventId = eventId))
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
fun getHighlightsForArticle(addressTag: String): List<HighlightData> = _highlights.value[addressTag] ?: emptyList()
fun getAllHighlights(): Map<String, List<HighlightData>> = _highlights.value
private suspend fun loadIndex() {
mutex.withLock {
if (indexFile.exists()) {
try {
val data: Map<String, List<HighlightData>> = mapper.readValue(indexFile)
_highlights.value = data
} catch (e: Exception) {
// Corrupted file — start fresh
_highlights.value = emptyMap()
}
}
}
}
private fun saveIndex(data: Map<String, List<HighlightData>>) {
try {
val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(data)
val tempFile = File(highlightsDir, "index.json.tmp")
tempFile.writeText(json)
Files.move(
tempFile.toPath(),
indexFile.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
} catch (_: Exception) {
// Best effort — don't crash on write failure
}
}
}

Some files were not shown because too many files have changed in this diff Show More