mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge branch 'vitorpamplona:main' into main
This commit is contained in:
@@ -119,7 +119,7 @@ Create 8 hybrid domain skills combining general expertise with AmethystMultiplat
|
||||
**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation
|
||||
|
||||
**SKILL.md sections:**
|
||||
- iOS source sets: iosMain, iosX64Main, iosArm64Main
|
||||
- iOS source sets: iosMain, iosArm64Main
|
||||
- Swift interop: type mapping, nullability
|
||||
- expect/actual iOS: 10+ examples from quartz/iosMain
|
||||
- XCFramework setup: baseName = "quartz-kmpKit"
|
||||
|
||||
Regular → Executable
@@ -16,7 +16,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "./gradlew spotlessApply",
|
||||
"command": "./gradlew spotlessApply 2>/dev/null || spotless-apply",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: find-missing-translations
|
||||
description: Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language
|
||||
---
|
||||
|
||||
# Find Missing Translations
|
||||
|
||||
## 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 missing keys and offers to translate them.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Need to find untranslated strings for a specific locale
|
||||
- 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
|
||||
|
||||
```
|
||||
Default: amethyst/src/main/res/values/strings.xml
|
||||
Target: amethyst/src/main/res/values-<locale>/strings.xml
|
||||
```
|
||||
|
||||
### 2. Find missing keys using cs-rCZ as reference
|
||||
|
||||
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")
|
||||
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-cs-rCZ/strings.xml \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
For each missing key, extract its English value:
|
||||
|
||||
```bash
|
||||
# For each missing key, extract the full line from default strings.xml
|
||||
while IFS= read -r key; do
|
||||
grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml
|
||||
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-cs-rCZ/strings.xml \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
|
||||
```
|
||||
|
||||
### 4. Present results and ask to translate
|
||||
|
||||
Output the missing entries as raw XML resource lines (copy-paste ready):
|
||||
|
||||
```xml
|
||||
<string name="attestation_valid">Valid</string>
|
||||
<string name="attestation_valid_from">Valid from %1$s</string>
|
||||
<string name="feed_group_lists">Lists</string>
|
||||
```
|
||||
|
||||
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
|
||||
- **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
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
### :quartz (KMP Nostr Library)
|
||||
**Type:** Kotlin Multiplatform Library
|
||||
**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64)
|
||||
**Targets:** JVM, Android, iOS (iosArm64, iosSimulatorArm64)
|
||||
**Dependencies:**
|
||||
- External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable
|
||||
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain
|
||||
@@ -127,7 +127,6 @@ commonMain (base)
|
||||
│ ├─ androidMain (Android platform)
|
||||
│ └─ jvmMain (Desktop platform)
|
||||
└─ iosMain (iOS platform)
|
||||
├─ iosX64Main
|
||||
├─ iosArm64Main
|
||||
└─ iosSimulatorArm64Main
|
||||
```
|
||||
|
||||
@@ -110,8 +110,8 @@ Think of source sets as a dependency graph, not folders.
|
||||
│ - Jackson │ │ │
|
||||
│ - OkHttp │ └────┬─────────────┘
|
||||
└───┬───────────┬───┘ │
|
||||
│ │ ├─→ iosX64Main
|
||||
▼ ▼ ├─→ iosArm64Main
|
||||
│ │ │
|
||||
▼ ▼ ├─→ iosArm64Main
|
||||
┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main
|
||||
│android │ │jvmMain │
|
||||
│Main │ │(Desktop) │
|
||||
@@ -252,7 +252,7 @@ expect fun currentTimeSeconds(): Long
|
||||
|
||||
**iOS (iosMain):**
|
||||
- Active development, framework configured
|
||||
- Architecture targets: iosX64Main, iosArm64Main, iosSimulatorArm64Main
|
||||
- Architecture targets: macosArm64Main, iosArm64Main, iosSimulatorArm64Main
|
||||
- Platform APIs via platform.posix, Security framework
|
||||
|
||||
### Web, wasm - Future Targets
|
||||
|
||||
@@ -29,7 +29,7 @@ Visual guide to source set organization with concrete examples from the codebase
|
||||
│ - Jackson │ │ - Platform libs │
|
||||
│ - OkHttp │ └───────┬───────────┘
|
||||
└────┬─────────┬───┘ │
|
||||
│ │ ├─→ iosX64Main (simulator Intel)
|
||||
│ │ │
|
||||
│ │ ├─→ iosArm64Main (device ARM64)
|
||||
│ │ └─→ iosSimulatorArm64Main (Apple Silicon)
|
||||
▼ ▼
|
||||
@@ -238,7 +238,6 @@ iosMain {
|
||||
}
|
||||
}
|
||||
|
||||
val iosX64Main by getting { dependsOn(iosMain.get()) }
|
||||
val iosArm64Main by getting { dependsOn(iosMain.get()) }
|
||||
val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) }
|
||||
```
|
||||
@@ -249,7 +248,6 @@ val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) }
|
||||
- Different from Android/Desktop
|
||||
|
||||
**Architecture targets:**
|
||||
- iosX64Main: Intel simulator
|
||||
- iosArm64Main: Device (iPhone, iPad)
|
||||
- iosSimulatorArm64Main: Apple Silicon simulator
|
||||
|
||||
@@ -326,7 +324,7 @@ commonMain
|
||||
| androidMain | jvmAndroid | Android framework | Activity, ViewModel |
|
||||
| jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar |
|
||||
| iosMain | commonMain | iOS platform | Security framework |
|
||||
| iosX64Main | iosMain | Simulator (Intel) | Architecture-specific |
|
||||
| iosMain | Simulator (Intel) | Architecture-specific |
|
||||
| iosArm64Main | iosMain | Device (ARM64) | Architecture-specific |
|
||||
| jsMain | commonMain | JS/DOM | Web (future) |
|
||||
| wasmMain | commonMain | wasm APIs | WebAssembly (future) |
|
||||
|
||||
@@ -76,7 +76,6 @@ fun main() = application {
|
||||
|
||||
**Source sets:**
|
||||
- iosMain (common iOS code)
|
||||
- iosX64Main (Intel simulator)
|
||||
- iosArm64Main (device - iPhone/iPad)
|
||||
- iosSimulatorArm64Main (Apple Silicon simulator)
|
||||
|
||||
@@ -110,7 +109,7 @@ actual object Secp256k1Instance {
|
||||
```kotlin
|
||||
// quartz/build.gradle.kts
|
||||
kotlin {
|
||||
listOf(iosX64(), iosArm64(), iosSimulatorArm64())
|
||||
listOf(macosArm64(), iosArm64(), iosSimulatorArm64())
|
||||
.forEach { target ->
|
||||
target.binaries.framework {
|
||||
baseName = "quartz-kmpKit"
|
||||
@@ -310,7 +309,7 @@ fun parseJson(json: String): Event {
|
||||
- Manual desktop app testing
|
||||
|
||||
**iOS:**
|
||||
- Unit tests: iosTest (iosX64Test, iosArm64Test, etc.)
|
||||
- Unit tests: iosTest (iosArm64Test, etc.)
|
||||
- Simulator/device testing
|
||||
|
||||
**Web (future):**
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -609,7 +609,7 @@ SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-
|
||||
import com.vitorpamplona.quartz.nip01Core.store.EventStore
|
||||
import android.content.Context
|
||||
|
||||
val store = EventStore(context)
|
||||
val store = EventStore()
|
||||
|
||||
// Insert
|
||||
store.insert(event)
|
||||
|
||||
@@ -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.
|
||||
+23
-19
@@ -6,22 +6,26 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -46,16 +50,16 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -72,7 +76,7 @@ jobs:
|
||||
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
|
||||
with:
|
||||
name: Test Reports
|
||||
@@ -84,16 +88,16 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -106,13 +110,13 @@ jobs:
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Upload Play APK
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Play Debug APK
|
||||
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
|
||||
|
||||
- name: Upload FDroid APK
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: FDroid Debug APK
|
||||
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
|
||||
@@ -121,19 +125,19 @@ jobs:
|
||||
run: ./gradlew assembleBenchmark
|
||||
|
||||
- name: Upload Play APK Benchmark
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Play Benchmark APK
|
||||
path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk
|
||||
|
||||
- name: Upload FDroid APK Benchmark
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: FDroid Benchmark APK
|
||||
path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk
|
||||
|
||||
- name: Upload Compose Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Compose Reports
|
||||
path: amethyst/build/compose_compiler
|
||||
@@ -163,16 +167,16 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -185,7 +189,7 @@ jobs:
|
||||
run: ./gradlew :desktopApp:${{ matrix.task }}
|
||||
|
||||
- name: Upload Desktop Distribution
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: ${{ matrix.artifact-path }}
|
||||
|
||||
@@ -27,16 +27,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -263,16 +263,16 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
/captures
|
||||
.cxx
|
||||
|
||||
# superpowers skill
|
||||
.superpowers
|
||||
docs/brainstorms
|
||||
docs/superpowers
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
@@ -153,3 +157,8 @@ TASKS.md
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
|
||||
# Downloaded VLC binaries (vlc-setup plugin)
|
||||
desktopApp/src/jvmMain/appResources/linux/
|
||||
desktopApp/src/jvmMain/appResources/macos/
|
||||
desktopApp/src/jvmMain/appResources/windows/
|
||||
|
||||
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>
|
||||
+108
-38
@@ -1,12 +1,19 @@
|
||||
Adds support for creating and rendering NIP-85 Polls
|
||||
<a id="v1.06.0"></a>
|
||||
# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2025-03-21
|
||||
|
||||
Adds support for Relay Feeds
|
||||
Polls:
|
||||
- Adds support for creating and rendering NIP-85
|
||||
- Redesign of the poll and zap poll cards
|
||||
- Adds special notification card while the poll is running
|
||||
|
||||
Relay Feeds
|
||||
- Adds support for rendering relay feeds
|
||||
- Adds support for NIP-51 favorite relay feeds
|
||||
- Shows favorite relays in the top navigation filter
|
||||
- Clicking wss:// links shows the global feed for that relay.
|
||||
- New user account adds nostr.wine to favorite relay feeds
|
||||
|
||||
Redesigns Media Player
|
||||
Media Player
|
||||
- Redesigned player controls for videos, audios, and picture-in-picture.
|
||||
- Adds our own buttons and indicators for the video playback
|
||||
- Adds Music support with waveform animations
|
||||
@@ -15,44 +22,78 @@ Redesigns Media Player
|
||||
- Turn video controller creation into a flow to fix playback lifecycle issues
|
||||
- Adds support for uploading audio
|
||||
|
||||
Adds support for NIP-47 Wallets
|
||||
NWC Wallets:
|
||||
- Adds support for NIP-47 Wallets and compete NWC spec
|
||||
- Adds views for Balance and Transactions
|
||||
- Add transaction filtering and pagination to wallet screen
|
||||
- Added several test cases from other repos to guarantee interoperability
|
||||
|
||||
Adds support for NIP-52 Calendar appointments
|
||||
Calendar:
|
||||
- Adds support for NIP-52 Calendar appointments
|
||||
- Adds proper display of calendar time slot and date slot events in the note feed
|
||||
- Refactored the early implementation on Quartz for easier use
|
||||
|
||||
Adds support for NIP-39 External Identities with kind 10011
|
||||
Code Snippets:
|
||||
- Adds support for NIP-C0 Code Snippets
|
||||
- Replies using NIP-22
|
||||
|
||||
Adds support for NIP-C0 Code Snippets
|
||||
NIPs on Nostr
|
||||
- Adds support for event kind 30817
|
||||
- Replies using NIP-22
|
||||
|
||||
Adds support for NIPs on Nostr (event kind 30817)
|
||||
PayTo:
|
||||
- Adds support for NIP-A3 Payment targets by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
|
||||
|
||||
Adds support for NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
|
||||
Blossom BUD-10:
|
||||
- Adds support for "Blossom:" URIs on the post
|
||||
- Supports automatic discovery of blossom servers
|
||||
- Renders/Previews images, audios, videos, and documents
|
||||
- Includes support for encryption when using it in NIP-17 DMs.
|
||||
|
||||
Adds support for BUD-10 "Blossom:" URIs in images, audios, videos, and documents.
|
||||
Expirations
|
||||
- Adds enhanced support for custom expirations in any new post.
|
||||
- Displays expirations on posts and DMs
|
||||
|
||||
Adds support for NIP-40 Expirations in any new post.
|
||||
Relay Monitors:
|
||||
- Adds support for NIP-66 Relay monitor and discovery support to Quartz
|
||||
|
||||
Adds support for NIP-66 Relay Monitor and discovery support to Quartz
|
||||
Attestations:
|
||||
- Adds support for rendering Attestations (https://attestr.xyz/)
|
||||
- Recommendations, Requests and Attestor Declarations are also included.
|
||||
|
||||
Adds support for Namecoin .bit urls to NIP-05
|
||||
- Adds choice of ElectrumX server to resolve namecoins.
|
||||
Chess:
|
||||
- Adds basic support for Chess with Jester protocol
|
||||
- Full chess game implemented
|
||||
- Supports for game challenges and view external games
|
||||
- Running on debug only for now
|
||||
|
||||
Adds basic support for Chess with Jester protocol
|
||||
DMs:
|
||||
- Removes NIP-04 DMs
|
||||
- Blocks DM sending if the receiver doesn't have NIP-17 relay lists.
|
||||
- Removed incognito icon from the new post field.
|
||||
|
||||
Adds NIP-46 Bunker support to Quartz and Amethyst Desktop
|
||||
Push Notifications:
|
||||
- Adds support for inline reply
|
||||
- Adds support for notification grouping
|
||||
- Adds support for Async image Loading
|
||||
- Removed NIP-04 notifications
|
||||
|
||||
Adds a Broadcasting feedback pop-up in the Complete UI mode
|
||||
Long Form:
|
||||
- Adds support for writing Long Form/Markdown content
|
||||
- Includes support for automatic Draft saving and editing
|
||||
- Includes support for editing
|
||||
|
||||
Adds support for rendering Zap events when quoted inside of posts.
|
||||
Uploads:
|
||||
- Adds support to upload Documents to all new post screens.
|
||||
- Adds toggle to stip file metadata regardless of compression by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Adds encrypted file upload fallback option for NIP-17 chats
|
||||
- Removes support for NIP-96 and updates Blossom recommendations
|
||||
|
||||
Removes support for NIP-96 and updates Blossom recommendations
|
||||
|
||||
Adds support to upload Documents to all new post screens.
|
||||
|
||||
Content warning improvements:
|
||||
- Adds optional description field for sensitive content warnings in new posts.
|
||||
Content Warning:
|
||||
- Adds an optional description field for sensitive content warnings in new posts.
|
||||
- Displays additional information on warning composables
|
||||
|
||||
Redesigns and reorganizes Setting pages
|
||||
Settings redesign:
|
||||
- Consolidate drawer settings into a single Settings hub screen
|
||||
- Redesigns Zap Amount and NWC setup screens
|
||||
- Redesigns Custom zap amount screens
|
||||
@@ -61,13 +102,21 @@ Redesigns and reorganizes Setting pages
|
||||
- Adds reactions row settings (enable/disable, order, show/hide counters) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
|
||||
- Tapping on Zap without any pre-configured amount opens the custom dialog
|
||||
|
||||
URL/URI parser rewrite in Kotlin multiplatform (KMP)
|
||||
Content parsers:
|
||||
- URL/URI parser rewrite in Kotlin multiplatform (KMP)
|
||||
- Fixes characters attached to URLs or nostr URLs without a space
|
||||
- Massively increases parsing performance
|
||||
- Treat multibyte characters as URL terminators in RichTextParser by @npub1k0jrarx8um0lyw3nmysn50539ky4k8p7gfgzgrsvn8d7lccx3d0s38dczd
|
||||
- Adds a parser for blossom: uris
|
||||
|
||||
Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
|
||||
UI Improvements:
|
||||
- Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
|
||||
- New Material 3 UI for DropDowns by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- New Material 3 UI for feed filters by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Draft Screen requests confirmation before deleting drafts on swipe
|
||||
- Swipe to switch tabs. Main screen and messages by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Adds support for rendering Zap events when quoted inside of posts.
|
||||
- Adds a Broadcasting feedback pop-up in the Complete UI mode
|
||||
|
||||
Relay Management:
|
||||
- Adds relay search tooltip when adding relays
|
||||
@@ -75,9 +124,11 @@ Relay Management:
|
||||
- Adds active subscriptions and outbox event in the queue to relay information
|
||||
- Adds a complete list of event kind names to the subscription card to relay information
|
||||
- Tracks and displays connection success rate on relay settings
|
||||
- Add relay settings export functionality
|
||||
- Adds relay settings export functionality
|
||||
- Adds NIP-45 count queries to show how many events each relay has.
|
||||
- Adds Relay sync utility to help users move posts between relays.
|
||||
|
||||
Search fixes
|
||||
Search:
|
||||
- Breaks the search filter into two subscriptions to prioritize Metadata without punishing content.
|
||||
- Fixes the need to start user searches with @ in user fields
|
||||
- Fixes the stability of the search feed when the user navigates away and back.
|
||||
@@ -87,17 +138,21 @@ Search fixes
|
||||
- Removes outdated versions of addressables from the search results
|
||||
|
||||
Profiles:
|
||||
- Adds support for NIP-39 External Identities with kind 10011
|
||||
- Adds a profile picture upload button when the user has no picture
|
||||
- Adds last seen to the user profile
|
||||
- Adds nprofile and npub copy options to the profile
|
||||
- Groups received zap amounts by sending the user in the profile tab
|
||||
- Increases the limit of Zap downloads for profiles to 1000
|
||||
- Simplifies profile edit screen layout by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
|
||||
- Migrates profile galleries to display a thumbnail for videos
|
||||
- Fixes profile galleries' aspect ratios
|
||||
- Adds support for Namecoin .bit urls to NIP-05 and choice of ElectrumX server to resolve namecoins.
|
||||
|
||||
Bulk Follow onboarding
|
||||
- Adds screens to search for a user and to copy his/her follow list
|
||||
Onboarding
|
||||
- Adds bulk follow screens to search for a user and to copy his/her follow list
|
||||
|
||||
Voice message support by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
Voice message by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Adds voice anonymization
|
||||
- Change from "hold to record" to "click to start, click to stop"
|
||||
- Display kind 1 voice replies as an audio waveform
|
||||
@@ -126,10 +181,8 @@ Fixes:
|
||||
- Fixes bug on Show More calculations for very long texts without spaces
|
||||
- Fixing IO Dispatchers and coroutine scopes of choice
|
||||
- Fixes anySync parallel operation that was returning the first result, not the first positive "any".
|
||||
|
||||
AI:
|
||||
- Add SKILL.md for AI agent customization
|
||||
- Add settings and hooks to setup Android Development for the agent
|
||||
- Fixes Req onCannotConnect listeners to the relays that actually sent the req
|
||||
- Fixes hanging subscriptions when exceptions happen during NostrClient utility methods
|
||||
|
||||
Defaults:
|
||||
- Switches wss://nostr.band to wss://antiprimal.net, wss://relay.ditto.pub on app defaults
|
||||
@@ -137,6 +190,15 @@ Defaults:
|
||||
- Adds wss://directory.yabu.me and wss://profiles.nostr1.com as index relays
|
||||
- Adds electrumx.testls.space, nmc2.bitcoins.sk, 46.229.238.187 and i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion as ElectrumX servers
|
||||
|
||||
Quartz:
|
||||
- Adds Relay Server implementation with NIP-45 COUNT and NIP-42 AUTH support
|
||||
- Adds support for dynamic auth policies to the relay implementation.
|
||||
- Migrates Quartz EventStore from Android-only to KMP
|
||||
- Adds a reqUntilEoseAsFlow extension to the Nostr Client
|
||||
- Adds a reqBypassingRelayLimits extension to the Nostr Client
|
||||
- Adds comprehensive NIP-46 Bunker support
|
||||
- Adds comprehensive support for NIP-47 non-payment methods.
|
||||
|
||||
Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
|
||||
- Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge.
|
||||
- Provide implementation for LargeCache, using a CacheMap
|
||||
@@ -149,7 +211,6 @@ Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53
|
||||
- Provide implementation for AESGCM
|
||||
- Provide implementation for DigestInstance
|
||||
- Provide implementation for LibSodium
|
||||
- Adds a reqUntilEoseAsFlow extension to the Nostr Client
|
||||
|
||||
Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
|
||||
- Adds NIP-46 Bunker Login
|
||||
@@ -159,6 +220,8 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7
|
||||
- Adds encrypted DMs (NIP-04/NIP-17)
|
||||
- Adds proper empty states with EOSE tracking
|
||||
- Adds multi-column deck layout
|
||||
- Adds Full media parity — images, video, audio, encrypted DMs, upload, lightbox
|
||||
- Adds advanced search with NIP-50, collapsible sections, and nav state preservation
|
||||
- Clear stored credentials on logout
|
||||
- Adds bunker heartbeat indicator
|
||||
- Adds QR-based signer pairing
|
||||
@@ -176,6 +239,7 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7
|
||||
|
||||
Code Quality
|
||||
- Migrates to AGP 9.0
|
||||
- Adds Amethyst Desktop to CI/CD and Release builds
|
||||
- Removes the in-app memory counter methods
|
||||
- Refactors the old NIP-05 code on Quartz
|
||||
- Migrates contact list management to addressable notes
|
||||
@@ -190,6 +254,10 @@ Code Quality
|
||||
- Removes support for feed definitions
|
||||
- AccountState refactoring
|
||||
|
||||
AI:
|
||||
- Add SKILL.md for AI agent customization
|
||||
- Add settings and hooks to setup Android Development for the agent
|
||||
|
||||
Updated translations:
|
||||
- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
|
||||
@@ -199,6 +267,8 @@ Updated translations:
|
||||
- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
|
||||
- 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
|
||||
@@ -7008,4 +7078,4 @@ First public version with:
|
||||
[v0.4]: https://github.com/vitorpamplona/amethyst/compare/v0.3...v0.4
|
||||
[v0.3]: https://github.com/vitorpamplona/amethyst/compare/v0.2...v0.3
|
||||
[v0.2]: https://github.com/vitorpamplona/amethyst/compare/v0.1...v0.2
|
||||
[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1
|
||||
[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1
|
||||
|
||||
+17
-6
@@ -5,7 +5,6 @@ plugins {
|
||||
alias(libs.plugins.googleServices)
|
||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||
alias(libs.plugins.serialization)
|
||||
alias(libs.plugins.stability.analyzer)
|
||||
}
|
||||
|
||||
def getCurrentBranch() {
|
||||
@@ -36,6 +35,17 @@ def generateVersionName(String baseVersion) {
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
|
||||
afterEvaluate {
|
||||
def stabilityNames = tasks.names.findAll { it.contains("StabilityCheck") }
|
||||
def compileNames = tasks.names.findAll { it.matches("compile.*UnitTestKotlin") }
|
||||
stabilityNames.each { scName ->
|
||||
compileNames.each { ctName ->
|
||||
tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = 'com.vitorpamplona.amethyst'
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInteger()
|
||||
@@ -44,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 {
|
||||
@@ -336,9 +346,7 @@ dependencies {
|
||||
fdroidImplementation libs.unifiedpush
|
||||
|
||||
// Charts
|
||||
implementation libs.vico.charts.core
|
||||
implementation libs.vico.charts.compose
|
||||
implementation libs.vico.charts.views
|
||||
implementation libs.vico.charts.m3
|
||||
|
||||
// GeoHash
|
||||
@@ -352,6 +360,9 @@ dependencies {
|
||||
// Image compression lib
|
||||
implementation libs.zelory.image.compressor
|
||||
|
||||
// EXIF metadata stripping
|
||||
implementation libs.androidx.exifinterface
|
||||
|
||||
// Voice anonymization DSP
|
||||
implementation libs.tarsosdsp
|
||||
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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.relays.eventsync
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.Constants
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EventSyncTest {
|
||||
companion object {
|
||||
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
|
||||
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
|
||||
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
val rootClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
|
||||
.build()
|
||||
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSync() =
|
||||
runBlocking {
|
||||
val sync =
|
||||
EventSync(
|
||||
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
relayDb = {
|
||||
listOf(Constants.mom, Constants.nos)
|
||||
},
|
||||
outboxTargets = { setOf(vitor) },
|
||||
inboxTargets = { setOf(vitor) },
|
||||
dmTargets = { setOf(vitor) },
|
||||
clientBuilder = {
|
||||
NostrClient(socketBuilder, appScope)
|
||||
},
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
sync.runSync()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFiatjafSync() =
|
||||
runBlocking {
|
||||
val sync =
|
||||
EventSync(
|
||||
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
relayDb = { listOf(fiatjaf) },
|
||||
outboxTargets = { setOf(vitor) },
|
||||
inboxTargets = { setOf(vitor) },
|
||||
dmTargets = { setOf(vitor) },
|
||||
clientBuilder = {
|
||||
val newClient = NostrClient(socketBuilder, appScope)
|
||||
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
|
||||
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
|
||||
// Authenticates with relays.
|
||||
val auth =
|
||||
RelayAuthenticator(
|
||||
newClient,
|
||||
appScope,
|
||||
signWithAllLoggedInUsers = { authTemplate ->
|
||||
listOf(signer.sign(authTemplate))
|
||||
},
|
||||
)
|
||||
|
||||
newClient
|
||||
},
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
sync.runSync()
|
||||
}
|
||||
}
|
||||
@@ -240,6 +240,11 @@
|
||||
<action android:name="com.shared.NOSTR" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".service.notifications.NotificationReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ private object PrefKeys {
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val LOCAL_RELAY_SERVERS = "localRelayServers"
|
||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
@@ -322,6 +323,8 @@ object LocalPreferences {
|
||||
JsonMapper.toJson(settings.defaultFileServer),
|
||||
)
|
||||
|
||||
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
|
||||
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNotificationFollowList.value))
|
||||
@@ -461,6 +464,7 @@ object LocalPreferences {
|
||||
|
||||
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
|
||||
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
|
||||
|
||||
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
|
||||
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
||||
@@ -503,6 +507,7 @@ object LocalPreferences {
|
||||
externalSignerPackageName = externalSignerPackageName,
|
||||
localRelayServers = MutableStateFlow(localRelayServers),
|
||||
defaultFileServer = defaultFileServer,
|
||||
stripLocationOnUpload = stripLocationOnUpload,
|
||||
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
|
||||
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
|
||||
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
|
||||
|
||||
@@ -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
|
||||
@@ -171,8 +172,8 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
@@ -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.
|
||||
@@ -1609,7 +1634,7 @@ class Account(
|
||||
client.send(newEvent, outboxRelays.flow.value + destinationRelays)
|
||||
}
|
||||
|
||||
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer)
|
||||
@@ -2016,6 +2041,7 @@ class Account(
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||
settings.saveable.debounce(1000).collect {
|
||||
if (it.accountSettings != null) {
|
||||
LocalPreferences.saveToEncryptedStorage(it.accountSettings)
|
||||
|
||||
@@ -160,6 +160,7 @@ class AccountSettings(
|
||||
var externalSignerPackageName: String? = null,
|
||||
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
|
||||
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
|
||||
var stripLocationOnUpload: Boolean = true,
|
||||
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
@@ -265,6 +266,13 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeStripLocationOnUpload(strip: Boolean) {
|
||||
if (stripLocationOnUpload != strip) {
|
||||
stripLocationOnUpload = strip
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// list names
|
||||
// ---
|
||||
|
||||
@@ -39,6 +39,10 @@ import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
|
||||
import com.vitorpamplona.amethyst.service.BundledInsert
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.note.dateFormatter
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
@@ -58,7 +62,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE
|
||||
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -135,8 +139,8 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
|
||||
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
@@ -325,7 +329,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
newFilter.init()
|
||||
|
||||
observables.put(newFilter, newFilter)
|
||||
observables[newFilter] = newFilter
|
||||
|
||||
awaitClose {
|
||||
observables.remove(newFilter)
|
||||
@@ -358,19 +362,19 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
|
||||
|
||||
override fun getOrCreateUser(key: HexKey): User {
|
||||
require(isValidHex(key = key)) { "$key is not a valid hex" }
|
||||
override fun getOrCreateUser(pubkey: HexKey): User {
|
||||
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
|
||||
|
||||
return users.getOrCreate(key) {
|
||||
val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(key))
|
||||
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(key))
|
||||
return users.getOrCreate(pubkey) {
|
||||
val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(pubkey))
|
||||
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(pubkey))
|
||||
User(it, nip65RelayListNote, dmRelayListNote)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getUserIfExists(key: String): User? {
|
||||
if (key.isEmpty()) return null
|
||||
return users.get(key)
|
||||
override fun getUserIfExists(pubkey: String): User? {
|
||||
if (pubkey.isEmpty()) return null
|
||||
return users.get(pubkey)
|
||||
}
|
||||
|
||||
override fun countUsers(predicate: (String, User) -> Boolean): Int {
|
||||
@@ -394,7 +398,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address)
|
||||
|
||||
override fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) }
|
||||
override fun getNoteIfExists(hexKey: String): Note? = if (hexKey.length == 64) notes.get(hexKey) else Address.parse(hexKey)?.let { addressables.get(it) }
|
||||
|
||||
fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId)
|
||||
|
||||
@@ -619,6 +623,30 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestationRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestorRecommendationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestorProficiencyEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consumeRegularEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -961,7 +989,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
@Suppress("DEPRECATION")
|
||||
fun computeReplyTo(event: Event): List<Note> =
|
||||
when (event) {
|
||||
is PollNoteEvent -> {
|
||||
is ZapPollEvent -> {
|
||||
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
}
|
||||
|
||||
@@ -1059,7 +1087,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: PollNoteEvent,
|
||||
event: ZapPollEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
@@ -2250,6 +2278,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
requestNote?.let { request -> zappedNote?.addZapPayment(request, note) }
|
||||
|
||||
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
responseCallback(event)
|
||||
}
|
||||
@@ -2344,8 +2373,21 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
if (key != null) {
|
||||
val note = getNoteIfExists(key)
|
||||
if ((note != null) && !excludeNoteEventFromSearchResults(note)) {
|
||||
return listOfNotNull(note)
|
||||
val noteEvent = note?.event
|
||||
val newNote =
|
||||
if (noteEvent is AddressableEvent) {
|
||||
val addressableNote = getAddressableNoteIfExists(noteEvent.address())
|
||||
if (addressableNote?.event?.id == note.idHex) {
|
||||
addressableNote
|
||||
} else {
|
||||
note
|
||||
}
|
||||
} else {
|
||||
note
|
||||
}
|
||||
|
||||
if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) {
|
||||
return listOfNotNull(newNote)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3050,6 +3092,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
is AppDefinitionEvent -> consume(event, relay, wasVerified)
|
||||
is AppRecommendationEvent -> consume(event, relay, wasVerified)
|
||||
is AppSpecificDataEvent -> consume(event, relay, wasVerified)
|
||||
is AttestationEvent -> consume(event, relay, wasVerified)
|
||||
is AttestationRequestEvent -> consume(event, relay, wasVerified)
|
||||
is AttestorRecommendationEvent -> consume(event, relay, wasVerified)
|
||||
is AttestorProficiencyEvent -> consume(event, relay, wasVerified)
|
||||
is AudioHeaderEvent -> consume(event, relay, wasVerified)
|
||||
is AudioTrackEvent -> consume(event, relay, wasVerified)
|
||||
is BadgeAwardEvent -> consume(event, relay, wasVerified)
|
||||
@@ -3140,7 +3186,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
is PublicMessageEvent -> consume(event, relay, wasVerified)
|
||||
is PeopleListEvent -> consume(event, relay, wasVerified)
|
||||
is CodeSnippetEvent -> consume(event, relay, wasVerified)
|
||||
is PollNoteEvent -> consume(event, relay, wasVerified)
|
||||
is ZapPollEvent -> consume(event, relay, wasVerified)
|
||||
is PollEvent -> consume(event, relay, wasVerified)
|
||||
is PollResponseEvent -> consume(event, relay, wasVerified)
|
||||
is ReactionEvent -> consume(event, relay, wasVerified)
|
||||
|
||||
+7
-7
@@ -31,13 +31,13 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectRequestCache
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectResponseCache
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectRequestCache
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -114,7 +114,7 @@ class NwcSignerState(
|
||||
|
||||
fun hasWalletConnectSetup(): Boolean = nip47Setup.value != null
|
||||
|
||||
override fun isNIP47Author(pubkey: HexKey?): Boolean = nip47Signer.value.pubKey == pubkey
|
||||
override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey
|
||||
|
||||
/**
|
||||
* Decrypts a NIP-47 payment request using the current signer.
|
||||
|
||||
+2
-2
@@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.name
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.title
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -181,7 +181,7 @@ class LabeledBookmarkListsState(
|
||||
|
||||
val template =
|
||||
listEvent.update {
|
||||
if (listName != null) name(listName)
|
||||
if (listName != null) title(listName)
|
||||
if (listDescription != null) description(listDescription)
|
||||
if (listImage != null) image(listImage)
|
||||
}
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.image
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.name
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.title
|
||||
import com.vitorpamplona.quartz.utils.flattenToSet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -226,7 +226,7 @@ class PeopleListsState(
|
||||
|
||||
val template =
|
||||
listEvent.update {
|
||||
if (listName != null) name(listName)
|
||||
if (listName != null) title(listName)
|
||||
if (listDescription != null) description(listDescription)
|
||||
if (listImage != null) image(listImage)
|
||||
}
|
||||
|
||||
+1
@@ -79,6 +79,7 @@ class MergedFollowListsState(
|
||||
communities = community.mapTo(mutableSetOf()) { it.address.toValue() },
|
||||
)
|
||||
|
||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||
val flow: StateFlow<AllFollows> =
|
||||
combine(
|
||||
listOf(
|
||||
|
||||
@@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.cashu.v4
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.cbor.ByteString
|
||||
|
||||
@@ -34,6 +35,7 @@ class V4Token(
|
||||
val t: Array<V4T>?,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
class V4T(
|
||||
// identifier
|
||||
@@ -42,6 +44,7 @@ class V4T(
|
||||
val p: Array<V4Proof>,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
class V4Proof(
|
||||
// amount
|
||||
@@ -57,6 +60,7 @@ class V4Proof(
|
||||
val w: String? = null,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
class V4DleqProof(
|
||||
@ByteString
|
||||
|
||||
+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),
|
||||
|
||||
@@ -33,6 +33,7 @@ import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.asNetworkClient
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.quartz.utils.startsWithIgnoreCase
|
||||
import okhttp3.Call
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@@ -43,18 +44,14 @@ class BlossomFetcher(
|
||||
private val blossomServerResolver: BlossomServerResolver,
|
||||
private val networkFetcher: (url: String) -> Fetcher,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
println("BlossomFetcher: starting $data")
|
||||
return try {
|
||||
override suspend fun fetch(): FetchResult? =
|
||||
try {
|
||||
val urlResult = blossomServerResolver.findServers(data.toString())
|
||||
println("BlossomFetcher: finished $data to ${urlResult?.serverUrl}")
|
||||
networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
println("BlossomFetcher: cancelled or error: $e $data")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
class Factory(
|
||||
@@ -68,11 +65,7 @@ class BlossomFetcher(
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher? {
|
||||
println("BlossomFetcher: PreFactory $data")
|
||||
if (!isApplicable(data)) return null
|
||||
|
||||
println("BlossomFetcher: Factory $data")
|
||||
|
||||
return BlossomFetcher(options, data, blossomServerResolver) { url ->
|
||||
NetworkFetcher(
|
||||
url = url,
|
||||
@@ -86,6 +79,6 @@ class BlossomFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isApplicable(data: Uri): Boolean = data.scheme?.lowercase() == "blossom"
|
||||
private fun isApplicable(data: Uri): Boolean = data.scheme?.startsWithIgnoreCase("blossom", "BLOSSOM") == true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-26
@@ -183,7 +183,7 @@ class EventNotificationConsumer(
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
private suspend fun notify(
|
||||
event: ChatMessageEncryptedFileHeaderEvent,
|
||||
account: Account,
|
||||
) {
|
||||
@@ -210,13 +210,13 @@ class EventNotificationConsumer(
|
||||
val content = chatNote.event?.content ?: ""
|
||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = chatNote.author?.profilePicture()
|
||||
val noteUri =
|
||||
chatNote.toNEvent() + ACCOUNT_QUERY_PARAM +
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub()
|
||||
val accountNpub =
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub()
|
||||
val chatroomMembers = chatRoom.users.joinToString(",")
|
||||
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
||||
|
||||
// TODO: Show Image on notification
|
||||
notificationManager()
|
||||
.sendDMNotification(
|
||||
event.id,
|
||||
@@ -226,12 +226,15 @@ class EventNotificationConsumer(
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
accountNpub = accountNpub,
|
||||
accountPictureUrl = account.userProfile().profilePicture(),
|
||||
chatroomMembers = chatroomMembers,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
private suspend fun notify(
|
||||
event: ChatMessageEvent,
|
||||
account: Account,
|
||||
) {
|
||||
@@ -255,20 +258,25 @@ class EventNotificationConsumer(
|
||||
val content = chatNote.event?.content ?: ""
|
||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = chatNote.author?.profilePicture()
|
||||
val noteUri =
|
||||
chatNote.toNEvent() + ACCOUNT_QUERY_PARAM +
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub()
|
||||
val accountNpub =
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub()
|
||||
val chatroomMembers = chatRoom.users.joinToString(",")
|
||||
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
||||
|
||||
notificationManager()
|
||||
.sendDMNotification(
|
||||
event.id,
|
||||
content,
|
||||
user,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
id = event.id,
|
||||
messageBody = content,
|
||||
senderName = user,
|
||||
time = event.createdAt,
|
||||
pictureUrl = userPicture,
|
||||
uri = noteUri,
|
||||
applicationContext = applicationContext,
|
||||
accountNpub = accountNpub,
|
||||
accountPictureUrl = account.userProfile().profilePicture(),
|
||||
chatroomMembers = chatroomMembers,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -297,13 +305,25 @@ class EventNotificationConsumer(
|
||||
decryptContent(note, account.signer)?.let { content ->
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri =
|
||||
note.toNEvent() + ACCOUNT_QUERY_PARAM +
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub()
|
||||
val accountNpub =
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub()
|
||||
val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
||||
|
||||
notificationManager()
|
||||
.sendDMNotification(event.id, content, user, event.createdAt, userPicture, noteUri, applicationContext)
|
||||
.sendDMNotification(
|
||||
id = event.id,
|
||||
messageBody = content,
|
||||
senderName = user,
|
||||
time = event.createdAt,
|
||||
pictureUrl = userPicture,
|
||||
uri = noteUri,
|
||||
applicationContext = applicationContext,
|
||||
accountNpub = accountNpub,
|
||||
accountPictureUrl = account.userProfile().profilePicture(),
|
||||
chatroomMembers = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
val notificationId = intent.getIntExtra(NotificationUtils.KEY_NOTIFICATION_ID, 0)
|
||||
val notificationManager =
|
||||
ContextCompat.getSystemService(context, NotificationManager::class.java)
|
||||
as NotificationManager
|
||||
|
||||
when (intent.action) {
|
||||
NotificationUtils.MARK_READ_ACTION -> {
|
||||
notificationManager.cancel(notificationId)
|
||||
}
|
||||
|
||||
NotificationUtils.REPLY_ACTION -> {
|
||||
val replyText =
|
||||
RemoteInput
|
||||
.getResultsFromIntent(intent)
|
||||
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
|
||||
?.toString()
|
||||
|
||||
if (replyText.isNullOrBlank()) return
|
||||
|
||||
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
|
||||
val chatroomMembersStr = intent.getStringExtra(NotificationUtils.KEY_CHATROOM_MEMBERS) ?: return
|
||||
val members = chatroomMembersStr.split(",").filter { it.isNotBlank() }
|
||||
|
||||
if (members.isEmpty()) return
|
||||
|
||||
val pendingResult = goAsync()
|
||||
|
||||
scope.launch {
|
||||
// activates the relay to send the message.
|
||||
val collectionJob =
|
||||
scope.launch {
|
||||
Amethyst.instance.relayProxyClientConnector.relayServices
|
||||
.collect()
|
||||
}
|
||||
|
||||
try {
|
||||
sendReply(accountNpub, members, replyText)
|
||||
notificationManager.cancel(notificationId)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("NotificationReply", "Failed to send reply: ${e.message}")
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
|
||||
// closes the relay connection.
|
||||
collectionJob.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendReply(
|
||||
accountNpub: String,
|
||||
chatroomMembers: List<String>,
|
||||
replyText: String,
|
||||
) {
|
||||
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return
|
||||
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
|
||||
|
||||
val recipients = chatroomMembers.map { PTag(it) }
|
||||
val template = ChatMessageEvent.build(msg = replyText, to = recipients)
|
||||
|
||||
account.sendNip17PrivateMessage(template)
|
||||
}
|
||||
}
|
||||
+265
-96
@@ -25,23 +25,37 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.net.toUri
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
import coil3.executeBlocking
|
||||
import coil3.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object NotificationUtils {
|
||||
private var dmChannel: NotificationChannel? = null
|
||||
private var zapChannel: NotificationChannel? = null
|
||||
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
||||
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
|
||||
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
|
||||
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
|
||||
const val KEY_REPLY_TEXT = "key_reply_text"
|
||||
const val KEY_NOTIFICATION_ID = "key_notification_id"
|
||||
const val KEY_ACCOUNT_NPUB = "key_account_npub"
|
||||
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
|
||||
|
||||
private const val DM_SUMMARY_ID = 0x10000
|
||||
private const val ZAP_SUMMARY_ID = 0x20000
|
||||
|
||||
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
if (dmChannel != null) return dmChannel!!
|
||||
@@ -50,13 +64,12 @@ object NotificationUtils {
|
||||
NotificationChannel(
|
||||
stringRes(applicationContext, R.string.app_notification_dms_channel_id),
|
||||
stringRes(applicationContext, R.string.app_notification_dms_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description =
|
||||
stringRes(applicationContext, R.string.app_notification_dms_channel_description)
|
||||
}
|
||||
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
@@ -78,7 +91,6 @@ object NotificationUtils {
|
||||
stringRes(applicationContext, R.string.app_notification_zaps_channel_description)
|
||||
}
|
||||
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
@@ -87,7 +99,7 @@ object NotificationUtils {
|
||||
return zapChannel!!
|
||||
}
|
||||
|
||||
fun NotificationManager.sendZapNotification(
|
||||
suspend fun NotificationManager.sendZapNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
@@ -96,109 +108,109 @@ object NotificationUtils {
|
||||
uri: String,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
val zapChannel = getOrCreateZapChannel(applicationContext)
|
||||
getOrCreateZapChannel(applicationContext)
|
||||
val channelId = stringRes(applicationContext, R.string.app_notification_zaps_channel_id)
|
||||
|
||||
sendNotification(
|
||||
id,
|
||||
messageBody,
|
||||
messageTitle,
|
||||
time,
|
||||
pictureUrl,
|
||||
uri,
|
||||
channelId,
|
||||
ZAP_GROUP_KEY,
|
||||
applicationContext,
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
time = time,
|
||||
pictureUrl = pictureUrl,
|
||||
uri = uri,
|
||||
channelId = channelId,
|
||||
notificationGroupKey = ZAP_GROUP_KEY,
|
||||
category = NotificationCompat.CATEGORY_SOCIAL,
|
||||
summaryId = ZAP_SUMMARY_ID,
|
||||
summaryText = stringRes(applicationContext, R.string.app_notification_zaps_summary),
|
||||
applicationContext = applicationContext,
|
||||
)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendDMNotification(
|
||||
suspend fun NotificationManager.sendDMNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
senderName: String,
|
||||
time: Long,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
applicationContext: Context,
|
||||
accountNpub: String? = null,
|
||||
accountPictureUrl: String? = null,
|
||||
chatroomMembers: String? = null,
|
||||
) {
|
||||
val dmChannel = getOrCreateDMChannel(applicationContext)
|
||||
getOrCreateDMChannel(applicationContext)
|
||||
val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id)
|
||||
|
||||
sendNotification(
|
||||
id,
|
||||
messageBody,
|
||||
messageTitle,
|
||||
time,
|
||||
pictureUrl,
|
||||
uri,
|
||||
channelId,
|
||||
DM_GROUP_KEY,
|
||||
applicationContext,
|
||||
sendDMNotificationStyled(
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
senderName = senderName,
|
||||
time = time,
|
||||
pictureUrl = pictureUrl,
|
||||
uri = uri,
|
||||
channelId = channelId,
|
||||
applicationContext = applicationContext,
|
||||
accountNpub = accountNpub,
|
||||
accountPictureUrl = accountPictureUrl,
|
||||
chatroomMembers = chatroomMembers,
|
||||
)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendNotification(
|
||||
private suspend fun loadBitmap(
|
||||
pictureUrl: String,
|
||||
applicationContext: Context,
|
||||
): Bitmap? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
|
||||
val imageLoader = ImageLoader(applicationContext)
|
||||
val result = imageLoader.execute(request)
|
||||
(result.image?.asDrawable(applicationContext.resources) as? BitmapDrawable)?.bitmap
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun NotificationManager.sendDMNotificationStyled(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
senderName: String,
|
||||
time: Long,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
notificationGroupKey: String,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
if (pictureUrl != null) {
|
||||
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
|
||||
|
||||
val imageLoader = ImageLoader(applicationContext)
|
||||
val imageResult = imageLoader.executeBlocking(request)
|
||||
sendNotificationInner(
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
time = time,
|
||||
picture = imageResult.image?.asDrawable(applicationContext.resources) as? BitmapDrawable,
|
||||
uri = uri,
|
||||
channelId,
|
||||
notificationGroupKey,
|
||||
applicationContext = applicationContext,
|
||||
)
|
||||
} else {
|
||||
sendNotificationInner(
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
time = time,
|
||||
picture = null,
|
||||
uri = uri,
|
||||
channelId,
|
||||
notificationGroupKey,
|
||||
applicationContext = applicationContext,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationManager.sendNotificationInner(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
time: Long,
|
||||
picture: BitmapDrawable?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
notificationGroupKey: String,
|
||||
applicationContext: Context,
|
||||
accountNpub: String?,
|
||||
accountPictureUrl: String?,
|
||||
chatroomMembers: String?,
|
||||
) {
|
||||
val notId = id.hashCode()
|
||||
|
||||
// dont notify twice
|
||||
val notifications: Array<StatusBarNotification> = getActiveNotifications()
|
||||
for (notification in notifications) {
|
||||
if (notification.id == notId) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (isDuplicate(notId)) return
|
||||
|
||||
val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) }
|
||||
val accountBitmap = accountPictureUrl?.let { loadBitmap(it, applicationContext) }
|
||||
|
||||
val senderIcon = bitmap?.let { IconCompat.createWithBitmap(it) }
|
||||
val accountIcon = accountBitmap?.let { IconCompat.createWithBitmap(it) }
|
||||
|
||||
val sender =
|
||||
Person
|
||||
.Builder()
|
||||
.setName(senderName)
|
||||
.apply { senderIcon?.let { setIcon(it) } }
|
||||
.build()
|
||||
|
||||
val messagingStyle =
|
||||
NotificationCompat
|
||||
.MessagingStyle(
|
||||
Person
|
||||
.Builder()
|
||||
.setName("Me")
|
||||
.setIcon(accountIcon)
|
||||
.build(),
|
||||
).addMessage(messageBody, time * 1000, sender)
|
||||
|
||||
val contentIntent =
|
||||
Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() }
|
||||
@@ -208,41 +220,198 @@ object NotificationUtils {
|
||||
applicationContext,
|
||||
notId,
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
// Build the notification
|
||||
val builderPublic =
|
||||
NotificationCompat
|
||||
.Builder(
|
||||
applicationContext,
|
||||
channelId,
|
||||
).setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(messageTitle)
|
||||
.Builder(applicationContext, channelId)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(senderName)
|
||||
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
.setLargeIcon(bitmap)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(time * 1000)
|
||||
|
||||
// Build the notification
|
||||
val builder =
|
||||
NotificationCompat
|
||||
.Builder(
|
||||
applicationContext,
|
||||
channelId,
|
||||
).setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(messageBody)
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
.Builder(applicationContext, channelId)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setLargeIcon(bitmap)
|
||||
.setStyle(messagingStyle)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPublicVersion(builderPublic.build())
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setGroup(DM_GROUP_KEY)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(time * 1000)
|
||||
|
||||
// Direct Reply action
|
||||
if (accountNpub != null && chatroomMembers != null) {
|
||||
val remoteInput =
|
||||
RemoteInput
|
||||
.Builder(KEY_REPLY_TEXT)
|
||||
.setLabel(stringRes(applicationContext, R.string.app_notification_reply_label))
|
||||
.build()
|
||||
|
||||
val replyIntent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
action = REPLY_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_ACCOUNT_NPUB, accountNpub)
|
||||
putExtra(KEY_CHATROOM_MEMBERS, chatroomMembers)
|
||||
}
|
||||
|
||||
val replyPendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
notId,
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val replyAction =
|
||||
NotificationCompat.Action
|
||||
.Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.build()
|
||||
|
||||
builder.addAction(replyAction)
|
||||
}
|
||||
|
||||
// Mark as Read action
|
||||
val markReadIntent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
action = MARK_READ_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
}
|
||||
|
||||
val markReadPendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
notId + 1,
|
||||
markReadIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val markReadAction =
|
||||
NotificationCompat.Action
|
||||
.Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||
.build()
|
||||
|
||||
builder.addAction(markReadAction)
|
||||
|
||||
notify(notId, builder.build())
|
||||
|
||||
// Group summary notification
|
||||
sendGroupSummary(channelId, DM_GROUP_KEY, DM_SUMMARY_ID, stringRes(applicationContext, R.string.app_notification_dms_summary), applicationContext)
|
||||
}
|
||||
|
||||
private suspend fun NotificationManager.sendNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
time: Long,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
notificationGroupKey: String,
|
||||
category: String,
|
||||
summaryId: Int,
|
||||
summaryText: String,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
val notId = id.hashCode()
|
||||
|
||||
if (isDuplicate(notId)) return
|
||||
|
||||
val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) }
|
||||
|
||||
val contentIntent =
|
||||
Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() }
|
||||
|
||||
val contentPendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
notId,
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val builderPublic =
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, channelId)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
|
||||
.setLargeIcon(bitmap)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(time * 1000)
|
||||
|
||||
val builder =
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, channelId)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(messageBody)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(messageBody))
|
||||
.setLargeIcon(bitmap)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPublicVersion(builderPublic.build())
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(category)
|
||||
.setGroup(notificationGroupKey)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(time * 1000)
|
||||
|
||||
notify(notId, builder.build())
|
||||
|
||||
sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext)
|
||||
}
|
||||
|
||||
private fun NotificationManager.sendGroupSummary(
|
||||
channelId: String,
|
||||
groupKey: String,
|
||||
summaryId: Int,
|
||||
summaryText: String,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId }
|
||||
|
||||
if (activeCount < 2) return
|
||||
|
||||
val summaryBuilder =
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, channelId)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setGroup(groupKey)
|
||||
.setGroupSummary(true)
|
||||
.setAutoCancel(true)
|
||||
.setStyle(
|
||||
NotificationCompat
|
||||
.InboxStyle()
|
||||
.setSummaryText(summaryText),
|
||||
)
|
||||
|
||||
notify(summaryId, summaryBuilder.build())
|
||||
}
|
||||
|
||||
private fun NotificationManager.isDuplicate(notId: Int): Boolean {
|
||||
val notifications: Array<StatusBarNotification> = activeNotifications
|
||||
for (notification in notifications) {
|
||||
if (notification.id == notId) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Cancels all notifications. */
|
||||
|
||||
+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),
|
||||
)
|
||||
}
|
||||
|
||||
+30
-51
@@ -32,12 +32,9 @@ import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PictureInPicture
|
||||
import androidx.compose.material.icons.filled.SaveAlt
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -45,9 +42,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
@@ -134,61 +133,41 @@ fun OverflowMenuButton(
|
||||
modifier = Size20Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded.value,
|
||||
onDismissRequest = { menuExpanded.value = false },
|
||||
containerColor = Color.Black.copy(alpha = 0.85f),
|
||||
if (menuExpanded.value) {
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.playback_actions_dialog_title),
|
||||
onDismiss = { menuExpanded.value = false },
|
||||
) {
|
||||
if (showShare) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.share_or_save), color = Color.White) },
|
||||
onClick = {
|
||||
M3ActionSection {
|
||||
if (showShare) {
|
||||
M3ActionRow(
|
||||
icon = Icons.Default.Share,
|
||||
text = stringRes(R.string.share_or_save),
|
||||
) {
|
||||
menuExpanded.value = false
|
||||
onShareClick()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Share,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showSave) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.download_to_phone), color = Color.White) },
|
||||
onClick = {
|
||||
}
|
||||
}
|
||||
if (showSave) {
|
||||
M3ActionRow(
|
||||
icon = Icons.Default.SaveAlt,
|
||||
text = stringRes(R.string.download_to_phone),
|
||||
) {
|
||||
menuExpanded.value = false
|
||||
onSaveClick()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.SaveAlt,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showPip) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) },
|
||||
onClick = {
|
||||
}
|
||||
}
|
||||
if (showPip) {
|
||||
M3ActionRow(
|
||||
icon = Icons.Default.PictureInPicture,
|
||||
text = stringRes(R.string.picture_in_picture),
|
||||
) {
|
||||
menuExpanded.value = false
|
||||
onPipClick()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.PictureInPicture,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-5
@@ -20,11 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
|
||||
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
@@ -38,7 +40,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
@@ -65,7 +67,7 @@ val NotificationsPerKeyKinds =
|
||||
ChannelMessageEvent.KIND,
|
||||
EphemeralChatEvent.KIND,
|
||||
BadgeAwardEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
ZapPollEvent.KIND,
|
||||
PollEvent.KIND,
|
||||
PollResponseEvent.KIND,
|
||||
PublicMessageEvent.KIND,
|
||||
@@ -85,6 +87,12 @@ val NotificationsPerKeyKinds2 =
|
||||
InteractiveStorySceneEvent.KIND,
|
||||
)
|
||||
|
||||
val NotificationsPerKeyKinds3 =
|
||||
listOf(
|
||||
AttestationRequestEvent.KIND,
|
||||
AttestorRecommendationEvent.KIND,
|
||||
)
|
||||
|
||||
fun filterSummaryNotificationsToPubkey(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey?,
|
||||
@@ -130,7 +138,17 @@ fun filterNotificationsToPubkey(
|
||||
Filter(
|
||||
kinds = NotificationsPerKeyKinds2,
|
||||
tags = mapOf("p" to listOf(pubkey)),
|
||||
limit = 500,
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = NotificationsPerKeyKinds3,
|
||||
tags = mapOf("p" to listOf(pubkey)),
|
||||
limit = 10,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
@@ -171,7 +189,17 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays(
|
||||
Filter(
|
||||
kinds = NotificationsPerKeyKinds2,
|
||||
tags = mapOf("p" to listOf(pubkey)),
|
||||
limit = 20,
|
||||
limit = 10,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = NotificationsPerKeyKinds3,
|
||||
tags = mapOf("p" to listOf(pubkey)),
|
||||
limit = 2,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
|
||||
+3
-3
@@ -51,14 +51,14 @@ class EventWatcherSubAssembler(
|
||||
}
|
||||
|
||||
override fun updateFilter(
|
||||
key: List<EventFinderQueryState>,
|
||||
keys: List<EventFinderQueryState>,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>? {
|
||||
if (key.isEmpty()) {
|
||||
if (keys.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
lastNotesOnFilter = key.map { it.note }
|
||||
lastNotesOnFilter = keys.map { it.note }
|
||||
|
||||
return groupByRelayPresence(lastNotesOnFilter, latestEOSEs)
|
||||
.map { group ->
|
||||
|
||||
+4
-2
@@ -22,7 +22,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
@@ -45,8 +46,9 @@ val RepliesAndReactionsToAddressesKinds1 =
|
||||
GenericRepostEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
ZapPollEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
AttestationEvent.KIND,
|
||||
)
|
||||
|
||||
val PostsAndChatMessagesToAddresses =
|
||||
|
||||
+4
-2
@@ -22,8 +22,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
|
||||
@@ -53,6 +54,7 @@ val RepliesAndReactionsKinds =
|
||||
OtsEvent.KIND,
|
||||
TextNoteModificationEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
AttestationEvent.KIND,
|
||||
)
|
||||
|
||||
val RepliesAndReactionsKinds2 =
|
||||
@@ -63,7 +65,7 @@ val RepliesAndReactionsKinds2 =
|
||||
TorrentCommentEvent.KIND,
|
||||
GitReplyEvent.KIND,
|
||||
PollResponseEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
ZapPollEvent.KIND,
|
||||
)
|
||||
|
||||
fun filterRepliesAndReactionsToNotes(
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
|
||||
fun filterNWCPaymentsFromRequests(
|
||||
serviceKeys: Set<HexKey>,
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
||||
|
||||
@SuppressLint("StateFlowValueCalledInComposition")
|
||||
@Composable
|
||||
|
||||
+6
-39
@@ -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(
|
||||
@@ -502,9 +466,12 @@ fun observeUserStatuses(
|
||||
|
||||
val flow =
|
||||
remember(user) {
|
||||
user.statusState().statuses.onStart {
|
||||
user.statusState().removeExpired()
|
||||
}
|
||||
user
|
||||
.statusState()
|
||||
.statuses
|
||||
.onStart {
|
||||
user.statusState().removeExpired()
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
@SuppressLint("StateFlowValueCalledInComposition")
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
|
||||
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
@@ -64,7 +64,7 @@ val SearchPostsByTextKinds1 =
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
ZapPollEvent.KIND,
|
||||
ChannelCreateEvent.KIND,
|
||||
)
|
||||
|
||||
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
* 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.service.uploads
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMuxer
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
data class StrippingResult(
|
||||
val uri: Uri,
|
||||
val stripped: Boolean,
|
||||
)
|
||||
|
||||
object MetadataStripper {
|
||||
private const val DEFAULT_REMUX_BUFFER_SIZE = 8 * 1024 * 1024
|
||||
|
||||
private val SENSITIVE_EXIF_TAGS =
|
||||
arrayOf(
|
||||
ExifInterface.TAG_GPS_LATITUDE,
|
||||
ExifInterface.TAG_GPS_LATITUDE_REF,
|
||||
ExifInterface.TAG_GPS_LONGITUDE,
|
||||
ExifInterface.TAG_GPS_LONGITUDE_REF,
|
||||
ExifInterface.TAG_GPS_ALTITUDE,
|
||||
ExifInterface.TAG_GPS_ALTITUDE_REF,
|
||||
ExifInterface.TAG_GPS_TIMESTAMP,
|
||||
ExifInterface.TAG_GPS_DATESTAMP,
|
||||
ExifInterface.TAG_GPS_PROCESSING_METHOD,
|
||||
ExifInterface.TAG_GPS_AREA_INFORMATION,
|
||||
ExifInterface.TAG_GPS_SPEED,
|
||||
ExifInterface.TAG_GPS_SPEED_REF,
|
||||
ExifInterface.TAG_GPS_TRACK,
|
||||
ExifInterface.TAG_GPS_TRACK_REF,
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION,
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION_REF,
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE,
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE_REF,
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE,
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE_REF,
|
||||
ExifInterface.TAG_GPS_DEST_BEARING,
|
||||
ExifInterface.TAG_GPS_DEST_BEARING_REF,
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE,
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE_REF,
|
||||
ExifInterface.TAG_GPS_MAP_DATUM,
|
||||
ExifInterface.TAG_GPS_DOP,
|
||||
ExifInterface.TAG_GPS_MEASURE_MODE,
|
||||
ExifInterface.TAG_GPS_SATELLITES,
|
||||
ExifInterface.TAG_GPS_STATUS,
|
||||
ExifInterface.TAG_GPS_VERSION_ID,
|
||||
ExifInterface.TAG_MAKE,
|
||||
ExifInterface.TAG_MODEL,
|
||||
ExifInterface.TAG_SOFTWARE,
|
||||
ExifInterface.TAG_ARTIST,
|
||||
ExifInterface.TAG_COPYRIGHT,
|
||||
ExifInterface.TAG_CAMERA_OWNER_NAME,
|
||||
ExifInterface.TAG_BODY_SERIAL_NUMBER,
|
||||
ExifInterface.TAG_LENS_SERIAL_NUMBER,
|
||||
ExifInterface.TAG_LENS_MAKE,
|
||||
ExifInterface.TAG_LENS_MODEL,
|
||||
ExifInterface.TAG_DATETIME,
|
||||
ExifInterface.TAG_DATETIME_ORIGINAL,
|
||||
ExifInterface.TAG_DATETIME_DIGITIZED,
|
||||
ExifInterface.TAG_OFFSET_TIME,
|
||||
ExifInterface.TAG_OFFSET_TIME_ORIGINAL,
|
||||
ExifInterface.TAG_OFFSET_TIME_DIGITIZED,
|
||||
ExifInterface.TAG_IMAGE_UNIQUE_ID,
|
||||
ExifInterface.TAG_USER_COMMENT,
|
||||
)
|
||||
|
||||
private fun extractorToCodecFlags(sampleFlags: Int): Int {
|
||||
var flags = 0
|
||||
if (sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0) {
|
||||
flags = flags or MediaCodec.BUFFER_FLAG_KEY_FRAME
|
||||
}
|
||||
if (sampleFlags and MediaExtractor.SAMPLE_FLAG_PARTIAL_FRAME != 0) {
|
||||
flags = flags or MediaCodec.BUFFER_FLAG_PARTIAL_FRAME
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
private fun remuxTracks(
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
outputFile: File,
|
||||
preStart: (MediaMuxer, MediaExtractor, Context, Uri) -> Unit = { _, _, _, _ -> },
|
||||
): Boolean {
|
||||
val extractor = MediaExtractor()
|
||||
var muxer: MediaMuxer? = null
|
||||
var muxerStarted = false
|
||||
var succeeded = false
|
||||
try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
|
||||
if (extractor.trackCount == 0) return false
|
||||
|
||||
// Note: MediaMuxer may still write a creation timestamp and encoder info into
|
||||
// the new container. This is not controllable via the Android API and is a
|
||||
// known residual privacy limitation of the remux approach.
|
||||
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
|
||||
val trackIndexMap = mutableMapOf<Int, Int>()
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
trackIndexMap[i] = muxer.addTrack(format)
|
||||
extractor.selectTrack(i)
|
||||
}
|
||||
|
||||
preStart(muxer, extractor, context, uri)
|
||||
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
|
||||
// Size buffer to the largest track's KEY_MAX_INPUT_SIZE (covers 4K keyframes),
|
||||
// falling back to 8MB if the format doesn't report it.
|
||||
var maxInputSize = DEFAULT_REMUX_BUFFER_SIZE
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val fmt = extractor.getTrackFormat(i)
|
||||
if (fmt.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||
maxInputSize = maxOf(maxInputSize, fmt.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
|
||||
}
|
||||
}
|
||||
val buffer = ByteBuffer.allocateDirect(maxInputSize)
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
|
||||
while (true) {
|
||||
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||
if (sampleSize < 0) break
|
||||
|
||||
val outputTrack = trackIndexMap[extractor.sampleTrackIndex] ?: break
|
||||
|
||||
bufferInfo.offset = 0
|
||||
bufferInfo.size = sampleSize
|
||||
bufferInfo.presentationTimeUs = extractor.sampleTime
|
||||
bufferInfo.flags = extractorToCodecFlags(extractor.sampleFlags)
|
||||
|
||||
muxer.writeSampleData(outputTrack, buffer, bufferInfo)
|
||||
extractor.advance()
|
||||
}
|
||||
|
||||
muxer.stop()
|
||||
muxerStarted = false
|
||||
succeeded = true
|
||||
} finally {
|
||||
if (muxerStarted) runCatching { muxer?.stop() }
|
||||
muxer?.release()
|
||||
extractor.release()
|
||||
if (!succeeded && !outputFile.delete()) {
|
||||
Log.w("MetadataStripper", "Failed to delete temp file: ${outputFile.absolutePath}")
|
||||
}
|
||||
}
|
||||
return succeeded
|
||||
}
|
||||
|
||||
fun stripImageMetadata(
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
): StrippingResult {
|
||||
var tempFile: File? = null
|
||||
return try {
|
||||
val mimeType = context.contentResolver.getType(uri) ?: ""
|
||||
val extension =
|
||||
when {
|
||||
mimeType.endsWith("jpeg", ignoreCase = true) ||
|
||||
mimeType.endsWith("jpg", ignoreCase = true) -> ".jpg"
|
||||
|
||||
mimeType.endsWith("png", ignoreCase = true) -> ".png"
|
||||
|
||||
mimeType.endsWith("webp", ignoreCase = true) -> ".webp"
|
||||
|
||||
else -> ".tmp"
|
||||
}
|
||||
tempFile = File.createTempFile("stripped_", extension, context.cacheDir)
|
||||
|
||||
val inputStream =
|
||||
context.contentResolver.openInputStream(uri)
|
||||
?: run {
|
||||
if (!tempFile.delete()) {
|
||||
Log.w("MetadataStripper", "Failed to delete temp file: ${tempFile.absolutePath}")
|
||||
}
|
||||
return StrippingResult(uri, false)
|
||||
}
|
||||
inputStream.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
val exif = ExifInterface(tempFile.absolutePath)
|
||||
for (tag in SENSITIVE_EXIF_TAGS) {
|
||||
exif.setAttribute(tag, null)
|
||||
}
|
||||
exif.saveAttributes()
|
||||
|
||||
Log.d("MetadataStripper", "Stripped EXIF metadata from image")
|
||||
StrippingResult(tempFile.toUri(), true)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
if (tempFile?.delete() == false) {
|
||||
Log.w("MetadataStripper", "Failed to delete temp file: ${tempFile.absolutePath}")
|
||||
}
|
||||
Log.d("MetadataStripper", "Failed to strip image metadata: ${e.message}")
|
||||
StrippingResult(uri, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun stripVideoMetadata(
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
): StrippingResult {
|
||||
return try {
|
||||
val tempOutputFile = File.createTempFile("stripped_video_", ".mp4", context.cacheDir)
|
||||
|
||||
val succeeded =
|
||||
remuxTracks(uri, context, tempOutputFile) { muxer, _, ctx, sourceUri ->
|
||||
// Rotation is a container-level property not included in track formats;
|
||||
// read it explicitly and reapply so the output plays back with the correct orientation.
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(ctx, sourceUri)
|
||||
val rotation =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.toIntOrNull() ?: 0
|
||||
if (rotation != 0) muxer.setOrientationHint(rotation)
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
|
||||
if (!succeeded) return StrippingResult(uri, false)
|
||||
|
||||
Log.d("MetadataStripper", "Stripped metadata from video")
|
||||
StrippingResult(tempOutputFile.toUri(), true)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d("MetadataStripper", "Failed to strip video metadata: ${e.message}")
|
||||
StrippingResult(uri, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun stripAudioMetadata(
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
): StrippingResult {
|
||||
return try {
|
||||
// Verify the primary track is AAC/MP4A before remuxing
|
||||
val extractor = MediaExtractor()
|
||||
try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
if (extractor.trackCount == 0) return StrippingResult(uri, false)
|
||||
val primaryMime = extractor.getTrackFormat(0).getString(MediaFormat.KEY_MIME) ?: ""
|
||||
if (!primaryMime.contains("mp4a") && !primaryMime.contains("aac")) {
|
||||
return StrippingResult(uri, false)
|
||||
}
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
|
||||
val tempOutputFile = File.createTempFile("stripped_audio_", ".m4a", context.cacheDir)
|
||||
|
||||
val succeeded = remuxTracks(uri, context, tempOutputFile)
|
||||
|
||||
if (!succeeded) return StrippingResult(uri, false)
|
||||
|
||||
Log.d("MetadataStripper", "Stripped metadata from audio")
|
||||
StrippingResult(tempOutputFile.toUri(), true)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d("MetadataStripper", "Failed to strip audio metadata: ${e.message}")
|
||||
StrippingResult(uri, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun stripMp3Metadata(
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
): StrippingResult {
|
||||
var tempInputFile: File? = null
|
||||
return try {
|
||||
tempInputFile = File.createTempFile("mp3_input_", ".mp3", context.cacheDir)
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tempInputFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: run {
|
||||
if (!tempInputFile.delete()) {
|
||||
Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}")
|
||||
}
|
||||
return StrippingResult(uri, false)
|
||||
}
|
||||
|
||||
val fileSize = tempInputFile.length()
|
||||
var startOffset = 0L
|
||||
var endOffset = fileSize
|
||||
|
||||
// Read first 10 bytes to check for ID3v2 header
|
||||
val header = ByteArray(10)
|
||||
tempInputFile.inputStream().use { it.read(header) }
|
||||
|
||||
if (fileSize >= 10 &&
|
||||
header[0] == 'I'.code.toByte() &&
|
||||
header[1] == 'D'.code.toByte() &&
|
||||
header[2] == '3'.code.toByte()
|
||||
) {
|
||||
val size =
|
||||
(header[6].toInt() and 0x7F shl 21) or
|
||||
(header[7].toInt() and 0x7F shl 14) or
|
||||
(header[8].toInt() and 0x7F shl 7) or
|
||||
(header[9].toInt() and 0x7F)
|
||||
startOffset = 10L + size
|
||||
}
|
||||
|
||||
// Read last 128 bytes to check for ID3v1 tag
|
||||
if (endOffset - startOffset >= 128) {
|
||||
val tail = ByteArray(128)
|
||||
java.io.RandomAccessFile(tempInputFile, "r").use { raf ->
|
||||
raf.seek(endOffset - 128)
|
||||
raf.readFully(tail)
|
||||
}
|
||||
if (tail[0] == 'T'.code.toByte() &&
|
||||
tail[1] == 'A'.code.toByte() &&
|
||||
tail[2] == 'G'.code.toByte()
|
||||
) {
|
||||
endOffset -= 128
|
||||
}
|
||||
}
|
||||
|
||||
if (startOffset == 0L && endOffset == fileSize) {
|
||||
if (!tempInputFile.delete()) {
|
||||
Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}")
|
||||
}
|
||||
tempInputFile = null
|
||||
return StrippingResult(uri, true) // no tags found, already clean
|
||||
}
|
||||
|
||||
val tempOutputFile = File.createTempFile("stripped_mp3_", ".mp3", context.cacheDir)
|
||||
java.io.RandomAccessFile(tempInputFile, "r").use { raf ->
|
||||
raf.seek(startOffset)
|
||||
tempOutputFile.outputStream().use { output ->
|
||||
val buffer = ByteArray(8192)
|
||||
var remaining = endOffset - startOffset
|
||||
while (remaining > 0) {
|
||||
val toRead = minOf(buffer.size.toLong(), remaining).toInt()
|
||||
val read = raf.read(buffer, 0, toRead)
|
||||
if (read <= 0) break
|
||||
output.write(buffer, 0, read)
|
||||
remaining -= read
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tempInputFile.delete()) {
|
||||
Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}")
|
||||
}
|
||||
tempInputFile = null
|
||||
|
||||
Log.d("MetadataStripper", "Stripped ID3 tags from MP3")
|
||||
StrippingResult(tempOutputFile.toUri(), true)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
if (tempInputFile?.delete() == false) {
|
||||
Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}")
|
||||
}
|
||||
Log.d("MetadataStripper", "Failed to strip MP3 metadata: ${e.message}")
|
||||
StrippingResult(uri, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun strip(
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
): StrippingResult =
|
||||
when {
|
||||
mimeType?.startsWith("image/", ignoreCase = true) == true -> stripImageMetadata(uri, context)
|
||||
mimeType?.startsWith("video/", ignoreCase = true) == true -> stripVideoMetadata(uri, context)
|
||||
mimeType?.equals("audio/mpeg", ignoreCase = true) == true -> stripMp3Metadata(uri, context)
|
||||
mimeType?.startsWith("audio/", ignoreCase = true) == true -> stripAudioMetadata(uri, context)
|
||||
else -> StrippingResult(uri, false)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ class MultiOrchestrator(
|
||||
account: Account,
|
||||
context: Context,
|
||||
useH265: Boolean = false,
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
): Result {
|
||||
coroutineScope {
|
||||
val jobs =
|
||||
@@ -74,6 +76,8 @@ class MultiOrchestrator(
|
||||
account,
|
||||
context,
|
||||
useH265,
|
||||
stripMetadata,
|
||||
onStrippingFailed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -93,6 +97,8 @@ class MultiOrchestrator(
|
||||
account: Account,
|
||||
context: Context,
|
||||
useH265: Boolean = false,
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
): Result {
|
||||
coroutineScope {
|
||||
val jobs =
|
||||
@@ -109,6 +115,8 @@ class MultiOrchestrator(
|
||||
account,
|
||||
context,
|
||||
useH265,
|
||||
stripMetadata,
|
||||
onStrippingFailed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.service.uploads
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
data class ConfirmationCallbacks(
|
||||
val onConfirm: () -> Unit,
|
||||
val onCancel: () -> Unit,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class SuspendableConfirmation {
|
||||
var state by mutableStateOf<ConfirmationCallbacks?>(null)
|
||||
private set
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun awaitConfirmation(): Boolean =
|
||||
mutex.withLock {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
state =
|
||||
ConfirmationCallbacks(
|
||||
onConfirm = {
|
||||
state = null
|
||||
continuation.resume(true)
|
||||
},
|
||||
onCancel = {
|
||||
state = null
|
||||
continuation.resume(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
-4
@@ -292,6 +292,38 @@ class UploadOrchestrator {
|
||||
MediaCompressorResult(uri, mimeType, null)
|
||||
}
|
||||
|
||||
private suspend fun stripAfterCompression(
|
||||
originalUri: Uri,
|
||||
compressed: MediaCompressorResult,
|
||||
mimeType: String?,
|
||||
compressionQuality: CompressorQuality,
|
||||
stripMetadata: Boolean,
|
||||
onStrippingFailed: suspend () -> Boolean,
|
||||
context: Context,
|
||||
): Uri? {
|
||||
if (!stripMetadata) return compressed.uri
|
||||
|
||||
val effectiveMimeType = compressed.contentType ?: mimeType
|
||||
val isVideo = effectiveMimeType?.startsWith("video/", ignoreCase = true) == true
|
||||
val compressionRequested = compressionQuality != CompressorQuality.UNCOMPRESSED
|
||||
val compressionApplied = compressionRequested && compressed.uri != originalUri
|
||||
|
||||
val strippingResult =
|
||||
if (isVideo && compressionApplied) {
|
||||
// Compression was requested and actually applied to a video;
|
||||
// assume it stripped metadata successfully.
|
||||
StrippingResult(compressed.uri, true)
|
||||
} else {
|
||||
MetadataStripper.strip(compressed.uri, effectiveMimeType, context.applicationContext)
|
||||
}
|
||||
|
||||
if (!strippingResult.stripped) {
|
||||
if (!onStrippingFailed()) return null
|
||||
}
|
||||
|
||||
return strippingResult.uri
|
||||
}
|
||||
|
||||
suspend fun upload(
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
@@ -302,13 +334,19 @@ class UploadOrchestrator {
|
||||
account: Account,
|
||||
context: Context,
|
||||
useH265: Boolean = false,
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
): UploadingFinalState {
|
||||
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
|
||||
|
||||
val finalUri =
|
||||
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
|
||||
?: return error(R.string.upload_cancelled)
|
||||
|
||||
return when (server.type) {
|
||||
ServerType.NIP95 -> uploadNIP95(compressed.uri, compressed.contentType, null, null, context)
|
||||
ServerType.NIP96 -> uploadNIP96(compressed.uri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
|
||||
ServerType.Blossom -> uploadBlossom(compressed.uri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
|
||||
ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context)
|
||||
ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
|
||||
ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,9 +361,16 @@ class UploadOrchestrator {
|
||||
account: Account,
|
||||
context: Context,
|
||||
useH265: Boolean = false,
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
): UploadingFinalState {
|
||||
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
|
||||
val encrypted = EncryptFiles().encryptFile(context, compressed.uri, encrypt)
|
||||
|
||||
val finalUri =
|
||||
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
|
||||
?: return error(R.string.upload_cancelled)
|
||||
|
||||
val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt)
|
||||
|
||||
return when (server.type) {
|
||||
ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,8 @@ fun EditPostView(
|
||||
postViewModel.load(edit, versionLookingAt)
|
||||
}
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties =
|
||||
@@ -265,8 +267,8 @@ fun EditPostView(
|
||||
it,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
isUploading = postViewModel.mediaUploadTracker.isUploading,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
|
||||
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context)
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata ->
|
||||
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context, stripMetadata)
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||
},
|
||||
onDelete = postViewModel::deleteMediaToUpload,
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
|
||||
@@ -90,6 +91,9 @@ open class EditPostViewModel : ViewModel() {
|
||||
// Images and Videos
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// Codec selection: false = H264, true = H265
|
||||
var useH265Codec by mutableStateOf(false)
|
||||
|
||||
@@ -161,8 +165,9 @@ open class EditPostViewModel : ViewModel() {
|
||||
server: ServerName,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
stripMetadata: Boolean = true,
|
||||
) = try {
|
||||
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context)
|
||||
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context, stripMetadata)
|
||||
} catch (e: SignerExceptions.ReadOnlyException) {
|
||||
onError(
|
||||
stringRes(context, R.string.read_only_user),
|
||||
@@ -178,6 +183,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
server: ServerName,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
stripMetadata: Boolean = true,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val myAccount = account
|
||||
@@ -194,6 +200,8 @@ open class EditPostViewModel : ViewModel() {
|
||||
myAccount,
|
||||
context,
|
||||
useH265Codec,
|
||||
stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
@@ -59,12 +60,18 @@ open class NewMediaModel : ViewModel() {
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
var onceUploaded: () -> Unit = {}
|
||||
|
||||
// Stripping failure dialog
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
|
||||
var mediaQualitySlider by mutableIntStateOf(1)
|
||||
|
||||
// Codec selection: false = H264, true = H265
|
||||
var useH265Codec by mutableStateOf(false)
|
||||
|
||||
// Strip location and sensitive metadata from files before upload
|
||||
var stripMetadata by mutableStateOf(true)
|
||||
|
||||
open fun load(
|
||||
account: Account,
|
||||
uris: ImmutableList<SelectedMedia>,
|
||||
@@ -73,6 +80,7 @@ open class NewMediaModel : ViewModel() {
|
||||
this.account = account
|
||||
this.multiOrchestrator = MultiOrchestrator(uris)
|
||||
this.selectedServer = defaultServer()
|
||||
this.stripMetadata = account.settings.stripLocationOnUpload
|
||||
}
|
||||
|
||||
fun isImage(
|
||||
@@ -115,6 +123,8 @@ open class NewMediaModel : ViewModel() {
|
||||
myAccount,
|
||||
context,
|
||||
useH265Codec,
|
||||
stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
|
||||
@@ -89,6 +89,8 @@ fun NewMediaView(
|
||||
postViewModel.load(account, uris)
|
||||
}
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties =
|
||||
@@ -112,6 +114,7 @@ fun NewMediaView(
|
||||
postViewModel.selectedServer?.let {
|
||||
account.settings.changeDefaultFileServer(it)
|
||||
}
|
||||
account.settings.changeStripLocationOnUpload(postViewModel.stripMetadata)
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -269,4 +272,15 @@ fun ImageVideoPost(
|
||||
onCheckedChange = { postViewModel.useH265Codec = it },
|
||||
)
|
||||
}
|
||||
|
||||
SettingSwitchItem(
|
||||
title = R.string.strip_metadata_label,
|
||||
description = R.string.strip_metadata_description,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
checked = postViewModel.stripMetadata,
|
||||
onCheckedChange = { postViewModel.stripMetadata = it },
|
||||
)
|
||||
}
|
||||
|
||||
+23
-1
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
@@ -210,7 +211,28 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
): String? {
|
||||
isUploadingImageForPicture = true
|
||||
|
||||
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
val strippingResult =
|
||||
if (account.settings.stripLocationOnUpload) {
|
||||
MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val sourceUri =
|
||||
if (account.settings.stripLocationOnUpload &&
|
||||
strippingResult != null &&
|
||||
!strippingResult.stripped
|
||||
) {
|
||||
onError(
|
||||
stringRes(context, R.string.metadata_strip_failed_title),
|
||||
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
|
||||
)
|
||||
return null
|
||||
} else {
|
||||
strippingResult?.uri ?: galleryUri.uri
|
||||
}
|
||||
|
||||
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
|
||||
return try {
|
||||
val result =
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.uploads.ConfirmationCallbacks
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun StrippingFailureDialog(confirmation: SuspendableConfirmation) {
|
||||
val dialogState = confirmation.state ?: return
|
||||
StrippingFailureDialog(dialogState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StrippingFailureDialog(dialogState: ConfirmationCallbacks) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { dialogState.onCancel() },
|
||||
title = { Text(stringRes(R.string.metadata_strip_failed_title)) },
|
||||
text = { Text(stringRes(R.string.metadata_strip_failed_body)) },
|
||||
confirmButton = {
|
||||
Button(onClick = { dialogState.onConfirm() }) {
|
||||
Text(stringRes(R.string.metadata_strip_failed_upload))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = { dialogState.onCancel() }) {
|
||||
Text(stringRes(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -53,7 +53,7 @@ class BlossomServersViewModel : ViewModel() {
|
||||
fun refresh() {
|
||||
isModified = false
|
||||
_fileServers.update {
|
||||
val obtainedFileServers = obtainFileServers() ?: emptyList()
|
||||
val obtainedFileServers = obtainFileServers()
|
||||
obtainedFileServers.mapNotNull { serverUrl ->
|
||||
try {
|
||||
ServerName(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.alpha
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
|
||||
@Composable
|
||||
fun M3ActionDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(vertical = 20.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun M3ActionSection(content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
) {
|
||||
Column {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun M3ActionRow(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
isDestructive: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tint =
|
||||
if (isDestructive) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val textColor =
|
||||
if (isDestructive) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
val alpha = if (enabled) 1f else 0.38f
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(alpha)
|
||||
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = tint,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = Font14SP,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -119,11 +119,12 @@ object ShareHelper {
|
||||
bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header)
|
||||
|
||||
// MP4/MOV alternative: moov, mdat, or free at offset 4
|
||||
bytesRead >= 8 && (
|
||||
matchesMagicNumbers(header, 4, MOV_MOOV) ||
|
||||
matchesMagicNumbers(header, 4, MOV_MDAT) ||
|
||||
matchesMagicNumbers(header, 4, MOV_FREE)
|
||||
) -> "mp4"
|
||||
bytesRead >= 8 &&
|
||||
(
|
||||
matchesMagicNumbers(header, 4, MOV_MOOV) ||
|
||||
matchesMagicNumbers(header, 4, MOV_MDAT) ||
|
||||
matchesMagicNumbers(header, 4, MOV_FREE)
|
||||
) -> "mp4"
|
||||
|
||||
else -> defaultExtension
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -34,7 +33,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -61,7 +59,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@@ -159,13 +156,11 @@ private fun BaseTextSpinner(
|
||||
)
|
||||
}
|
||||
|
||||
if (optionsShowing) {
|
||||
options.isNotEmpty().also {
|
||||
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
|
||||
currentText = options[it].title
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
}
|
||||
if (optionsShowing && options.isNotEmpty()) {
|
||||
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
|
||||
currentText = options[it].title
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,14 +206,14 @@ fun <T> SpinnerSelectionDialog(
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
border = BorderStroke(0.25.dp, Color.LightGray),
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
LazyColumn {
|
||||
title?.let {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp, 16.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
@@ -227,7 +222,6 @@ fun <T> SpinnerSelectionDialog(
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
itemsIndexed(options) { index, item ->
|
||||
@@ -237,7 +231,7 @@ fun <T> SpinnerSelectionDialog(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(index) }
|
||||
.padding(16.dp, 16.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
contentDescription = optionsOfLabel
|
||||
@@ -245,9 +239,6 @@ fun <T> SpinnerSelectionDialog(
|
||||
) {
|
||||
Column { onRenderItem(item) }
|
||||
}
|
||||
if (index < options.lastIndex) {
|
||||
HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -47,27 +46,6 @@ import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier
|
||||
|
||||
@Composable
|
||||
private fun CopyToClipboard(
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
content: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded.value,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_url_to_clipboard)) },
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(content))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun UrlPreviewCard(
|
||||
@@ -81,11 +59,20 @@ fun UrlPreviewCard(
|
||||
}
|
||||
|
||||
if (popupExpanded.value) {
|
||||
CopyToClipboard(
|
||||
popupExpanded = popupExpanded,
|
||||
content = url,
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.link_actions_dialog_title),
|
||||
onDismiss = { popupExpanded.value = false },
|
||||
) {
|
||||
popupExpanded.value = false
|
||||
M3ActionSection {
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.ContentCopy,
|
||||
text = stringRes(R.string.copy_url_to_clipboard),
|
||||
) {
|
||||
clipboardManager.setText(AnnotatedString(url))
|
||||
popupExpanded.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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.components
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
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
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
|
||||
private const val PAGER_ZONE_FRACTION = 0.5f
|
||||
|
||||
fun Modifier.zonedDrawerSwipe(
|
||||
pagerState: PagerState,
|
||||
openDrawer: () -> Unit,
|
||||
): Modifier =
|
||||
composed {
|
||||
var widthPx by remember { mutableFloatStateOf(1f) }
|
||||
var gestureStartX by remember { mutableFloatStateOf(0f) }
|
||||
var gestureStartPage by remember { mutableIntStateOf(0) }
|
||||
var drawerOpened by remember { mutableStateOf(false) }
|
||||
|
||||
val connection =
|
||||
remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset {
|
||||
if (source != NestedScrollSource.UserInput) return Offset.Zero
|
||||
if (drawerOpened) return Offset(available.x, 0f)
|
||||
|
||||
// Non-first pages in the drawer zone: intercept before the
|
||||
// pager consumes the delta to page backwards.
|
||||
if (available.x > 0f) {
|
||||
val wasOnFirstPage = gestureStartPage == 0
|
||||
val isInPagerZone = gestureStartX < widthPx * PAGER_ZONE_FRACTION
|
||||
|
||||
if (!wasOnFirstPage && !isInPagerZone) {
|
||||
drawerOpened = true
|
||||
openDrawer()
|
||||
return Offset(available.x, 0f)
|
||||
}
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset {
|
||||
if (source != NestedScrollSource.UserInput) return Offset.Zero
|
||||
if (drawerOpened) return Offset(available.x, 0f)
|
||||
|
||||
// First page: open drawer only with unconsumed right-swipe
|
||||
// so child LazyRows can scroll first.
|
||||
if (available.x > 0f && gestureStartPage == 0) {
|
||||
drawerOpened = true
|
||||
openDrawer()
|
||||
return Offset(available.x, 0f)
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this
|
||||
.onSizeChanged { widthPx = it.width.toFloat() }
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
gestureStartX = down.position.x
|
||||
gestureStartPage = pagerState.currentPage
|
||||
drawerOpened = false
|
||||
}
|
||||
}.nestedScroll(connection)
|
||||
}
|
||||
+88
-111
@@ -32,15 +32,16 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material.icons.outlined.Link
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -123,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.
|
||||
@@ -223,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)
|
||||
}
|
||||
@@ -771,119 +766,101 @@ fun ShareMediaAction(
|
||||
// Track if video is downloading - hoisted here to block menu dismiss during download
|
||||
val isDownloadingVideo = remember { mutableStateOf(false) }
|
||||
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded.value,
|
||||
onDismissRequest = { if (!isDownloadingVideo.value) onDismiss() },
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
if (popupExpanded.value) {
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.media_actions_dialog_title),
|
||||
onDismiss = { if (!isDownloadingVideo.value) onDismiss() },
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
if (videoUri != null && !videoUri.startsWith("file")) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_url_to_clipboard)) },
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(videoUri))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
postNostrUri?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_the_note_id_to_the_clipboard)) },
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
postNostrUri?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.add_media_to_gallery)) },
|
||||
onClick = {
|
||||
if (videoUri != null) {
|
||||
val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent
|
||||
if (n19 != null) {
|
||||
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType) // TODO Whole list or first?
|
||||
accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery)
|
||||
// Copy & Gallery section
|
||||
if ((videoUri != null && !videoUri.startsWith("file")) || postNostrUri != null) {
|
||||
M3ActionSection {
|
||||
if (videoUri != null && !videoUri.startsWith("file")) {
|
||||
M3ActionRow(icon = Icons.Outlined.Link, text = stringRes(R.string.copy_url_to_clipboard)) {
|
||||
clipboardManager.setText(AnnotatedString(videoUri))
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
content?.let {
|
||||
val context = LocalContext.current
|
||||
|
||||
when (content) {
|
||||
is MediaUrlImage -> {
|
||||
videoUri?.let {
|
||||
if (videoUri.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.share_image)) },
|
||||
onClick = {
|
||||
scope.launch { shareImageFile(context, videoUri, mimeType) }
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
postNostrUri?.let {
|
||||
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_the_note_id_to_the_clipboard)) {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
onDismiss()
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.Collections, text = stringRes(R.string.add_media_to_gallery)) {
|
||||
if (videoUri != null) {
|
||||
val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent
|
||||
if (n19 != null) {
|
||||
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType)
|
||||
accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery)
|
||||
}
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is MediaUrlVideo -> {
|
||||
videoUri?.let {
|
||||
if (videoUri.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringRes(R.string.share_video))
|
||||
if (isDownloadingVideo.value) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp)
|
||||
// Share section
|
||||
content?.let {
|
||||
val context = LocalContext.current
|
||||
|
||||
M3ActionSection {
|
||||
when (content) {
|
||||
is MediaUrlImage -> {
|
||||
videoUri?.let {
|
||||
if (videoUri.isNotEmpty()) {
|
||||
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_image)) {
|
||||
scope.launch { shareImageFile(context, videoUri, mimeType) }
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is MediaUrlVideo -> {
|
||||
videoUri?.let {
|
||||
if (videoUri.isNotEmpty()) {
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.Share,
|
||||
text = stringRes(R.string.share_video),
|
||||
enabled = !isDownloadingVideo.value,
|
||||
) {
|
||||
isDownloadingVideo.value = true
|
||||
scope.launch {
|
||||
shareVideoFile(
|
||||
context = context,
|
||||
videoUrl = videoUri,
|
||||
mimeType = mimeType,
|
||||
okHttpClient = { url ->
|
||||
accountViewModel.httpClientBuilder.okHttpClientForVideo(url)
|
||||
},
|
||||
onComplete = {
|
||||
isDownloadingVideo.value = false
|
||||
onDismiss()
|
||||
},
|
||||
onError = {
|
||||
isDownloadingVideo.value = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isDownloadingVideo.value,
|
||||
onClick = {
|
||||
isDownloadingVideo.value = true
|
||||
scope.launch {
|
||||
shareVideoFile(
|
||||
context = context,
|
||||
videoUrl = videoUri,
|
||||
mimeType = mimeType,
|
||||
okHttpClient = { url ->
|
||||
accountViewModel.httpClientBuilder.okHttpClientForVideo(url)
|
||||
},
|
||||
onComplete = {
|
||||
isDownloadingVideo.value = false
|
||||
onDismiss()
|
||||
},
|
||||
onError = {
|
||||
isDownloadingVideo.value = false
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is MediaLocalVideo -> {
|
||||
content.localFile?.let { localFile ->
|
||||
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_video)) {
|
||||
scope.launch { shareLocalVideoFile(context, localFile, mimeType) }
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> { /* No share option for other types */ }
|
||||
}
|
||||
}
|
||||
|
||||
is MediaLocalVideo -> {
|
||||
content.localFile?.let { localFile ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.share_video)) },
|
||||
onClick = {
|
||||
scope.launch { shareLocalVideoFile(context, localFile, mimeType) }
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> { /* No share option for other types */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,8 +37,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
|
||||
@@ -79,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
|
||||
@@ -108,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
|
||||
@@ -126,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
|
||||
@@ -142,7 +143,17 @@ fun AppNavigation(
|
||||
) {
|
||||
val nav = rememberNav()
|
||||
|
||||
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
|
||||
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
|
||||
val isTabPagerRoute =
|
||||
navBackStackEntry?.destination?.let { dest ->
|
||||
dest.hasRoute<Route.Home>() || dest.hasRoute<Route.Message>()
|
||||
} ?: false
|
||||
val drawerGesturesEnabled =
|
||||
!isTabPagerRoute ||
|
||||
nav.drawerState.isOpen ||
|
||||
nav.drawerState.targetValue != nav.drawerState.currentValue
|
||||
|
||||
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav, drawerGesturesEnabled) {
|
||||
NavHost(
|
||||
navController = nav.controller,
|
||||
startDestination = Route.Home,
|
||||
@@ -195,16 +206,13 @@ 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) }
|
||||
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
|
||||
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.EventSync> { EventSyncScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -223,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) }
|
||||
@@ -265,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,
|
||||
)
|
||||
@@ -276,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,
|
||||
)
|
||||
@@ -288,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,
|
||||
)
|
||||
@@ -298,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,
|
||||
)
|
||||
@@ -312,22 +313,31 @@ 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,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottomArgs<Route.NewLongFormPost> {
|
||||
LongFormPostScreen(
|
||||
draftId = it.draft,
|
||||
versionId = it.version,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottomArgs<Route.NewShortNote> {
|
||||
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,
|
||||
)
|
||||
@@ -414,7 +424,9 @@ private fun NavigateIfIntentRequested(
|
||||
actionableNextPage?.let { nextRoute ->
|
||||
val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
|
||||
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
|
||||
accountSessionManager.checkAndSwitchUserSync(npub, nextRoute)
|
||||
accountSessionManager.checkAndSwitchUserSync(npub) { account ->
|
||||
uriToRoute(intentNextPage, account)
|
||||
}
|
||||
} else {
|
||||
val currentRoute = getRouteWithArguments(nextRoute::class, nav.controller)
|
||||
if (!isSameRoute(currentRoute, nextRoute)) {
|
||||
@@ -470,7 +482,9 @@ private fun NavigateIfIntentRequested(
|
||||
scope.launch {
|
||||
val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
|
||||
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
|
||||
accountSessionManager.checkAndSwitchUserSync(npub, newPage)
|
||||
accountSessionManager.checkAndSwitchUserSync(npub) { newAccount ->
|
||||
uriToRoute(uri, newAccount)
|
||||
}
|
||||
} else {
|
||||
val currentRoute = getRouteWithArguments(newPage::class, nav.controller)
|
||||
if (!isSameRoute(currentRoute, newPage)) {
|
||||
|
||||
+18
-6
@@ -43,6 +43,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
@@ -51,6 +52,7 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.Drafts
|
||||
import androidx.compose.material.icons.outlined.GroupAdd
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Sync
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -71,7 +73,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
@@ -88,6 +89,7 @@ import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
@@ -442,7 +444,7 @@ fun ListContent(
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.my_lists,
|
||||
icon = ImageVector.vectorResource(R.drawable.format_list_bulleted_type),
|
||||
icon = Icons.AutoMirrored.Filled.FormatListBulleted,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Lists,
|
||||
@@ -472,13 +474,23 @@ fun ListContent(
|
||||
route = Route.Wallet,
|
||||
)
|
||||
|
||||
if (isDebug) {
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = R.drawable.ic_chess,
|
||||
iconReference = 1,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
)
|
||||
}
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = R.drawable.ic_chess,
|
||||
iconReference = 1,
|
||||
title = R.string.event_sync_title,
|
||||
icon = Icons.Outlined.Sync,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
route = Route.EventSync,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
|
||||
+10
-1
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
@@ -85,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 -> {
|
||||
@@ -330,6 +335,10 @@ suspend fun routeEditDraftTo(
|
||||
Route.NewShortNote(draft = note.idHex)
|
||||
}
|
||||
|
||||
is LongTextNoteEvent -> {
|
||||
Route.NewLongFormPost(draft = note.idHex)
|
||||
}
|
||||
|
||||
is ClassifiedsEvent -> {
|
||||
Route.NewProduct(draft = note.idHex)
|
||||
}
|
||||
|
||||
@@ -134,6 +134,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditRelays : Route()
|
||||
|
||||
@Serializable object EventSync : Route()
|
||||
|
||||
@Serializable object EditMediaServers : Route()
|
||||
|
||||
@Serializable object UpdateReactionType : Route()
|
||||
@@ -286,6 +288,12 @@ sealed class Route {
|
||||
val draft: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class NewLongFormPost(
|
||||
val draft: String? = null,
|
||||
val version: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class GeoPost(
|
||||
val geohash: String? = null,
|
||||
|
||||
+271
-77
@@ -21,20 +21,36 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation.topbars
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ViewList
|
||||
import androidx.compose.material.icons.automirrored.outlined.VolumeOff
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.outlined.Groups
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.outlined.Public
|
||||
import androidx.compose.material.icons.outlined.SensorDoor
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -43,14 +59,17 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
@@ -62,7 +81,6 @@ import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.CommunityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
@@ -74,6 +92,8 @@ import com.vitorpamplona.amethyst.ui.screen.RelayName
|
||||
import com.vitorpamplona.amethyst.ui.screen.ResourceName
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@@ -120,6 +140,8 @@ fun FeedFilterSpinner(
|
||||
stringRes(R.string.feed_filter_select_an_option, selectAnOption)
|
||||
}
|
||||
|
||||
val openDropdownLabel = stringRes(R.string.open_dropdown_menu)
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -137,7 +159,7 @@ fun FeedFilterSpinner(
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = 12.sp,
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
)
|
||||
} else {
|
||||
@@ -152,7 +174,7 @@ fun FeedFilterSpinner(
|
||||
Row {
|
||||
Text(
|
||||
text = "(${myLocation.geoHash})",
|
||||
fontSize = 12.sp,
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
@@ -162,7 +184,7 @@ fun FeedFilterSpinner(
|
||||
) { cityName ->
|
||||
Text(
|
||||
text = "($cityName)",
|
||||
fontSize = 12.sp,
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
)
|
||||
}
|
||||
@@ -171,7 +193,7 @@ fun FeedFilterSpinner(
|
||||
LocationState.LocationResult.LackPermission -> {
|
||||
Text(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = 12.sp,
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
)
|
||||
}
|
||||
@@ -179,7 +201,7 @@ fun FeedFilterSpinner(
|
||||
LocationState.LocationResult.Loading -> {
|
||||
Text(
|
||||
text = stringRes(R.string.loading_location),
|
||||
fontSize = 12.sp,
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
)
|
||||
}
|
||||
@@ -207,7 +229,7 @@ fun FeedFilterSpinner(
|
||||
}.semantics {
|
||||
role = Role.DropdownList
|
||||
stateDescription = accessibilityDescription
|
||||
onClick(label = "Open feed filter menu") {
|
||||
onClick(label = openDropdownLabel) {
|
||||
optionsShowing = true
|
||||
return@onClick true
|
||||
}
|
||||
@@ -215,20 +237,18 @@ fun FeedFilterSpinner(
|
||||
)
|
||||
}
|
||||
|
||||
if (optionsShowing) {
|
||||
options.isNotEmpty().also {
|
||||
SpinnerSelectionDialog(
|
||||
title = explainer,
|
||||
options = options,
|
||||
onDismiss = { optionsShowing = false },
|
||||
onSelect = {
|
||||
selected = options[it]
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
},
|
||||
) {
|
||||
RenderOption(it.name, accountViewModel)
|
||||
}
|
||||
if (optionsShowing && options.isNotEmpty()) {
|
||||
GroupedFeedFilterDialog(
|
||||
title = explainer,
|
||||
options = options,
|
||||
onDismiss = { optionsShowing = false },
|
||||
onSelect = {
|
||||
selected = options[it]
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
},
|
||||
) {
|
||||
RenderOption(it.name, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,84 +261,258 @@ fun RenderOption(
|
||||
when (option) {
|
||||
is GeoHashName -> {
|
||||
LoadCityName(option.geoHashTag) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text(text = "/g/$it", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
|
||||
is HashtagName -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text(text = option.name(), fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
|
||||
is ResourceName -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(id = option.resourceId),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringRes(id = option.resourceId),
|
||||
fontSize = Font14SP,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
is PeopleListName -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val noteState by observeNote(option.note, accountViewModel)
|
||||
val noteState by observeNote(option.note, accountViewModel)
|
||||
|
||||
val noteEvent = noteState.note.event
|
||||
val name =
|
||||
when (noteEvent) {
|
||||
is PeopleListEvent -> {
|
||||
noteEvent.titleOrName() ?: option.note.dTag()
|
||||
}
|
||||
|
||||
is FollowListEvent -> {
|
||||
noteEvent.title() ?: option.note.dTag()
|
||||
}
|
||||
|
||||
else -> {
|
||||
option.note.dTag()
|
||||
}
|
||||
val noteEvent = noteState.note.event
|
||||
val name =
|
||||
when (noteEvent) {
|
||||
is PeopleListEvent -> {
|
||||
noteEvent.titleOrName() ?: option.note.dTag()
|
||||
}
|
||||
|
||||
Text(text = name, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
is FollowListEvent -> {
|
||||
noteEvent.title() ?: option.note.dTag()
|
||||
}
|
||||
|
||||
else -> {
|
||||
option.note.dTag()
|
||||
}
|
||||
}
|
||||
|
||||
Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
|
||||
is CommunityName -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val it by observeNote(option.note, accountViewModel)
|
||||
val it by observeNote(option.note, accountViewModel)
|
||||
|
||||
Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
|
||||
is RelayName -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Text(
|
||||
text = option.name(),
|
||||
fontSize = Font14SP,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
private data class IndexedFeedDefinition(
|
||||
val originalIndex: Int,
|
||||
val item: FeedDefinition,
|
||||
)
|
||||
|
||||
private enum class FeedGroup(
|
||||
@param:androidx.annotation.StringRes val labelRes: Int,
|
||||
) {
|
||||
FEEDS(R.string.feed_group_feeds),
|
||||
HASHTAGS(R.string.feed_group_hashtags),
|
||||
COMMUNITIES(R.string.feed_group_communities),
|
||||
LISTS(R.string.feed_group_lists),
|
||||
}
|
||||
|
||||
private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<FeedGroup, List<IndexedFeedDefinition>> {
|
||||
val indexed = options.mapIndexed { index, item -> IndexedFeedDefinition(index, item) }
|
||||
return indexed.groupBy { entry ->
|
||||
when (entry.item.name) {
|
||||
is HashtagName -> FeedGroup.HASHTAGS
|
||||
is CommunityName -> FeedGroup.COMMUNITIES
|
||||
is PeopleListName -> FeedGroup.LISTS
|
||||
else -> FeedGroup.FEEDS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun GroupedFeedFilterDialog(
|
||||
title: String,
|
||||
options: ImmutableList<FeedDefinition>,
|
||||
onSelect: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onRenderItem: @Composable (FeedDefinition) -> Unit,
|
||||
) {
|
||||
val grouped = remember(options) { groupFeedDefinitions(options) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
text = option.name(),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
item {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
FeedGroup.entries.forEach { group ->
|
||||
val items = grouped[group]
|
||||
if (!items.isNullOrEmpty()) {
|
||||
item {
|
||||
GroupSection(
|
||||
label = stringRes(group.labelRes),
|
||||
items = items,
|
||||
isChipLayout = group == FeedGroup.HASHTAGS,
|
||||
onSelect = onSelect,
|
||||
onRenderItem = onRenderItem,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun GroupSection(
|
||||
label: String,
|
||||
items: List<IndexedFeedDefinition>,
|
||||
isChipLayout: Boolean,
|
||||
onSelect: (Int) -> Unit,
|
||||
onRenderItem: @Composable (FeedDefinition) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = label.uppercase(),
|
||||
fontSize = Font12SP,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 6.dp),
|
||||
)
|
||||
|
||||
if (isChipLayout) {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items.forEach { entry ->
|
||||
Surface(
|
||||
modifier = Modifier.clickable { onSelect(entry.originalIndex) },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
color = Color.Transparent,
|
||||
) {
|
||||
Text(
|
||||
text = entry.item.name.name(),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
} else {
|
||||
items.forEach { entry ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(entry.originalIndex) }
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
FeedIcon(
|
||||
item = entry.item,
|
||||
modifier = Size20Modifier,
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(start = 12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) { onRenderItem(entry.item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedIcon(
|
||||
item: FeedDefinition,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val icon =
|
||||
when (item.code) {
|
||||
is TopFilter.Global -> {
|
||||
Icons.Outlined.Public
|
||||
}
|
||||
|
||||
is TopFilter.AroundMe -> {
|
||||
Icons.Outlined.LocationOn
|
||||
}
|
||||
|
||||
is TopFilter.AllFollows -> {
|
||||
Icons.Outlined.Groups
|
||||
}
|
||||
|
||||
is TopFilter.AllUserFollows -> {
|
||||
Icons.Outlined.Person
|
||||
}
|
||||
|
||||
is TopFilter.DefaultFollows -> {
|
||||
Icons.Outlined.Groups
|
||||
}
|
||||
|
||||
is TopFilter.MuteList -> {
|
||||
Icons.AutoMirrored.Outlined.VolumeOff
|
||||
}
|
||||
|
||||
is TopFilter.Chess -> {
|
||||
Icons.Outlined.Groups
|
||||
}
|
||||
|
||||
is TopFilter.PeopleList -> {
|
||||
Icons.AutoMirrored.Outlined.ViewList
|
||||
}
|
||||
|
||||
else -> {
|
||||
when (item.name) {
|
||||
is GeoHashName -> Icons.Outlined.LocationOn
|
||||
is RelayName -> Icons.Outlined.SensorDoor
|
||||
is CommunityName -> Icons.Outlined.Groups
|
||||
is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList
|
||||
else -> Icons.Outlined.Person
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ fun BadgeCompose(
|
||||
nav: INav,
|
||||
) {
|
||||
val noteState by observeNote(likeSetCard.note, accountViewModel)
|
||||
val note = noteState?.note
|
||||
val note = noteState.note
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
|
||||
@@ -174,6 +178,10 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
|
||||
@@ -184,7 +192,7 @@ import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
@@ -767,6 +775,22 @@ private fun RenderNoteRow(
|
||||
RenderAppDefinition(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is AttestationEvent -> {
|
||||
RenderAttestation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is AttestationRequestEvent -> {
|
||||
RenderAttestationRequest(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is AttestorRecommendationEvent -> {
|
||||
RenderAttestorRecommendation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is AttestorProficiencyEvent -> {
|
||||
RenderAttestorProficiency(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is AudioTrackEvent -> {
|
||||
RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav)
|
||||
}
|
||||
@@ -1039,7 +1063,7 @@ private fun RenderNoteRow(
|
||||
)
|
||||
}
|
||||
|
||||
is PollNoteEvent -> {
|
||||
is ZapPollEvent -> {
|
||||
RenderZapPoll(
|
||||
baseNote,
|
||||
makeItShort,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -32,16 +32,27 @@ import kotlin.math.round
|
||||
private const val YEAR_DATE_FORMAT = "MMM dd, yyyy"
|
||||
private const val MONTH_DATE_FORMAT = "MMM dd"
|
||||
|
||||
private const val YEAR_NO_DAY_DATE_FORMAT = "MMM yyyy"
|
||||
private const val MONTH_NO_DAY_DATE_FORMAT = "MMM dd"
|
||||
|
||||
var locale: Locale = Locale.getDefault()
|
||||
var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
|
||||
var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
|
||||
|
||||
var yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
|
||||
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
|
||||
|
||||
@@ -54,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()) {
|
||||
@@ -63,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +127,46 @@ fun timeAgoNoDot(
|
||||
}
|
||||
}
|
||||
|
||||
fun timeAgoNoDotNoDay(
|
||||
time: Long?,
|
||||
context: Context,
|
||||
): String {
|
||||
if (time == null) return " "
|
||||
if (time == 0L) return " ${stringRes(context, R.string.never)}"
|
||||
|
||||
val timeDifference = TimeUtils.now() - time
|
||||
|
||||
return if (timeDifference > TimeUtils.ONE_YEAR) {
|
||||
// Dec 12, 2022
|
||||
|
||||
if (locale != Locale.getDefault()) {
|
||||
locale = Locale.getDefault()
|
||||
yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
|
||||
monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
|
||||
}
|
||||
|
||||
yearNoDayFormatter.format(time * 1000)
|
||||
} else if (timeDifference > TimeUtils.ONE_MONTH) {
|
||||
// Dec 12
|
||||
if (locale != Locale.getDefault()) {
|
||||
locale = Locale.getDefault()
|
||||
yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
|
||||
monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
|
||||
}
|
||||
|
||||
monthNoDayFormatter.format(time * 1000)
|
||||
} else if (timeDifference > TimeUtils.ONE_DAY) {
|
||||
// 2 days
|
||||
(timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, R.string.d)
|
||||
} else if (timeDifference > TimeUtils.ONE_HOUR) {
|
||||
(timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, R.string.h)
|
||||
} else if (timeDifference > TimeUtils.ONE_MINUTE) {
|
||||
(timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, R.string.m)
|
||||
} else {
|
||||
stringRes(context, R.string.now)
|
||||
}
|
||||
}
|
||||
|
||||
fun timeAheadNoDot(
|
||||
time: Long?,
|
||||
context: Context,
|
||||
|
||||
@@ -30,10 +30,10 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
@@ -49,10 +49,10 @@ fun showAmountInteger(amount: BigDecimal?): String {
|
||||
if (amount.abs() < BigDecimal(0.01)) return ""
|
||||
|
||||
return when {
|
||||
amount >= OneGiga -> dfG.get().format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP))
|
||||
amount >= OneMega -> dfM.get().format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP))
|
||||
amount >= TenKilo -> dfK.get().format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP))
|
||||
else -> dfN.get().format(amount)
|
||||
amount >= OneGiga -> dfG.get()?.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) ?: ""
|
||||
amount >= OneMega -> dfM.get()?.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) ?: ""
|
||||
amount >= TenKilo -> dfK.get()?.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) ?: ""
|
||||
else -> dfN.get()?.format(amount) ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -32,7 +34,6 @@ 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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -58,8 +59,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
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.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -93,13 +97,15 @@ import com.vitorpamplona.amethyst.ui.theme.BigPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -115,7 +121,7 @@ import kotlin.uuid.Uuid
|
||||
@Composable
|
||||
fun ZapZapPollNotePreview() {
|
||||
val event =
|
||||
PollNoteEvent(
|
||||
ZapPollEvent(
|
||||
id = "6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72",
|
||||
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
createdAt = 1729186078,
|
||||
@@ -188,7 +194,7 @@ fun ZapZapPollNotePreview() {
|
||||
@Composable
|
||||
fun ZapZapPollNotePreview2() {
|
||||
val event =
|
||||
PollNoteEvent(
|
||||
ZapPollEvent(
|
||||
id = "3064bf97800a4b04b612fc0fd498936eae75fffbdca5bbd09d19a6dc598530ab",
|
||||
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
createdAt = 1729191389,
|
||||
@@ -311,13 +317,6 @@ private fun OptionNote(
|
||||
modifier = Modifier.padding(vertical = 3.dp),
|
||||
) {
|
||||
if (!pollViewModel.canZap.value) {
|
||||
val color =
|
||||
if (poolOption.consensusThreadhold.value) {
|
||||
Color.Green.copy(alpha = 0.32f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.mediumImportanceLink
|
||||
}
|
||||
|
||||
ZapVote(
|
||||
baseNote,
|
||||
poolOption,
|
||||
@@ -326,7 +325,7 @@ private fun OptionNote(
|
||||
RenderOptionAfterVote(
|
||||
baseNote,
|
||||
poolOption,
|
||||
color,
|
||||
poolOption.consensusThreadhold.value,
|
||||
canPreview,
|
||||
tags,
|
||||
backgroundColor,
|
||||
@@ -366,7 +365,7 @@ private fun OptionNote(
|
||||
private fun RenderOptionAfterVote(
|
||||
baseNote: Note,
|
||||
poolOption: PollOption,
|
||||
color: Color,
|
||||
isWinning: Boolean,
|
||||
canPreview: Boolean,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
@@ -376,14 +375,25 @@ private fun RenderOptionAfterVote(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.clip(shape = QuoteBorder)
|
||||
.clip(SmallishBorder)
|
||||
.border(
|
||||
2.dp,
|
||||
color,
|
||||
QuoteBorder,
|
||||
width = 1.dp,
|
||||
color =
|
||||
if (isWinning) {
|
||||
MaterialTheme.colorScheme.allGoodColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.grayText
|
||||
},
|
||||
shape = SmallishBorder,
|
||||
).background(
|
||||
if (isWinning) {
|
||||
MaterialTheme.colorScheme.allGoodColor.copy(0.2f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.subtleBorder
|
||||
},
|
||||
),
|
||||
) {
|
||||
DisplayProgress(poolOption, color, modifier = Modifier.matchParentSize())
|
||||
DisplayProgress(poolOption, isWinning, modifier = Modifier.matchParentSize())
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -428,22 +438,29 @@ private fun RenderOptionAfterVote(
|
||||
@Composable
|
||||
private fun DisplayProgress(
|
||||
poolOption: PollOption,
|
||||
color: Color,
|
||||
isWinning: Boolean,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val progress by poolOption.tally
|
||||
// Animate the progress bar when a vote is cast
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = poolOption.tally.value,
|
||||
animationSpec = tween(durationMillis = 800),
|
||||
)
|
||||
|
||||
// The LinearProgressIndicator has some weird update issues and renders inaccurate percentages.
|
||||
Box(modifier = modifier) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(progress)
|
||||
.fillMaxHeight()
|
||||
.background(color = color),
|
||||
) {
|
||||
}
|
||||
}
|
||||
val progressBarColor = if (isWinning) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.primary
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.alpha(0.32f)
|
||||
.drawWithContent {
|
||||
// Clip the drawing area to show only the progress amount
|
||||
clipRect(right = size.width * animatedProgress) {
|
||||
drawRect(progressBarColor)
|
||||
}
|
||||
drawContent()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -475,11 +492,11 @@ private fun RenderOptionBeforeVote(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.clip(shape = QuoteBorder)
|
||||
.clip(SmallishBorder)
|
||||
.border(
|
||||
2.dp,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
QuoteBorder,
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
shape = SmallishBorder,
|
||||
),
|
||||
) {
|
||||
Column(BigPadding) {
|
||||
@@ -568,7 +585,19 @@ fun ZapVote(
|
||||
showErrorMessageDialog = StringToastMsg(title, message)
|
||||
},
|
||||
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
|
||||
onPayViaIntent = {},
|
||||
onPayViaIntent = {
|
||||
if (it.size == 1) {
|
||||
val payable = it.first()
|
||||
payViaIntent(payable.invoice, context, { }) { error ->
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = StringToastMsg(stringRes(context, R.string.error_dialog_zap_error), error)
|
||||
}
|
||||
} else {
|
||||
val uid = Uuid.random().toString()
|
||||
accountViewModel.tempManualPaymentCache.put(uid, it)
|
||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
wantsToZap = true
|
||||
|
||||
@@ -29,7 +29,7 @@ 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.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -53,7 +53,7 @@ class PollNoteViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
private var pollNote: Note? = null
|
||||
|
||||
private var pollEvent: PollNoteEvent? = null
|
||||
private var pollEvent: ZapPollEvent? = null
|
||||
private var pollOptions: Map<Int, String>? = null
|
||||
private var valueMaximum: Long? = null
|
||||
private var valueMinimum: Long? = null
|
||||
@@ -76,7 +76,7 @@ class PollNoteViewModel : ViewModel() {
|
||||
fun load(note: Note?) {
|
||||
if (pollNote != note) {
|
||||
pollNote = note
|
||||
pollEvent = pollNote?.event as PollNoteEvent
|
||||
pollEvent = pollNote?.event as ZapPollEvent
|
||||
pollOptions = pollEvent?.pollOptions()
|
||||
valueMaximum = pollEvent?.maxAmount()
|
||||
valueMinimum = pollEvent?.minAmount()
|
||||
@@ -118,13 +118,13 @@ class PollNoteViewModel : ViewModel() {
|
||||
it.zappedValue.value = zappedValue
|
||||
it.tally.value = tallyValue.toFloat()
|
||||
it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!!
|
||||
it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false
|
||||
it.zappedByLoggedIn.value = account.userProfile().let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkIfCanZap(): Boolean {
|
||||
val account = account ?: return false
|
||||
val account = account
|
||||
val note = pollNote ?: return false
|
||||
return account.userProfile() != note.author && !wasZappedByLoggedInAccount
|
||||
}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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.note.creators.anonymous
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PersonOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
|
||||
|
||||
@Composable
|
||||
fun AnonymousPostButton(
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -59,7 +59,7 @@ fun ContentSensitivityExplainer(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
.padding(bottom = 5.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ fun ExpirationDatePicker(model: IExpiration) {
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
.padding(bottom = 5.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Timer,
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ fun InvoiceRequest(
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CustomHashTagIcons.Lightning,
|
||||
|
||||
+3
@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -76,6 +77,8 @@ fun DisplayLocationInTitle(geohash: String) {
|
||||
text = cityName,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
modifier = Modifier.padding(start = Size5dp),
|
||||
)
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+2
-2
@@ -59,13 +59,13 @@ fun SecretEmojiRequest(onSuccess: (String) -> Unit) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Assistant,
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
Text(
|
||||
|
||||
+50
-29
@@ -79,7 +79,7 @@ fun ImageVideoDescription(
|
||||
uris: MultiOrchestrator,
|
||||
defaultServer: ServerName,
|
||||
isUploading: Boolean,
|
||||
onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit,
|
||||
onAdd: (String, ServerName, Boolean, Int, Boolean, Boolean) -> Unit,
|
||||
onDelete: (SelectedMediaProcessing) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -108,6 +108,8 @@ fun ImageVideoDescription(
|
||||
// Codec selection: false = H264, true = H265
|
||||
var useH265Codec by remember { mutableStateOf(false) }
|
||||
|
||||
var stripMetadata by remember { mutableStateOf(accountViewModel.account.settings.stripLocationOnUpload) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -248,36 +250,52 @@ fun ImageVideoDescription(
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1.0f),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_label),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// Hide privacy toggle when video compression is selected (compression already strips metadata)
|
||||
val isVideoWithCompression =
|
||||
uris.first().media.isVideo() == true && mediaQualitySlider != 3
|
||||
|
||||
if (!isVideoWithCompression) {
|
||||
SettingSwitchItem(
|
||||
title = R.string.strip_metadata_label,
|
||||
description = R.string.strip_metadata_description,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
checked = stripMetadata,
|
||||
onCheckedChange = { stripMetadata = it },
|
||||
)
|
||||
}
|
||||
|
||||
val firstMedia = uris.first().media
|
||||
|
||||
if (firstMedia.isVideo() == true || firstMedia.isImage() == true || firstMedia.isAudio() == true) {
|
||||
if (firstMedia.isVideo() == true || firstMedia.isImage() == true) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1.0f),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_label),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
@@ -302,7 +320,7 @@ fun ImageVideoDescription(
|
||||
}
|
||||
}
|
||||
|
||||
if (uris.first().media.isVideo() == true) {
|
||||
if (uris.first().media.isVideo() == true && mediaQualitySlider != 3) {
|
||||
SettingSwitchItem(
|
||||
title = R.string.video_codec_h265_label,
|
||||
description = R.string.video_codec_h265_description,
|
||||
@@ -321,7 +339,10 @@ fun ImageVideoDescription(
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
enabled = !isUploading,
|
||||
onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) },
|
||||
onClick = {
|
||||
val effectiveStripMetadata = if (isVideoWithCompression) false else stripMetadata
|
||||
onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec, effectiveStripMetadata)
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
|
||||
+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,
|
||||
)
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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.note.creators.zappolls
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
@Composable
|
||||
fun ZapPollConsensusThreshold(pollViewModel: ShortNotePostViewModel) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
pollViewModel.isValidConsensusThreshold.value = true
|
||||
if (text.isNotEmpty()) {
|
||||
try {
|
||||
val int = text.toInt()
|
||||
if (int !in 0..100) {
|
||||
pollViewModel.isValidConsensusThreshold.value = false
|
||||
} else {
|
||||
pollViewModel.zapPollConsensusThreshold = int
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
pollViewModel.isValidConsensusThreshold.value = false
|
||||
}
|
||||
}
|
||||
|
||||
val colorInValid =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.error,
|
||||
unfocusedBorderColor = Color.Red,
|
||||
)
|
||||
val colorValid =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(150.dp),
|
||||
colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_consensus_threshold),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_consensus_threshold_percent),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ViewModelConstructorInComposable")
|
||||
@Preview
|
||||
@Composable
|
||||
fun ZapPollConsensusThresholdPreview() {
|
||||
ZapPollConsensusThreshold(ShortNotePostViewModel())
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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.note.creators.zappolls
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.SelectableDates
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.TimePickerDialog
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
|
||||
// Get current time details
|
||||
val currentTime = Instant.ofEpochMilli(model.zapPollClosedAt * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime()
|
||||
|
||||
val datePickerState =
|
||||
rememberDatePickerState(
|
||||
initialSelectedDateMillis = model.zapPollClosedAt * 1000,
|
||||
yearRange = currentTime.year..2050,
|
||||
selectableDates =
|
||||
object : SelectableDates {
|
||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
|
||||
// Only allow today and future dates
|
||||
return utcTimeMillis >= System.currentTimeMillis() - 86400000 // minus 24h buffer
|
||||
}
|
||||
},
|
||||
)
|
||||
val timePickerState =
|
||||
rememberTimePickerState(
|
||||
initialHour = currentTime.hour,
|
||||
initialMinute = currentTime.minute,
|
||||
is24Hour = false, // Set to true if you prefer military time
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
OutlinedCard(
|
||||
onClick = { showDatePicker = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.DateRange, contentDescription = stringResource(R.string.accessibility_select_date))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
if (model.zapPollClosedAt < TimeUtils.oneMinuteFromNow()) {
|
||||
Text(stringRes(R.string.poll_closing_date_time) + " " + model.zapPollClosedAt, style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_closing_in, timeAheadNoDot(model.zapPollClosedAt, context)),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Date Picker Dialog ---
|
||||
if (showDatePicker) {
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showDatePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showDatePicker = false
|
||||
showTimePicker = true
|
||||
}) { Text(stringResource(R.string.next)) }
|
||||
},
|
||||
) {
|
||||
DatePicker(state = datePickerState)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Time Picker Dialog ---
|
||||
if (showTimePicker) {
|
||||
TimePickerDialog(
|
||||
title = {
|
||||
Text(stringResource(R.string.closing_time))
|
||||
},
|
||||
onDismissRequest = { showTimePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val datetimeLocalTimeZone =
|
||||
datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis ->
|
||||
(localDayAtZeroHourMillis / 1000) +
|
||||
(timePickerState.hour * TimeUtils.ONE_HOUR) +
|
||||
(timePickerState.minute * TimeUtils.ONE_MINUTE)
|
||||
} ?: TimeUtils.oneDayAhead()
|
||||
|
||||
// Get the offset from UTC for the current instant in the local time zone
|
||||
val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now())
|
||||
|
||||
model.zapPollClosedAt = datetimeLocalTimeZone - offset.totalSeconds
|
||||
|
||||
showTimePicker = false
|
||||
},
|
||||
) { Text(stringResource(R.string.confirm)) }
|
||||
},
|
||||
) {
|
||||
TimePicker(state = timePickerState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ViewModelConstructorInComposable")
|
||||
@Preview
|
||||
@Composable
|
||||
fun ZapPollDeadlinePickerPreview() {
|
||||
ZapPollDeadlinePicker(
|
||||
ShortNotePostViewModel(),
|
||||
)
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.note.creators.zappolls
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun ZapPollField(postViewModel: ShortNotePostViewModel) {
|
||||
val optionsList = postViewModel.zapPollOptions
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
optionsList.forEach { value ->
|
||||
ZapPollOption(postViewModel, value.key)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
ZapPollDeadlinePicker(postViewModel)
|
||||
|
||||
ZapPollVoteValueRange(postViewModel)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
// postViewModel.pollOptions[postViewModel.pollOptions.size] = ""
|
||||
optionsList[optionsList.size] = ""
|
||||
},
|
||||
border =
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = stringRes(R.string.add_poll_option_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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.note.creators.zappolls
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@Composable
|
||||
fun ZapPollOption(
|
||||
pollViewModel: ShortNotePostViewModel,
|
||||
optionIndex: Int,
|
||||
) {
|
||||
Row {
|
||||
val deleteIcon: @Composable (() -> Unit) = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
pollViewModel.removeZapPollOption(optionIndex)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringRes(R.string.clear),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.weight(1F),
|
||||
value = pollViewModel.zapPollOptions[optionIndex] ?: "",
|
||||
onValueChange = {
|
||||
pollViewModel.updateZapPollOption(optionIndex, it)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_option_index).format(optionIndex + 1),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_option_description),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
// colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else
|
||||
// colorInValid,
|
||||
trailingIcon = if (optionIndex > 1) deleteIcon else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ViewModelConstructorInComposable")
|
||||
@Preview
|
||||
@Composable
|
||||
fun ZapPollOptionPreview() {
|
||||
ZapPollOption(ShortNotePostViewModel(), 0)
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.note.creators.zappolls
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
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.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@Composable
|
||||
fun ZapPollVoteValueRange(pollViewModel: ShortNotePostViewModel) {
|
||||
val colorInValid =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.error,
|
||||
unfocusedBorderColor = Color.Red,
|
||||
)
|
||||
val colorValid =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = pollViewModel.zapPollValueMinimum?.toString() ?: "",
|
||||
onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = if (pollViewModel.isValidValueMinimum.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_zap_value_min),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.sats),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
OutlinedTextField(
|
||||
value = pollViewModel.zapPollValueMaximum?.toString() ?: "",
|
||||
onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = if (pollViewModel.isValidValueMaximum.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_zap_value_max),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.sats),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_zap_value_min_max_explainer),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ViewModelConstructorInComposable")
|
||||
@Preview
|
||||
@Composable
|
||||
fun ZapPollVoteValueRangePreview() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ZapPollVoteValueRange(ShortNotePostViewModel())
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -54,7 +54,7 @@ fun ZapRaiserRequest(
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CustomHashTagIcons.Lightning,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user