mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge branch 'main' into claude/remove-libsodium-dependency-DjtWa
This commit is contained in:
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Generated
+2
-1
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
) {
|
||||
|
||||
+28
-3
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
-36
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+7
-9
@@ -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(
|
||||
|
||||
+8
@@ -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,
|
||||
) {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-7
@@ -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)
|
||||
}
|
||||
|
||||
+6
@@ -82,6 +82,12 @@ fun DisplayErrorMessages(
|
||||
}
|
||||
}
|
||||
|
||||
is ThrowableToastMsg2 -> {
|
||||
InformationDialog(obj) {
|
||||
toastManager.clearToasts()
|
||||
}
|
||||
}
|
||||
|
||||
is MultiErrorToastMsg -> {
|
||||
MultiUserErrorMessageDialog(obj, accountViewModel, nav)
|
||||
}
|
||||
|
||||
+7
@@ -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,
|
||||
)
|
||||
|
||||
+5
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-10
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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
|
||||
|
||||
+59
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+10
-2
@@ -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("")
|
||||
|
||||
+40
-12
@@ -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
|
||||
|
||||
+6
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+20
-1
@@ -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)
|
||||
|
||||
+21
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -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,
|
||||
) {
|
||||
|
||||
+12
-4
@@ -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 = {
|
||||
|
||||
+6
-3
@@ -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 = {
|
||||
|
||||
+7
-3
@@ -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 = {
|
||||
|
||||
+5
-3
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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 {
|
||||
|
||||
+7
-7
@@ -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 {
|
||||
|
||||
+7
-7
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+41
-11
@@ -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,
|
||||
|
||||
+20
-8
@@ -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("")
|
||||
|
||||
+8
-1
@@ -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()
|
||||
|
||||
+4
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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) },
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+3
@@ -105,6 +105,9 @@ private fun RenderRelayRow(
|
||||
onRemoveRelay = {
|
||||
nav.nav(Route.EditRelays)
|
||||
},
|
||||
onClick = {
|
||||
nav.nav(Route.RelayInfo(relay.url.url))
|
||||
},
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
|
||||
+4
-2
@@ -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))
|
||||
|
||||
+42
-10
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
-48
@@ -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
|
||||
}
|
||||
+14
-4
@@ -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>,
|
||||
|
||||
+5
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1001,7 +1001,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="follow_set_empty_feed_msg">Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen.</string>
|
||||
<string name="follow_set_add_author_from_note_action">Autor zum Folge-Set hinzufügen</string>
|
||||
<string name="follow_set_profile_actions_menu_description">Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen.</string>
|
||||
<string name="follow_set_icon_description">Symbol für %1$s-Liste</string>
|
||||
<string name="follow_set_icon_description">Symbol für Liste</string>
|
||||
<string name="follow_set_absence_indicator">%1$s ist nicht in dieser Liste</string>
|
||||
<string name="follow_set_man_dialog_title">Deine Folge-Sets</string>
|
||||
<string name="follow_set_empty_dialog_msg">Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen.</string>
|
||||
|
||||
@@ -290,6 +290,7 @@
|
||||
<string name="nip_05">Nostr-cím</string>
|
||||
<string name="never">soha</string>
|
||||
<string name="now">most</string>
|
||||
<string name="seconds">másodperc</string>
|
||||
<string name="h">ó</string>
|
||||
<string name="m">p</string>
|
||||
<string name="d">n</string>
|
||||
@@ -447,6 +448,8 @@
|
||||
<string name="poll_zap_value_max">Maximum Zap</string>
|
||||
<string name="poll_consensus_threshold">Együttműködés</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_single_choice">Egyetlen lehetőség</string>
|
||||
<string name="poll_multiple_choice">Több lehetőség</string>
|
||||
<string name="poll_closing_date_time">Szavazás lezárásának dátuma és ideje</string>
|
||||
<string name="poll_closing_in">A szavazás lezárul %1$s múlva</string>
|
||||
<string name="poll_closing_time">Szavazás lezárása</string>
|
||||
@@ -1180,7 +1183,7 @@
|
||||
<string name="outbox_relays_title">Átjátszók a kimenő üzenetkhez</string>
|
||||
<string name="outbox_relays_not_found">Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez</string>
|
||||
<string name="outbox_relays_not_found_description">A tartalom fogadására kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez, és ez az egyetlen módja annak, hogy a követői megtalálják Önt. </string>
|
||||
<string name="outbox_relays_not_found_editing">Adjon meg 1-3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért</string>
|
||||
<string name="outbox_relays_not_found_editing">Adjon meg 1–3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért</string>
|
||||
<string name="outbox_relays_not_found_examples">Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
|
||||
<string name="inbox_relays_title">Átjátszók a bejövő üzenetkhez</string>
|
||||
<string name="inbox_relays_not_found">Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához</string>
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<string name="yes">Jā</string>
|
||||
<string name="no">Nē</string>
|
||||
<string name="follow_list_selection">Sekot saraksts</string>
|
||||
<string name="follow_set_icon_description">Ikona %1$s sarakstam</string>
|
||||
<string name="follow_set_icon_description">Ikona sarakstam</string>
|
||||
<string name="follow_set_creation_menu_title">Izveidot jaunu sarakstu</string>
|
||||
<string name="follow_set_creation_dialog_title">Jaunais %1$s saraksts</string>
|
||||
<string name="follow_set_creation_name_label">Kolekcijas nosaukums</string>
|
||||
|
||||
@@ -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">(0–100)%</string>
|
||||
<string name="poll_single_choice">Pojedynczy wybór</string>
|
||||
<string name="poll_multiple_choice">Wielokrotny wybór</string>
|
||||
<string name="poll_closing_date_time">Data & godzina zakończenia ankiety</string>
|
||||
<string name="poll_closing_in">Ankieta zostanie zamknięta za %1$s</string>
|
||||
<string name="poll_closing_time">Zamknij po</string>
|
||||
@@ -537,7 +540,7 @@
|
||||
<string name="follow_set_create_btn_label">Nowa</string>
|
||||
<string name="follow_set_add_author_from_note_action">Dodaj autora do listy obserwowanych</string>
|
||||
<string name="follow_set_profile_actions_menu_description">Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem.</string>
|
||||
<string name="follow_set_icon_description">Ikona dla listy %1$s</string>
|
||||
<string name="follow_set_icon_description">Ikona dla listy</string>
|
||||
<string name="follow_set_public_presence_indicator">%1$s jest uczestnikiem publicznym</string>
|
||||
<string name="follow_set_private_presence_indicator">%1$s jest uczestnikiem prywatnym</string>
|
||||
<string name="follow_set_public_member_add_label">Dodaj jako uczestnika publicznego</string>
|
||||
@@ -633,7 +636,7 @@
|
||||
<string name="app_notification_dms_channel_description">Powiadamia Cię, gdy nadejdzie prywatna wiadomość</string>
|
||||
<string name="app_notification_zaps_channel_name">Otrzymano Zapy</string>
|
||||
<string name="app_notification_zaps_channel_description">Powiadamia Cię, gdy ktoś prześle ci zapy</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s Satsów</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s Satoszy</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Od %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">dla %1$s</string>
|
||||
<string name="app_notification_reply_label">Odpowiedz</string>
|
||||
@@ -668,9 +671,9 @@
|
||||
<string name="new_reaction_symbol">Nowy Symbol Odzewu</string>
|
||||
<string name="no_reaction_type_setup_long_press_to_change">Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić</string>
|
||||
<string name="zapraiser">Zapraiser</string>
|
||||
<string name="zapraiser_explainer">Dodaje docelową liczbę satsów do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn</string>
|
||||
<string name="zapraiser_target_amount_in_sats">Docelowa kwota w Satach</string>
|
||||
<string name="sats_to_complete">Zapraiser przy: %1$s. %2$s satach do celu</string>
|
||||
<string name="zapraiser_explainer">Dodaje docelową liczbę satoszy do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn</string>
|
||||
<string name="zapraiser_target_amount_in_sats">Docelowa kwota w Satoszach</string>
|
||||
<string name="sats_to_complete">Zapraiser przy: %1$s. %2$s satoszach do celu</string>
|
||||
<string name="read_from_relay">Odczytaj z Transmitera</string>
|
||||
<string name="write_to_relay">Zapisz do Transmitera</string>
|
||||
<string name="write_to_relay_description">Ilość w bajtach, która została wysłana do tego transmitera, w tym filtry i wydarzenia</string>
|
||||
@@ -878,7 +881,7 @@
|
||||
<string name="copy_to_clipboard">Kopiuj do schowka</string>
|
||||
<string name="copy_nprofile_to_clipboard">Skopiuj nprofile do schowka</string>
|
||||
<string name="copy_npub_to_clipboard">Kopiuj npub do schowka</string>
|
||||
<string name="share_or_save">Udostępnij lub Zapisz</string>
|
||||
<string name="share_or_save">Udostępnij lub zapisz</string>
|
||||
<string name="copy_url_to_clipboard">Kopiuj adres URL do schowka</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopiuj ID wpisu do schowka</string>
|
||||
<string name="add_media_to_gallery">Dodaj pliki do Galerii</string>
|
||||
@@ -916,7 +919,7 @@
|
||||
<string name="zap_split_search_and_add_user">Szukaj i dodaj użytkownika</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Nick lub Login</string>
|
||||
<string name="missing_lud16">Brakująca konfiguracja LN</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satsy</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satosze</string>
|
||||
<string name="zap_split_weight">Procentowo</string>
|
||||
<string name="zap_split_weight_placeholder">25</string>
|
||||
<string name="splitting_zaps_with">Podziel zapsy z</string>
|
||||
@@ -947,7 +950,7 @@
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint dostarczył następujący komunikat błędu: %1$s</string>
|
||||
<string name="cashu_failed_redemption_explainer_already_spent">Tokeny Cashu zostały już wydane.</string>
|
||||
<string name="cashu_successful_redemption">Cashu odebrano</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satsów)</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satoszy)</string>
|
||||
<string name="cashu_no_wallet_found">W systemie nie znaleziono kompatybilnego portfela Cashu</string>
|
||||
<string name="error_unable_to_fetch_invoice">Nie można pobrać faktury z serwerów odbiorcy</string>
|
||||
<string name="wallet_connect_pay_invoice_error_error">Twój dostawca połączenia z portfelem zwrócił następujący błąd: %1$s</string>
|
||||
@@ -965,7 +968,7 @@
|
||||
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">Nie znaleziono zwrotnego adresu URL z odpowiedzi %1$s</string>
|
||||
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">Wystąpił błąd podczas analizowania JSON z pobierania faktury z Lightning Adresu. Sprawdź konfigurację lightning użytkownika</string>
|
||||
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika</string>
|
||||
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">Nieprawidłowa kwota faktury (%1$s satsów) od %2$s. Powinieno być %3$s</string>
|
||||
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">Nieprawidłowa kwota faktury (%1$s satoszy) od %2$s. Powinieno być %3$s</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">Nie można utworzyć faktury przed wysłaniem zapa. Portfel odbiorcy wysłał następujący błąd: %1$s</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Nie można utworzyć faktury. Wiadomość od %1$s: %2$s</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">Nie można utworzyć faktury przed wysłaniem zapa. Element pr nie został znaleziony w powstałym JSON.</string>
|
||||
@@ -992,7 +995,7 @@
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="classifieds_condition">Stan</string>
|
||||
<string name="classifieds_category">Kategoria</string>
|
||||
<string name="classifieds_price">Cena (w Satach)</string>
|
||||
<string name="classifieds_price">Cena (w Satoszach)</string>
|
||||
<string name="classifieds_price_placeholder">1000</string>
|
||||
<string name="classifieds_location">Lokalizacja</string>
|
||||
<string name="classifieds_location_placeholder">Miasto, Województwo, Kraj</string>
|
||||
@@ -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">(0–100)%</string>
|
||||
<string name="poll_single_choice">单选</string>
|
||||
<string name="poll_multiple_choice">多选</string>
|
||||
<string name="poll_closing_date_time">投票结束日期 & 时间</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">插入 1–3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入</string>
|
||||
<string name="outbox_relays_not_found_examples">好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
|
||||
<string name="inbox_relays_title">收件箱中继</string>
|
||||
<string name="inbox_relays_not_found">设置您的公共收件箱中继来接收通知</string>
|
||||
@@ -1241,6 +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>
|
||||
|
||||
@@ -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">(0–100)%</string>
|
||||
<string name="poll_single_choice">Single choice</string>
|
||||
<string name="poll_multiple_choice">Multiple choice</string>
|
||||
<string name="poll_closing_date_time">Poll Closing Date & Time</string>
|
||||
<string name="poll_closing_in">Poll closes in %1$s</string>
|
||||
<string name="poll_closing_time">Close after</string>
|
||||
@@ -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>
|
||||
|
||||
|
||||
+37
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+120
@@ -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))
|
||||
}
|
||||
}
|
||||
+143
@@ -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
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+363
@@ -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) + "" + 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) + "" + 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
|
||||
}
|
||||
}
|
||||
+184
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+160
@@ -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") },
|
||||
)
|
||||
}
|
||||
}
|
||||
+116
@@ -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 ->
|
||||
|
||||
+11
-2
@@ -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,
|
||||
)
|
||||
+79
@@ -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)
|
||||
}
|
||||
}
|
||||
+84
@@ -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())
|
||||
}
|
||||
}
|
||||
+27
-20
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
+289
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -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
Reference in New Issue
Block a user