mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge branch 'main' into claude/add-markdown-post-screen-Xma8x
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
@@ -9,18 +9,18 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "./gradlew spotlessApply",
|
||||
"command": "./gradlew spotlessApply 2>/dev/null || spotless-apply",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
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 a table ready for translation.
|
||||
|
||||
## 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
|
||||
|
||||
## Technique
|
||||
|
||||
### 1. Identify files
|
||||
|
||||
```
|
||||
Default: amethyst/src/main/res/values/strings.xml
|
||||
Target: amethyst/src/main/res/values-<locale>/strings.xml
|
||||
```
|
||||
|
||||
Default locale: `cs-rCZ` if none specified. User may override (e.g., `pt-rBR`, `ja`).
|
||||
|
||||
### 2. Extract and diff keys
|
||||
|
||||
Use a single bash pipeline to extract translatable keys from both files and diff them:
|
||||
|
||||
```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-<LOCALE>/strings.xml \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
|
||||
```
|
||||
|
||||
This gives the list of missing key names.
|
||||
|
||||
### 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-<LOCALE>/strings.xml \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
|
||||
```
|
||||
|
||||
### 4. Present results
|
||||
|
||||
Output the missing entries as raw XML resource lines (copy-paste ready for the locale file):
|
||||
|
||||
```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.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- **Forgetting `translatable="false"`** — these should never appear in locale files
|
||||
- **Not checking string-arrays/plurals** — only checking `<string>` misses other resource types
|
||||
- **Modifying files** — this is a read-only research task unless the user asks to add entries
|
||||
@@ -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):**
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ echo "$JAVA_HOME"
|
||||
echo "$(java -version)"
|
||||
echo "Running test... "
|
||||
|
||||
./gradlew test
|
||||
./gradlew test --quiet
|
||||
|
||||
status=$?
|
||||
|
||||
|
||||
+134
-22
@@ -1,4 +1,4 @@
|
||||
name: Test/Build Android
|
||||
name: Test/Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -6,78 +6,190 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
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
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Linter (gradle)
|
||||
run: ./gradlew spotlessCheck
|
||||
|
||||
test:
|
||||
needs: lint
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Test (gradle)
|
||||
run: ./gradlew test --no-daemon
|
||||
|
||||
- name: Android Test Report
|
||||
uses: asadmansr/android-test-report-action@v1.2.0
|
||||
if: ${{ always() }} # IMPORTANT: run Android Test Report regardless
|
||||
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
|
||||
with:
|
||||
name: Test Reports
|
||||
path: amethyst/build/reports
|
||||
|
||||
build-android:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Build APK (gradle)
|
||||
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
|
||||
|
||||
- name: Build APK (gradle)
|
||||
- name: Build Benchmark APK (gradle)
|
||||
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
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Test Reports
|
||||
path: amethyst/build/reports
|
||||
build-desktop:
|
||||
needs: test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
task: packageDeb
|
||||
artifact-name: Desktop Linux DEB
|
||||
artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb
|
||||
- os: macos-latest
|
||||
task: packageDmg
|
||||
artifact-name: Desktop macOS DMG
|
||||
artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg
|
||||
- os: windows-latest
|
||||
task: packageMsi
|
||||
artifact-name: Desktop Windows MSI
|
||||
artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Build Desktop Distribution
|
||||
run: ./gradlew :desktopApp:${{ matrix.task }}
|
||||
|
||||
- name: Upload Desktop Distribution
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: ${{ matrix.artifact-path }}
|
||||
|
||||
@@ -6,23 +6,42 @@ on:
|
||||
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release ${{ github.ref }}
|
||||
draft: false
|
||||
prerelease: true
|
||||
|
||||
deploy-android:
|
||||
needs: create-release
|
||||
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
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
@@ -38,7 +57,6 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Sign AAB (F-Droid)
|
||||
@@ -50,7 +68,6 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Build APK
|
||||
@@ -65,7 +82,6 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Sign APK (F-Droid)
|
||||
@@ -77,20 +93,8 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release ${{ github.ref }}
|
||||
draft: false
|
||||
prerelease: true
|
||||
|
||||
# Google Play APK
|
||||
- name: Upload Play APK Universal Asset
|
||||
id: upload-release-asset-play-universal-apk
|
||||
@@ -98,7 +102,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
@@ -109,7 +113,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
@@ -120,7 +124,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
@@ -131,7 +135,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
@@ -142,7 +146,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
@@ -154,7 +158,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
@@ -165,7 +169,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
@@ -176,7 +180,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
@@ -187,7 +191,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
@@ -198,13 +202,11 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
|
||||
|
||||
# Google Play AAB
|
||||
- name: Upload Google Play AAB Asset
|
||||
id: upload-release-asset-play-aab
|
||||
@@ -212,7 +214,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab
|
||||
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
|
||||
asset_content_type: application/zip
|
||||
@@ -224,7 +226,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab
|
||||
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
|
||||
asset_content_type: application/zip
|
||||
@@ -236,3 +238,65 @@ jobs:
|
||||
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }}
|
||||
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}
|
||||
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
|
||||
|
||||
deploy-desktop:
|
||||
needs: create-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
task: packageDeb
|
||||
format: deb
|
||||
platform: linux
|
||||
- os: macos-latest
|
||||
task: packageDmg
|
||||
format: dmg
|
||||
platform: macos
|
||||
- os: windows-latest
|
||||
task: packageMsi
|
||||
format: msi
|
||||
platform: windows
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Build Desktop Distribution
|
||||
run: ./gradlew :desktopApp:${{ matrix.task }}
|
||||
|
||||
- name: Find distribution file
|
||||
id: find-dist
|
||||
run: |
|
||||
DIST_FILE=$(find desktopApp/build/compose/binaries/main/${{ matrix.format }} -type f \( -name "*.deb" -o -name "*.dmg" -o -name "*.msi" \) | head -1)
|
||||
echo "path=$DIST_FILE" >> $GITHUB_OUTPUT
|
||||
echo "name=$(basename $DIST_FILE)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload Desktop Distribution to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: ${{ steps.find-dist.outputs.path }}
|
||||
asset_name: amethyst-desktop-${{ matrix.platform }}-${{ github.ref_name }}.${{ matrix.format }}
|
||||
asset_content_type: application/octet-stream
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
@@ -22,6 +22,7 @@
|
||||
/.idea/AndroidProjectSystem.xml
|
||||
/.idea/deviceManager.xml
|
||||
/.idea/inspectionProfiles/
|
||||
/.idea/migrations.xml
|
||||
/commons/.idea/gradle.xml
|
||||
/commons/.idea/misc.xml
|
||||
/commons/.idea/workspace.xml
|
||||
@@ -32,6 +33,10 @@
|
||||
/captures
|
||||
.cxx
|
||||
|
||||
# superpowers skill
|
||||
.superpowers
|
||||
docs/brainstorms
|
||||
docs/superpowers
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
@@ -149,3 +154,11 @@ lint/tmp/
|
||||
|
||||
# Local task tracking
|
||||
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/
|
||||
|
||||
+50
-15
@@ -1,4 +1,9 @@
|
||||
<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 creating and rendering NIP-85 Polls
|
||||
- Redesign of the poll and zap poll cards
|
||||
- Adds Special notification card that stays while the poll is running
|
||||
|
||||
Adds support for Relay Feeds
|
||||
- Adds support for NIP-51 favorite relay feeds
|
||||
@@ -15,7 +20,10 @@ Redesigns Media Player
|
||||
- Turn video controller creation into a flow to fix playback lifecycle issues
|
||||
- Adds support for uploading audio
|
||||
|
||||
Adds support for NIP events (kind 30817)
|
||||
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
|
||||
|
||||
@@ -23,34 +31,38 @@ Adds support for NIP-39 External Identities with kind 10011
|
||||
|
||||
Adds support for NIP-C0 Code Snippets
|
||||
|
||||
Adds support for NIPs on Nostr (event kind 30817)
|
||||
|
||||
Adds support for NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
|
||||
|
||||
Adds support for BUD-10 "Blossom:" URIs in images, audios, videos, and documents.
|
||||
|
||||
Adds support for NIP-40 Expirations in any new post.
|
||||
Adds support for custom NIP-40 Expirations in any new post.
|
||||
- Displays expirations on posts and DMs
|
||||
|
||||
Adds support for NIP-66 Relay Monitor and discovery support to Quartz
|
||||
|
||||
Adds support for Namecoin .bit urls to NIP-05
|
||||
- Adds choice of ElectrumX server to resolve namecoins.
|
||||
Adds support for Attestations (https://attestr.xyz/)
|
||||
|
||||
Adds basic support for Chess with Jester protocol
|
||||
|
||||
Adds NIP-46 Bunker support to Quartz and Amethyst Desktop
|
||||
|
||||
Adds a Broadcasting feedback pop-up in the Complete UI mode
|
||||
Adds support for inline reply, mark as read from Push Notifications
|
||||
|
||||
Adds support for rendering Zap events when quoted inside of posts.
|
||||
Removes NIP-04 DMs and blocks DM sending if the receiver doesn't have NIP-17 relay lists.
|
||||
|
||||
Removes support for NIP-96 and updates Blossom recommendations
|
||||
|
||||
Adds support to upload Documents to all new post screens.
|
||||
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
|
||||
|
||||
Content warning improvements:
|
||||
- Adds optional description field for sensitive content warnings in new posts.
|
||||
- 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
|
||||
@@ -65,7 +77,14 @@ URL/URI parser rewrite in Kotlin multiplatform (KMP)
|
||||
- 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 UI for DropDowns by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- New 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
|
||||
@@ -73,7 +92,9 @@ 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
|
||||
- Breaks the search filter into two subscriptions to prioritize Metadata without punishing content.
|
||||
@@ -86,11 +107,14 @@ Search fixes
|
||||
|
||||
Profiles:
|
||||
- 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
|
||||
@@ -124,6 +148,7 @@ 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".
|
||||
- Fixes Req onCannotConnect listeners to the relays that actually sent the req
|
||||
|
||||
AI:
|
||||
- Add SKILL.md for AI agent customization
|
||||
@@ -135,6 +160,14 @@ 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 policies to the relay implementation.
|
||||
- Migrates Quartz EventStore from Android-only to KMP
|
||||
- Adds a reqUntilEoseAsFlow 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
|
||||
@@ -147,7 +180,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
|
||||
@@ -157,6 +189,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
|
||||
@@ -174,6 +208,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
|
||||
@@ -7006,4 +7041,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
|
||||
|
||||
@@ -218,6 +218,13 @@ Build and run the Desktop app (requires Java 21+):
|
||||
```bash
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
Full build (including tests)
|
||||
```bash
|
||||
./gradlew build
|
||||
```
|
||||
Requirements:
|
||||
- Xcode and iOS simulator
|
||||
- libsodium installed (e.g. via brew: `brew install libsodium`
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
|
||||
+14
-2
@@ -36,6 +36,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()
|
||||
@@ -336,9 +347,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 +361,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),
|
||||
|
||||
@@ -171,7 +171,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.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
|
||||
@@ -225,7 +226,6 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@@ -507,7 +507,7 @@ class Account(
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
|
||||
suspend fun updateTranslateTo(languageCode: Locale) {
|
||||
suspend fun updateTranslateTo(languageCode: String) {
|
||||
if (settings.updateTranslateTo(languageCode)) {
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
@@ -591,6 +591,14 @@ class Account(
|
||||
|
||||
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState)
|
||||
|
||||
suspend fun sendNwcRequest(
|
||||
request: Request,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse)
|
||||
client.send(event, setOf(relay))
|
||||
}
|
||||
|
||||
suspend fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
@@ -1601,7 +1609,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)
|
||||
@@ -2008,6 +2016,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)
|
||||
|
||||
@@ -63,7 +63,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultChannels =
|
||||
listOf(
|
||||
@@ -161,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),
|
||||
@@ -266,6 +266,13 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeStripLocationOnUpload(strip: Boolean) {
|
||||
if (stripLocationOnUpload != strip) {
|
||||
stripLocationOnUpload = strip
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// list names
|
||||
// ---
|
||||
@@ -332,11 +339,11 @@ class AccountSettings(
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: Locale) =
|
||||
fun translateToContains(languageCode: String) =
|
||||
syncedSettings.languages.translateTo.value
|
||||
.contains(languageCode.language)
|
||||
.contains(languageCode)
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale): Boolean {
|
||||
fun updateTranslateTo(languageCode: String): Boolean {
|
||||
if (syncedSettings.languages.updateTranslateTo(languageCode)) {
|
||||
saveAccountSettings()
|
||||
return true
|
||||
|
||||
@@ -27,7 +27,6 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.Locale
|
||||
|
||||
@Stable
|
||||
class AccountSyncedSettings(
|
||||
@@ -165,11 +164,11 @@ class AccountLanguagePreferences(
|
||||
dontTranslateFrom.update { it - languageCode }
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: Locale) = translateTo.value.contains(languageCode.language)
|
||||
fun translateToContains(languageCode: String) = translateTo.value.contains(languageCode)
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale): Boolean {
|
||||
if (translateTo.value != languageCode.language) {
|
||||
translateTo.tryEmit(languageCode.language)
|
||||
fun updateTranslateTo(languageCode: String): Boolean {
|
||||
if (translateTo.value != languageCode) {
|
||||
translateTo.tryEmit(languageCode)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -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)
|
||||
|
||||
+46
-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.
|
||||
@@ -138,6 +138,45 @@ class NwcSignerState(
|
||||
return zapPaymentResponseDecryptionCache.value.decryptResponse(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a generic NIP-47 request to the connected wallet.
|
||||
* Subscribes to responses and waits up to 60s for a reply.
|
||||
*
|
||||
* @param request the NIP-47 request to send
|
||||
* @param onResponse callback to handle the response from the wallet
|
||||
* @return a pair containing the request event and target relay URL
|
||||
* @throws IllegalArgumentException if no NIP-47 wallet is set up
|
||||
*/
|
||||
suspend fun sendNwcRequest(
|
||||
request: Request,
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
|
||||
val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup")
|
||||
|
||||
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, nip47Signer.value)
|
||||
|
||||
val filter =
|
||||
NWCPaymentQueryState(
|
||||
fromServiceHex = walletService.pubKeyHex,
|
||||
toUserHex = event.pubKey,
|
||||
replyingToHex = event.id,
|
||||
relay = walletService.relayUri,
|
||||
)
|
||||
|
||||
nwcFilterAssembler.subscribe(filter)
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(60000)
|
||||
nwcFilterAssembler.unsubscribe(filter)
|
||||
}
|
||||
|
||||
cache.consume(event, null, true, walletService.relayUri) {
|
||||
onResponse(decryptResponse(it))
|
||||
}
|
||||
|
||||
return Pair(event, walletService.relayUri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a zap payment request to a connected Lightning wallet.
|
||||
* Subscribes to responses and waits up to 60s for a reply.
|
||||
|
||||
+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
|
||||
|
||||
+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. */
|
||||
|
||||
+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
|
||||
|
||||
+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)
|
||||
|
||||
@@ -125,6 +125,8 @@ fun EditPostView(
|
||||
postViewModel.load(edit, versionLookingAt)
|
||||
}
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties =
|
||||
@@ -264,8 +266,9 @@ fun EditPostView(
|
||||
ImageVideoDescription(
|
||||
it,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
|
||||
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context)
|
||||
isUploading = postViewModel.mediaUploadTracker.isUploading,
|
||||
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,
|
||||
@@ -372,6 +375,7 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) {
|
||||
) {
|
||||
SelectFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
enabled = !postViewModel.isUploadingFile,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
@@ -379,7 +383,8 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) {
|
||||
}
|
||||
|
||||
SelectFromFiles(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
isUploading = postViewModel.isUploadingFile,
|
||||
enabled = !postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
|
||||
@@ -37,8 +37,10 @@ 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
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
@@ -79,7 +81,9 @@ open class EditPostViewModel : ViewModel() {
|
||||
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val mediaUploadTracker = MediaUploadTracker()
|
||||
val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage
|
||||
val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile
|
||||
|
||||
var userSuggestions: UserSuggestionState? = null
|
||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||
@@ -87,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)
|
||||
|
||||
@@ -158,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),
|
||||
@@ -175,12 +183,13 @@ open class EditPostViewModel : ViewModel() {
|
||||
server: ServerName,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
stripMetadata: Boolean = true,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val myAccount = account
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
|
||||
isUploadingImage = true
|
||||
mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
|
||||
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
@@ -191,6 +200,8 @@ open class EditPostViewModel : ViewModel() {
|
||||
myAccount,
|
||||
context,
|
||||
useH265Codec,
|
||||
stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
@@ -243,7 +254,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
}
|
||||
|
||||
isUploadingImage = false
|
||||
mediaUploadTracker.finishUpload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +266,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
|
||||
multiOrchestrator = null
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
mediaUploadTracker.finishUpload()
|
||||
|
||||
wantsInvoice = false
|
||||
|
||||
@@ -296,7 +307,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && multiOrchestrator == null
|
||||
fun canPost() = message.text.isNotBlank() && !mediaUploadTracker.isUploading && !wantsInvoice && multiOrchestrator == null
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
multiOrchestrator = MultiOrchestrator(uris)
|
||||
|
||||
@@ -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(
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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.uploads
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
class MediaUploadTracker {
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
private set
|
||||
var isUploadingFile by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val isUploading: Boolean get() = isUploadingImage || isUploadingFile
|
||||
|
||||
fun startUpload(hasNonMedia: Boolean) {
|
||||
if (hasNonMedia) {
|
||||
isUploadingFile = true
|
||||
} else {
|
||||
isUploadingImage = true
|
||||
}
|
||||
}
|
||||
|
||||
fun finishUpload() {
|
||||
isUploadingImage = false
|
||||
isUploadingFile = false
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -48,6 +48,7 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
@Composable
|
||||
fun SelectFromFiles(
|
||||
isUploading: Boolean,
|
||||
enabled: Boolean = true,
|
||||
tint: Color,
|
||||
modifier: Modifier,
|
||||
onFilesChosen: (ImmutableList<SelectedMedia>) -> Unit,
|
||||
@@ -64,19 +65,20 @@ fun SelectFromFiles(
|
||||
)
|
||||
}
|
||||
|
||||
FileSelectButton(isUploading, tint, modifier) { showFileSelect = true }
|
||||
FileSelectButton(isUploading, enabled, tint, modifier) { showFileSelect = true }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileSelectButton(
|
||||
isUploading: Boolean,
|
||||
enabled: Boolean,
|
||||
tint: Color,
|
||||
modifier: Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier,
|
||||
enabled = !isUploading,
|
||||
enabled = enabled && !isUploading,
|
||||
onClick = { onClick() },
|
||||
) {
|
||||
if (!isUploading) {
|
||||
|
||||
+5
-3
@@ -69,6 +69,7 @@ class SelectedMedia(
|
||||
@Composable
|
||||
fun SelectFromGallery(
|
||||
isUploading: Boolean,
|
||||
enabled: Boolean = true,
|
||||
tint: Color,
|
||||
modifier: Modifier,
|
||||
onImageChosen: (ImmutableList<SelectedMedia>) -> Unit,
|
||||
@@ -85,7 +86,7 @@ fun SelectFromGallery(
|
||||
)
|
||||
}
|
||||
|
||||
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true }
|
||||
GallerySelectButton(isUploading, enabled, tint, modifier) { showGallerySelect = true }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -107,19 +108,20 @@ fun SelectSingleFromGallery(
|
||||
)
|
||||
}
|
||||
|
||||
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true }
|
||||
GallerySelectButton(isUploading, true, tint, modifier) { showGallerySelect = true }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GallerySelectButton(
|
||||
isUploading: Boolean,
|
||||
enabled: Boolean,
|
||||
tint: Color,
|
||||
modifier: Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier,
|
||||
enabled = !isUploading,
|
||||
enabled = enabled && !isUploading,
|
||||
onClick = { onClick() },
|
||||
) {
|
||||
if (!isUploading) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+87
-104
@@ -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
|
||||
@@ -771,119 +772,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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -109,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
|
||||
@@ -121,6 +124,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
|
||||
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
|
||||
@@ -139,7 +146,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,
|
||||
@@ -153,6 +170,11 @@ fun AppNavigation(
|
||||
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
|
||||
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
|
||||
@@ -197,6 +219,7 @@ fun AppNavigation(
|
||||
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) }
|
||||
|
||||
@@ -415,7 +438,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)) {
|
||||
@@ -471,7 +496,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)) {
|
||||
|
||||
+25
-4
@@ -46,10 +46,12 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.outlined.AccountBalanceWallet
|
||||
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
|
||||
@@ -87,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
|
||||
@@ -464,12 +467,30 @@ fun ListContent(
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = R.drawable.ic_chess,
|
||||
iconReference = 1,
|
||||
title = R.string.wallet,
|
||||
icon = Icons.Outlined.AccountBalanceWallet,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
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.event_sync_title,
|
||||
icon = Icons.Outlined.Sync,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.EventSync,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
|
||||
@@ -43,6 +43,14 @@ sealed class Route {
|
||||
|
||||
@Serializable object Chess : Route()
|
||||
|
||||
@Serializable object Wallet : Route()
|
||||
|
||||
@Serializable object WalletSend : Route()
|
||||
|
||||
@Serializable object WalletReceive : Route()
|
||||
|
||||
@Serializable object WalletTransactions : Route()
|
||||
|
||||
@Serializable object Search : Route()
|
||||
|
||||
@Serializable object SecurityFilters : Route()
|
||||
@@ -126,6 +134,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditRelays : Route()
|
||||
|
||||
@Serializable object EventSync : Route()
|
||||
|
||||
@Serializable object EditMediaServers : Route()
|
||||
|
||||
@Serializable object UpdateReactionType : Route()
|
||||
|
||||
+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,
|
||||
|
||||
@@ -32,10 +32,16 @@ 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,
|
||||
@@ -116,6 +122,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
|
||||
}
|
||||
|
||||
+52
-29
@@ -78,7 +78,8 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
fun ImageVideoDescription(
|
||||
uris: MultiOrchestrator,
|
||||
defaultServer: ServerName,
|
||||
onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit,
|
||||
isUploading: Boolean,
|
||||
onAdd: (String, ServerName, Boolean, Int, Boolean, Boolean) -> Unit,
|
||||
onDelete: (SelectedMediaProcessing) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -107,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
|
||||
@@ -247,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(
|
||||
@@ -301,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,
|
||||
@@ -319,7 +338,11 @@ fun ImageVideoDescription(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) },
|
||||
enabled = !isUploading,
|
||||
onClick = {
|
||||
val effectiveStripMetadata = if (isVideoWithCompression) false else stripMetadata
|
||||
onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec, effectiveStripMetadata)
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
|
||||
+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())
|
||||
}
|
||||
}
|
||||
+124
-173
@@ -21,10 +21,22 @@
|
||||
package com.vitorpamplona.amethyst.ui.note.elements
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Bookmark
|
||||
import androidx.compose.material.icons.outlined.BookmarkAdd
|
||||
import androidx.compose.material.icons.outlined.BookmarkRemove
|
||||
import androidx.compose.material.icons.outlined.CellTower
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.LockOpen
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.material.icons.outlined.PersonRemove
|
||||
import androidx.compose.material.icons.outlined.PlaylistAdd
|
||||
import androidx.compose.material.icons.outlined.Report
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
@@ -44,6 +56,9 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.EditPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
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.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
|
||||
@@ -53,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
@@ -78,16 +92,16 @@ fun MoreOptionsButton(
|
||||
onClick = { popupExpanded.value = true },
|
||||
) {
|
||||
VerticalDotsIcon()
|
||||
}
|
||||
|
||||
if (popupExpanded.value) {
|
||||
NoteDropDownMenu(
|
||||
note = baseNote,
|
||||
onDismiss = { popupExpanded.value = false },
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
if (popupExpanded.value) {
|
||||
NoteDropDownMenu(
|
||||
note = baseNote,
|
||||
onDismiss = { popupExpanded.value = false },
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,213 +160,150 @@ fun NoteDropDownMenu(
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = true,
|
||||
onDismissRequest = onDismiss,
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.note_actions_dialog_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val actContext = LocalContext.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (!state.isFollowingAuthor) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.follow)) },
|
||||
onClick = {
|
||||
val author = note.author ?: return@DropdownMenuItem
|
||||
// Follow section
|
||||
M3ActionSection {
|
||||
if (!state.isFollowingAuthor) {
|
||||
M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) {
|
||||
val author = note.author ?: return@M3ActionRow
|
||||
accountViewModel.follow(author)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.unfollow)) },
|
||||
onClick = {
|
||||
val author = note.author ?: return@DropdownMenuItem
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) {
|
||||
val author = note.author ?: return@M3ActionRow
|
||||
accountViewModel.unfollow(author)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) },
|
||||
onClick = {
|
||||
val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem
|
||||
}
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.PlaylistAdd, text = stringRes(R.string.follow_set_add_author_from_note_action)) {
|
||||
val authorHexKey = note.author?.pubkeyHex ?: return@M3ActionRow
|
||||
nav.nav(Route.PeopleListManagement(authorHexKey))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_text)) },
|
||||
onClick = {
|
||||
}
|
||||
}
|
||||
|
||||
// Copy & Share section
|
||||
M3ActionSection {
|
||||
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) {
|
||||
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
|
||||
accountViewModel.decrypt(lastNoteVersion) {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
}
|
||||
accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) }
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_user_pubkey)) },
|
||||
onClick = {
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
|
||||
note.author?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}"))
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_note_id)) },
|
||||
onClick = {
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(AnnotatedString(note.toNostrUri()))
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.quick_action_share)) },
|
||||
onClick = {
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
|
||||
val sendIntent =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
externalLinkForNote(note),
|
||||
)
|
||||
putExtra(
|
||||
Intent.EXTRA_TITLE,
|
||||
stringRes(actContext, R.string.quick_action_share_browser_link),
|
||||
)
|
||||
putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note))
|
||||
putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link))
|
||||
}
|
||||
|
||||
val shareIntent =
|
||||
Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
|
||||
val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
|
||||
ContextCompat.startActivity(actContext, shareIntent, null)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
if (state.isLoggedUser && note.isDraft()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.edit_draft)) },
|
||||
onClick = {
|
||||
nav.nav {
|
||||
routeEditDraftTo(note, accountViewModel.account)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (note.event is TextNoteEvent && !note.isDraft()) {
|
||||
if (state.isLoggedUser) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.edit_post)) },
|
||||
onClick = {
|
||||
wantsToEditPost.value = true
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.propose_an_edit)) },
|
||||
onClick = {
|
||||
wantsToEditPost.value = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.broadcast)) },
|
||||
onClick = {
|
||||
|
||||
// Edit & Broadcast section
|
||||
M3ActionSection {
|
||||
if (state.isLoggedUser && note.isDraft()) {
|
||||
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_draft)) {
|
||||
nav.nav { routeEditDraftTo(note, accountViewModel.account) }
|
||||
}
|
||||
}
|
||||
if (note.event is TextNoteEvent && !note.isDraft()) {
|
||||
if (state.isLoggedUser) {
|
||||
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_post)) {
|
||||
wantsToEditPost.value = true
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.propose_an_edit)) {
|
||||
wantsToEditPost.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.broadcast)) {
|
||||
accountViewModel.broadcast(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.timestamp_pending)) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.timestamp_it)) },
|
||||
onClick = {
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp & Bookmarks section
|
||||
M3ActionSection {
|
||||
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
|
||||
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() }
|
||||
} else {
|
||||
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) {
|
||||
accountViewModel.timestamp(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
note.let {
|
||||
}
|
||||
}
|
||||
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.manage_bookmark_label, noteBookmarkType)) },
|
||||
onClick = {
|
||||
if (note.event is LongTextNoteEvent) {
|
||||
val noteAddress = (note as AddressableNote).address
|
||||
nav.nav(Route.ArticleBookmarkManagement(noteAddress))
|
||||
} else {
|
||||
nav.nav(Route.PostBookmarkManagement(note.idHex))
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (state.isPrivateBookmarkNote) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.remove_from_private_bookmarks)) },
|
||||
onClick = {
|
||||
M3ActionRow(icon = Icons.Outlined.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
|
||||
if (note.event is LongTextNoteEvent) {
|
||||
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
|
||||
} else {
|
||||
nav.nav(Route.PostBookmarkManagement(note.idHex))
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
if (state.isPrivateBookmarkNote) {
|
||||
M3ActionRow(icon = Icons.Outlined.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) {
|
||||
accountViewModel.removePrivateBookmark(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.add_to_private_bookmarks)) },
|
||||
onClick = {
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = Icons.Outlined.Lock, text = stringRes(R.string.add_to_private_bookmarks)) {
|
||||
accountViewModel.addPrivateBookmark(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (state.isPublicBookmarkNote) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.remove_from_public_bookmarks)) },
|
||||
onClick = {
|
||||
}
|
||||
}
|
||||
if (state.isPublicBookmarkNote) {
|
||||
M3ActionRow(icon = Icons.Outlined.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) {
|
||||
accountViewModel.removePublicBookmark(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.add_to_public_bookmarks)) },
|
||||
onClick = {
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = Icons.Outlined.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) {
|
||||
accountViewModel.addPublicBookmark(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
if (state.isLoggedUser) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.request_deletion)) },
|
||||
onClick = {
|
||||
|
||||
// Moderation section
|
||||
M3ActionSection {
|
||||
if (state.isLoggedUser) {
|
||||
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) {
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.block_report)) },
|
||||
onClick = { reportDialogShowing = true },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) {
|
||||
reportDialogShowing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-6
@@ -42,9 +42,11 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
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.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
@@ -149,7 +151,9 @@ open class CommentPostViewModel :
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val mediaUploadTracker = MediaUploadTracker()
|
||||
val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage
|
||||
val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile
|
||||
|
||||
var userSuggestions: UserSuggestionState? = null
|
||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||
@@ -159,6 +163,9 @@ open class CommentPostViewModel :
|
||||
// Images and Videos
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
@@ -467,8 +474,9 @@ open class CommentPostViewModel :
|
||||
server: ServerName,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
stripMetadata: Boolean = true,
|
||||
) = try {
|
||||
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context)
|
||||
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, stripMetadata)
|
||||
} catch (_: SignerExceptions.ReadOnlyException) {
|
||||
onError(
|
||||
stringRes(context, R.string.read_only_user),
|
||||
@@ -483,11 +491,12 @@ open class CommentPostViewModel :
|
||||
server: ServerName,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
stripMetadata: Boolean = true,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
|
||||
isUploadingImage = true
|
||||
mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
|
||||
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
@@ -497,6 +506,8 @@ open class CommentPostViewModel :
|
||||
server,
|
||||
account,
|
||||
context,
|
||||
stripMetadata = stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
@@ -551,7 +562,7 @@ open class CommentPostViewModel :
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
}
|
||||
|
||||
isUploadingImage = false
|
||||
mediaUploadTracker.finishUpload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,7 +575,7 @@ open class CommentPostViewModel :
|
||||
externalIdentity = null
|
||||
|
||||
multiOrchestrator = null
|
||||
isUploadingImage = false
|
||||
mediaUploadTracker.finishUpload()
|
||||
|
||||
notifying = null
|
||||
|
||||
@@ -676,7 +687,7 @@ open class CommentPostViewModel :
|
||||
|
||||
fun canPost(): Boolean =
|
||||
message.text.isNotBlank() &&
|
||||
!isUploadingImage &&
|
||||
!mediaUploadTracker.isUploading &&
|
||||
!wantsInvoice &&
|
||||
(!wantsZapraiser || zapRaiserAmount.value != null) &&
|
||||
multiOrchestrator == null
|
||||
|
||||
+9
-3
@@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
@@ -139,6 +140,8 @@ fun GenericCommentPostScreen(
|
||||
) {
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
@@ -299,8 +302,9 @@ private fun GenericCommentPostBody(
|
||||
ImageVideoDescription(
|
||||
it,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
|
||||
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
|
||||
isUploading = postViewModel.mediaUploadTracker.isUploading,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata ->
|
||||
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata)
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||
},
|
||||
onDelete = postViewModel::deleteMediaToUpload,
|
||||
@@ -395,6 +399,7 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) {
|
||||
) {
|
||||
SelectFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
enabled = !postViewModel.isUploadingFile,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
@@ -402,7 +407,8 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) {
|
||||
}
|
||||
|
||||
SelectFromFiles(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
isUploading = postViewModel.isUploadingFile,
|
||||
enabled = !postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
/*
|
||||
* 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.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.HourglassTop
|
||||
import androidx.compose.material.icons.filled.Recommend
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.KindChip
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity
|
||||
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderAttestationPreview() {
|
||||
val event =
|
||||
AttestationEvent(
|
||||
id = "f8e05e4fa964d9dfbeb339ae0450d0b3424aaf8f6083d95f38c780335c3dbd56",
|
||||
pubKey = "c4f5e7a75a8ce3683d529cff06368439c529e5243c6b125ba68789198856cac7",
|
||||
createdAt = 1773941524,
|
||||
content = "This is Frank's new npub.",
|
||||
sig = "61dde5dc5738bfa637aa04451eec63d45f649902a0772abbaf55711aad5b7ce376bc7712c937f9ffc504655c4fd7e39f78139dc3f1a90953d150b43f3e2428fb",
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "af5aa898:fe108febb997:1773941524"),
|
||||
arrayOf("e", "fe108febb99796c4091775e00aa1fc3ffc489ad22fdf1f8c559b2472815c09c7"),
|
||||
arrayOf("s", "verified"),
|
||||
arrayOf("v", "valid"),
|
||||
arrayOf("client", "attestr.xyz"),
|
||||
),
|
||||
)
|
||||
|
||||
LocalCache.justConsume(event, null, true)
|
||||
val note = LocalCache.getOrCreateNote(event.id)
|
||||
|
||||
ThemeComparisonColumn(
|
||||
toPreview = {
|
||||
RenderAttestation(
|
||||
baseNote = note,
|
||||
quotesLeft = 3,
|
||||
backgroundColor = remember { mutableStateOf(Color.Transparent) },
|
||||
accountViewModel = mockAccountViewModel(),
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderAttestation(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteState by baseNote
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val noteEvent = noteState.note.event as? AttestationEvent ?: return
|
||||
|
||||
RenderAttestation(
|
||||
baseNote,
|
||||
noteEvent,
|
||||
quotesLeft,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderAttestation(
|
||||
note: Note,
|
||||
noteEvent: AttestationEvent,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val validity = remember(noteEvent) { noteEvent.validity() }
|
||||
val status = remember(noteEvent) { noteEvent.status() }
|
||||
val validFrom = remember(noteEvent) { noteEvent.validFrom() }
|
||||
val validTo = remember(noteEvent) { noteEvent.validTo() }
|
||||
val content = remember(noteEvent) { noteEvent.content.ifBlank { null } }
|
||||
|
||||
val statusColor = remember(status, validity) { attestationColor(status, validity) }
|
||||
val statusIcon = remember(status, validity) { attestationIcon(status, validity) }
|
||||
val statusLabel = attestationStatusLabel(status, validity)
|
||||
|
||||
val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() }
|
||||
val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() }
|
||||
val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.border(1.dp, statusColor.copy(alpha = 0.3f), RoundedCornerShape(12.dp))
|
||||
.background(statusColor.copy(alpha = 0.06f))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = statusIcon,
|
||||
contentDescription = stringRes(R.string.attestation),
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Text(
|
||||
text = statusLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = statusColor,
|
||||
)
|
||||
}
|
||||
|
||||
if (validFrom != null || validTo != null) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
validFrom?.let {
|
||||
Text(
|
||||
text = stringRes(R.string.attestation_valid_from, formatTimestamp(it)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
validTo?.let {
|
||||
Text(
|
||||
text = stringRes(R.string.attestation_valid_to, formatTimestamp(it)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (quotesLeft > 0) {
|
||||
if (aboutAddress != null) {
|
||||
LoadAddressableNote(aboutAddress, accountViewModel) {
|
||||
if (it != null) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
makeItShort = true,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (aboutEvent != null) {
|
||||
LoadNote(aboutEvent, accountViewModel) {
|
||||
if (it != null) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
makeItShort = true,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (aboutPubkey != null) {
|
||||
LoadUser(aboutPubkey, accountViewModel) {
|
||||
if (it != null) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content?.let {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = true,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList },
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderAttestationRequest(
|
||||
note: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteState by note
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val noteEvent = noteState.note.event as? AttestationRequestEvent ?: return
|
||||
|
||||
val content = remember(noteEvent) { noteEvent.content.ifBlank { null } }
|
||||
|
||||
val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() }
|
||||
val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() }
|
||||
val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(12.dp),
|
||||
).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.06f))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Send,
|
||||
contentDescription = stringRes(R.string.attestation_request),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.attestation_request),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
content?.let {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = true,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList },
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (quotesLeft > 0) {
|
||||
if (aboutAddress != null) {
|
||||
LoadAddressableNote(aboutAddress, accountViewModel) {
|
||||
if (it != null) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Text(
|
||||
text = stringRes(R.string.attestation_requests_attestation_to),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
makeItShort = true,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (aboutEvent != null) {
|
||||
LoadNote(aboutEvent, accountViewModel) {
|
||||
if (it != null) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Text(
|
||||
text = stringRes(R.string.attestation_requests_attestation_to),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
makeItShort = true,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (aboutPubkey != null) {
|
||||
LoadUser(aboutPubkey, accountViewModel) {
|
||||
if (it != null) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Text(
|
||||
text = stringRes(R.string.attestation_requests_attestation_to),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RenderAttestorRecommendation(
|
||||
note: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteState by note
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val noteEvent = noteState.note.event as? AttestorRecommendationEvent ?: return
|
||||
|
||||
val kinds = remember(noteEvent) { noteEvent.kinds() }
|
||||
val description = remember(noteEvent) { noteEvent.description() }
|
||||
val aboutPubKey = remember(noteEvent) { noteEvent.dTag() }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(12.dp),
|
||||
).background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.06f))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Recommend,
|
||||
contentDescription = stringRes(R.string.attestor_recommendation),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.attestor_recommendation),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
|
||||
LoadUser(aboutPubKey, accountViewModel) {
|
||||
if (it != null) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
|
||||
if (kinds.isNotEmpty()) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.attestor_recommendation_for_kinds),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// Kinds
|
||||
kinds.forEach { kind ->
|
||||
KindChip(kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
description?.let {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = true,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList },
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RenderAttestorProficiency(
|
||||
note: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteState by note
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val noteEvent = noteState.note.event as? AttestorProficiencyEvent ?: return
|
||||
|
||||
val kinds = remember(noteEvent) { noteEvent.kinds() }
|
||||
val description = remember(noteEvent) { noteEvent.description() }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(12.dp),
|
||||
).background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.06f))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = stringRes(R.string.attestor_proficiency),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.attestor_proficiency),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
|
||||
if (kinds.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
kinds.forEach { kind ->
|
||||
Text(
|
||||
text = "Kind $kind",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
description?.let {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = true,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList },
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attestationColor(
|
||||
status: AttestationStatus?,
|
||||
validity: Validity?,
|
||||
): Color =
|
||||
when {
|
||||
status == AttestationStatus.REVOKED -> Color(0xFFB71C1C)
|
||||
status == AttestationStatus.REJECTED -> Color(0xFFB71C1C)
|
||||
validity == Validity.INVALID -> Color(0xFFB71C1C)
|
||||
status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32)
|
||||
validity == Validity.VALID -> Color(0xFF2E7D32)
|
||||
status == AttestationStatus.VERIFYING -> Color(0xFFF57F17)
|
||||
status == AttestationStatus.ACCEPTED -> Color(0xFF1565C0)
|
||||
else -> Color(0xFF757575)
|
||||
}
|
||||
|
||||
private fun attestationIcon(
|
||||
status: AttestationStatus?,
|
||||
validity: Validity?,
|
||||
): ImageVector =
|
||||
when {
|
||||
status == AttestationStatus.REVOKED -> Icons.Default.Close
|
||||
status == AttestationStatus.REJECTED -> Icons.Default.Close
|
||||
validity == Validity.INVALID -> Icons.Default.Close
|
||||
status == AttestationStatus.VERIFIED -> Icons.Default.VerifiedUser
|
||||
validity == Validity.VALID -> Icons.Default.CheckCircle
|
||||
status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop
|
||||
status == AttestationStatus.ACCEPTED -> Icons.Default.CheckCircle
|
||||
else -> Icons.Default.VerifiedUser
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun attestationStatusLabel(
|
||||
status: AttestationStatus?,
|
||||
validity: Validity?,
|
||||
): String =
|
||||
when {
|
||||
status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked)
|
||||
status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected)
|
||||
validity == Validity.INVALID -> stringRes(R.string.attestation_invalid)
|
||||
status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified)
|
||||
validity == Validity.VALID -> stringRes(R.string.attestation_valid)
|
||||
status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying)
|
||||
status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted)
|
||||
else -> stringRes(R.string.attestation)
|
||||
}
|
||||
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
|
||||
return sdf.format(Date(timestamp * 1000))
|
||||
}
|
||||
@@ -96,7 +96,7 @@ fun RenderLiveChessChallenge(
|
||||
nav: INav,
|
||||
) {
|
||||
val event = (note.event as? LiveChessGameChallengeEvent) ?: return
|
||||
val gameId = event.gameId() ?: return
|
||||
val gameId = event.gameId()
|
||||
|
||||
val chessViewModel: ChessViewModelNew =
|
||||
viewModel(
|
||||
|
||||
@@ -72,9 +72,9 @@ private fun ObserverAndRenderNIP95(
|
||||
val content by
|
||||
remember(noteState) {
|
||||
// Creates a new object when the event arrives to force an update of the image.
|
||||
val note = noteState?.note
|
||||
val note = noteState.note
|
||||
val uri = header.toNostrUri()
|
||||
val localDir = note?.idHex?.let { File(Amethyst.instance.nip95cache, it) }
|
||||
val localDir = note.idHex.let { File(Amethyst.instance.nip95cache, it) }
|
||||
val blurHash = eventHeader.blurhash()
|
||||
val dimensions = eventHeader.dimensions()
|
||||
val description = eventHeader.alt() ?: eventHeader.content
|
||||
|
||||
@@ -163,7 +163,7 @@ fun RenderTextModificationEvent(
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = noteState) {
|
||||
val newAuthor = accountViewModel.isLoggedUser(noteState?.note?.author)
|
||||
val newAuthor = accountViewModel.isLoggedUser(noteState.note.author)
|
||||
|
||||
if (isAuthorTheLoggedUser.value != newAuthor) {
|
||||
isAuthorTheLoggedUser.value = newAuthor
|
||||
|
||||
@@ -45,7 +45,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
@@ -61,7 +61,7 @@ fun RenderZapPoll(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = note.event as? PollNoteEvent ?: return
|
||||
val noteEvent = note.event as? ZapPollEvent ?: return
|
||||
val eventContent = noteEvent.content
|
||||
|
||||
val showReply by
|
||||
|
||||
+9
-9
@@ -112,11 +112,11 @@ class AccountSessionManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loginWithDefaultAccount(route: Route? = null) {
|
||||
private suspend fun loginWithDefaultAccount(routeBuilder: ((account: Account) -> Route?)? = null) {
|
||||
val accountSettings = localPreferences.loadAccountConfigFromEncryptedStorage()
|
||||
|
||||
if (accountSettings != null) {
|
||||
startUI(accountSettings, route)
|
||||
startUI(accountSettings, routeBuilder)
|
||||
} else {
|
||||
requestLoginUI()
|
||||
}
|
||||
@@ -184,11 +184,11 @@ class AccountSessionManager(
|
||||
|
||||
fun startUI(
|
||||
accountSettings: AccountSettings,
|
||||
route: Route? = null,
|
||||
routeBuilder: ((account: Account) -> Route?)? = null,
|
||||
) {
|
||||
val account = accountsCache.loadAccount(accountSettings)
|
||||
_accountContent.update {
|
||||
AccountState.LoggedIn(account, route)
|
||||
AccountState.LoggedIn(account, routeBuilder?.invoke(account))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ class AccountSessionManager(
|
||||
|
||||
localPreferences.setDefaultAccount(accountSettings)
|
||||
|
||||
startUI(accountSettings, route = Route.ImportFollowsSelectUser)
|
||||
startUI(accountSettings, routeBuilder = { Route.ImportFollowsSelectUser })
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(2000) // waits for the new user to connect to the new relays.
|
||||
@@ -329,12 +329,12 @@ class AccountSessionManager(
|
||||
|
||||
suspend fun checkAndSwitchUserSync(
|
||||
npub: String,
|
||||
route: Route,
|
||||
routeBuilder: ((account: Account) -> Route?)? = null,
|
||||
): Boolean {
|
||||
if (npub != localPreferences.currentAccount()) {
|
||||
val account = localPreferences.allSavedAccounts().firstOrNull { it.npub == npub }
|
||||
if (account != null) {
|
||||
switchUserSync(account, route)
|
||||
switchUserSync(account, routeBuilder)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -343,10 +343,10 @@ class AccountSessionManager(
|
||||
|
||||
private suspend fun switchUserSync(
|
||||
accountInfo: AccountInfo,
|
||||
route: Route? = null,
|
||||
routeBuilder: ((account: Account) -> Route?)? = null,
|
||||
) {
|
||||
localPreferences.switchToAccount(accountInfo)
|
||||
loginWithDefaultAccount(route)
|
||||
loginWithDefaultAccount(routeBuilder)
|
||||
}
|
||||
|
||||
fun currentAccountNPub() =
|
||||
|
||||
+2
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -68,6 +69,7 @@ class AccountFeedContentStates(
|
||||
val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(account), scope, LocalCache)
|
||||
|
||||
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
|
||||
val notificationsOpenPolls = OpenPollsState(account, scope)
|
||||
val notificationSummary = NotificationSummaryState(account)
|
||||
|
||||
val feedListOptions = TopNavFilterState(account, scope)
|
||||
|
||||
+2
@@ -49,6 +49,7 @@ fun AccountSwitcherAndLeftDrawerLayout(
|
||||
accountViewModel: AccountViewModel,
|
||||
accountSessionManager: AccountSessionManager,
|
||||
nav: INav,
|
||||
gesturesEnabled: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -83,6 +84,7 @@ fun AccountSwitcherAndLeftDrawerLayout(
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = nav.drawerState,
|
||||
gesturesEnabled = gesturesEnabled,
|
||||
drawerContent = {
|
||||
DrawerContent(nav, openSheetFunction, accountViewModel)
|
||||
BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer)
|
||||
|
||||
+68
-3
@@ -35,6 +35,7 @@ import coil3.asDrawable
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
|
||||
@@ -79,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
|
||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
@@ -93,8 +95,10 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
@@ -123,7 +127,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
@@ -144,8 +148,10 @@ import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -158,7 +164,6 @@ import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
@Stable
|
||||
class AccountViewModel(
|
||||
@@ -176,6 +181,66 @@ class AccountViewModel(
|
||||
val broadcastTracker = BroadcastTracker()
|
||||
val feedStates = AccountFeedContentStates(account, viewModelScope)
|
||||
|
||||
val eventSync =
|
||||
EventSync(
|
||||
accountPubKey = account.signer.pubKey,
|
||||
relayDb = {
|
||||
val stats = Amethyst.instance.relayStats.snapshot()
|
||||
|
||||
val relays =
|
||||
account.cache.relayHints.relayDB
|
||||
.keys()
|
||||
.filter { url ->
|
||||
val relayStat = stats[url]
|
||||
// has connected at least once OR never tried.
|
||||
if (relayStat != null) {
|
||||
relayStat.connectionCompleted > 0 || relayStat.connectionTentatives == 0
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val sortMap = relays.associateWith { stats.get(it)?.receivedBytes }
|
||||
|
||||
relays.sortedByDescending { sortMap[it] }
|
||||
},
|
||||
outboxTargets = { account.nip65RelayList.outboxFlow.value },
|
||||
inboxTargets = { account.nip65RelayList.inboxFlow.value },
|
||||
dmTargets = { account.dmRelayList.flow.value },
|
||||
clientBuilder = {
|
||||
// creates a new client to make sure these events don't end up polluting the local cache.
|
||||
|
||||
// Create a new scope that inherits the ViewModel's lifecycle
|
||||
// but uses a SupervisorJob so child failures are independent.
|
||||
val customScope = CoroutineScope(viewModelScope.coroutineContext + SupervisorJob())
|
||||
|
||||
// Provides a relay pool
|
||||
val newClient = NostrClient(Amethyst.instance.websocketBuilder, customScope)
|
||||
|
||||
// Authenticates with relays.
|
||||
val auth =
|
||||
RelayAuthenticator(
|
||||
newClient,
|
||||
customScope,
|
||||
signWithAllLoggedInUsers = { authTemplate ->
|
||||
if (account.signer.isWriteable()) {
|
||||
try {
|
||||
listOf(account.signer.sign(authTemplate))
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
newClient
|
||||
},
|
||||
scope = viewModelScope,
|
||||
)
|
||||
|
||||
val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -1009,7 +1074,7 @@ class AccountViewModel(
|
||||
|
||||
fun removeDontTranslateFrom(languageCode: String) = launchSigner { account.removeDontTranslateFrom(languageCode) }
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale) = launchSigner { account.updateTranslateTo(languageCode) }
|
||||
fun updateTranslateTo(languageCode: String) = launchSigner { account.updateTranslateTo(languageCode) }
|
||||
|
||||
fun prefer(
|
||||
source: String,
|
||||
|
||||
+111
-135
@@ -21,10 +21,19 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.BookmarkRemove
|
||||
import androidx.compose.material.icons.outlined.CellTower
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.LockOpen
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.material.icons.outlined.PersonRemove
|
||||
import androidx.compose.material.icons.outlined.Report
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -42,6 +51,9 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.EditPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
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.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
|
||||
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
|
||||
@@ -52,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -76,20 +87,20 @@ fun BookmarkGroupItemOptions(
|
||||
onClick = { popupExpanded.value = true },
|
||||
) {
|
||||
VerticalDotsIcon()
|
||||
}
|
||||
|
||||
if (popupExpanded.value) {
|
||||
BookmarkGroupItemOptionsMenu(
|
||||
note = baseNote,
|
||||
isBookmarkItemPrivate = isBookmarkItemPrivate,
|
||||
onDismiss = { popupExpanded.value = false },
|
||||
onMoveBookmarkToPublic = onMoveBookmarkToPublic,
|
||||
onMoveBookmarkToPrivate = onMoveBookmarkToPrivate,
|
||||
onDeleteBookmarkItem = onDeleteBookmarkItem,
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
if (popupExpanded.value) {
|
||||
BookmarkGroupItemOptionsMenu(
|
||||
note = baseNote,
|
||||
isBookmarkItemPrivate = isBookmarkItemPrivate,
|
||||
onDismiss = { popupExpanded.value = false },
|
||||
onMoveBookmarkToPublic = onMoveBookmarkToPublic,
|
||||
onMoveBookmarkToPrivate = onMoveBookmarkToPrivate,
|
||||
onDeleteBookmarkItem = onDeleteBookmarkItem,
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,89 +153,71 @@ fun BookmarkGroupItemOptionsMenu(
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = true,
|
||||
onDismissRequest = onDismiss,
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.bookmark_item_actions_dialog_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val actContext = LocalContext.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label)) },
|
||||
onClick = if (isBookmarkItemPrivate) onMoveBookmarkToPublic else onMoveBookmarkToPrivate,
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.bookmark_remove_action_label)) },
|
||||
onClick = {
|
||||
|
||||
// Bookmark Management section
|
||||
M3ActionSection {
|
||||
M3ActionRow(
|
||||
icon = if (isBookmarkItemPrivate) Icons.Outlined.LockOpen else Icons.Outlined.Lock,
|
||||
text = stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label),
|
||||
) {
|
||||
if (isBookmarkItemPrivate) onMoveBookmarkToPublic() else onMoveBookmarkToPrivate()
|
||||
}
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.BookmarkRemove,
|
||||
text = stringRes(R.string.bookmark_remove_action_label),
|
||||
isDestructive = true,
|
||||
) {
|
||||
onDeleteBookmarkItem()
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.isFollowingAuthor) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.follow)) },
|
||||
onClick = {
|
||||
val author = note.author ?: return@DropdownMenuItem
|
||||
// Follow section
|
||||
M3ActionSection {
|
||||
if (!state.isFollowingAuthor) {
|
||||
M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) {
|
||||
val author = note.author ?: return@M3ActionRow
|
||||
accountViewModel.follow(author)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.unfollow)) },
|
||||
onClick = {
|
||||
val author = note.author ?: return@DropdownMenuItem
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) {
|
||||
val author = note.author ?: return@M3ActionRow
|
||||
accountViewModel.unfollow(author)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
// DropdownMenuItem(
|
||||
// text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) },
|
||||
// onClick = {
|
||||
// val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem
|
||||
// nav.nav(Route.PeopleListManagement(authorHexKey))
|
||||
// onDismiss()
|
||||
// },
|
||||
// )
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_text)) },
|
||||
onClick = {
|
||||
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
|
||||
accountViewModel.decrypt(lastNoteVersion) {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy & Share section
|
||||
M3ActionSection {
|
||||
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) {
|
||||
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
|
||||
accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) }
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_user_pubkey)) },
|
||||
onClick = {
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
|
||||
note.author?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}"))
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_note_id)) },
|
||||
onClick = {
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(AnnotatedString(note.toNostrUri()))
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.quick_action_share)) },
|
||||
onClick = {
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
|
||||
val sendIntent =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
@@ -243,74 +236,57 @@ fun BookmarkGroupItemOptionsMenu(
|
||||
Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
|
||||
ContextCompat.startActivity(actContext, shareIntent, null)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
if (state.isLoggedUser && note.isDraft()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.edit_draft)) },
|
||||
onClick = {
|
||||
}
|
||||
}
|
||||
|
||||
// Edit & Broadcast section
|
||||
M3ActionSection {
|
||||
if (state.isLoggedUser && note.isDraft()) {
|
||||
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_draft)) {
|
||||
nav.nav {
|
||||
routeEditDraftTo(note, accountViewModel.account)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (note.event is TextNoteEvent && !note.isDraft()) {
|
||||
if (state.isLoggedUser) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.edit_post)) },
|
||||
onClick = {
|
||||
wantsToEditPost.value = true
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.propose_an_edit)) },
|
||||
onClick = {
|
||||
wantsToEditPost.value = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.broadcast)) },
|
||||
onClick = {
|
||||
if (note.event is TextNoteEvent && !note.isDraft()) {
|
||||
if (state.isLoggedUser) {
|
||||
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_post)) {
|
||||
wantsToEditPost.value = true
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.propose_an_edit)) {
|
||||
wantsToEditPost.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.broadcast)) {
|
||||
accountViewModel.broadcast(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.timestamp_pending)) },
|
||||
onClick = {
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp & Moderation section
|
||||
M3ActionSection {
|
||||
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
|
||||
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) {
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.timestamp_it)) },
|
||||
onClick = {
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) {
|
||||
accountViewModel.timestamp(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
if (state.isLoggedUser) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.request_deletion)) },
|
||||
onClick = {
|
||||
}
|
||||
}
|
||||
if (state.isLoggedUser) {
|
||||
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) {
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.block_report)) },
|
||||
onClick = { reportDialogShowing = true },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) {
|
||||
reportDialogShowing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-43
@@ -30,11 +30,11 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CellTower
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -57,6 +57,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
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.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
@@ -65,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -304,6 +306,31 @@ fun BookmarkGroupActionsMenuButton(
|
||||
) {
|
||||
val isActionListOpen = remember { mutableStateOf(false) }
|
||||
|
||||
if (isActionListOpen.value) {
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.list_actions_dialog_title),
|
||||
onDismiss = { isActionListOpen.value = false },
|
||||
) {
|
||||
M3ActionSection {
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.CellTower,
|
||||
text = stringRes(R.string.bookmark_list_broadcast_btn_label),
|
||||
) {
|
||||
onBroadcastList()
|
||||
isActionListOpen.value = false
|
||||
}
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.Delete,
|
||||
text = stringRes(R.string.bookmark_list_delete_btn_label),
|
||||
isDestructive = true,
|
||||
) {
|
||||
onDeleteList()
|
||||
isActionListOpen.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClickableBox(
|
||||
modifier =
|
||||
StdPadding
|
||||
@@ -319,44 +346,5 @@ fun BookmarkGroupActionsMenuButton(
|
||||
onClick = { isActionListOpen.value = true },
|
||||
) {
|
||||
VerticalDotsIcon()
|
||||
BookmarkGroupActionsMenu(
|
||||
onCloseMenu = { isActionListOpen.value = false },
|
||||
isOpen = isActionListOpen.value,
|
||||
onBroadcastList = onBroadcastList,
|
||||
onDeleteList = onDeleteList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupActionsMenu(
|
||||
onCloseMenu: () -> Unit,
|
||||
isOpen: Boolean,
|
||||
onBroadcastList: () -> Unit,
|
||||
onDeleteList: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = isOpen,
|
||||
onDismissRequest = onCloseMenu,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(stringRes(R.string.bookmark_list_broadcast_btn_label))
|
||||
},
|
||||
onClick = {
|
||||
onBroadcastList()
|
||||
onCloseMenu()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(stringRes(R.string.bookmark_list_delete_btn_label))
|
||||
},
|
||||
onClick = {
|
||||
onDeleteList()
|
||||
onCloseMenu()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+44
-52
@@ -32,10 +32,12 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.Article
|
||||
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
@@ -57,6 +59,9 @@ import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
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.note.VerticalDotsIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -258,18 +263,18 @@ private fun BookmarkGroupOptionsButton(
|
||||
onClick = { isMenuOpen.value = true },
|
||||
) {
|
||||
VerticalDotsIcon()
|
||||
|
||||
GroupOptionsMenu(
|
||||
groupName = bookmarkGroupName,
|
||||
groupDescription = bookmarkGroupDescription,
|
||||
isExpanded = isMenuOpen.value,
|
||||
onDismiss = { isMenuOpen.value = false },
|
||||
onGroupRename = onGroupRename,
|
||||
onGroupDescriptionChange = onGroupDescriptionChange,
|
||||
onGroupClone = onGroupCloneCreate,
|
||||
onDelete = onGroupDelete,
|
||||
)
|
||||
}
|
||||
|
||||
GroupOptionsMenu(
|
||||
groupName = bookmarkGroupName,
|
||||
groupDescription = bookmarkGroupDescription,
|
||||
isExpanded = isMenuOpen.value,
|
||||
onDismiss = { isMenuOpen.value = false },
|
||||
onGroupRename = onGroupRename,
|
||||
onGroupDescriptionChange = onGroupDescriptionChange,
|
||||
onGroupClone = onGroupCloneCreate,
|
||||
onDelete = onGroupDelete,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -288,45 +293,32 @@ private fun GroupOptionsMenu(
|
||||
val optionalCloneName = remember { mutableStateOf<String?>(null) }
|
||||
val optionalCloneDescription = remember { mutableStateOf<String?>(null) }
|
||||
|
||||
DropdownMenu(
|
||||
expanded = isExpanded,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.follow_set_rename_btn_label))
|
||||
},
|
||||
onClick = {
|
||||
onGroupRename()
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.follow_set_desc_modify_label))
|
||||
},
|
||||
onClick = {
|
||||
onGroupDescriptionChange()
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.follow_set_copy_action_btn_label))
|
||||
},
|
||||
onClick = {
|
||||
isCopyDialogOpen.value = true
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.quick_action_delete))
|
||||
},
|
||||
onClick = {
|
||||
onDelete()
|
||||
},
|
||||
)
|
||||
if (isExpanded) {
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.group_actions_dialog_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
M3ActionSection {
|
||||
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_rename_btn_label)) {
|
||||
onGroupRename()
|
||||
onDismiss()
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.Description, text = stringRes(R.string.follow_set_desc_modify_label)) {
|
||||
onGroupDescriptionChange()
|
||||
onDismiss()
|
||||
}
|
||||
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.follow_set_copy_action_btn_label)) {
|
||||
isCopyDialogOpen.value = true
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
M3ActionSection {
|
||||
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) {
|
||||
onDelete()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isCopyDialogOpen.value) {
|
||||
|
||||
+17
-1
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
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
|
||||
@@ -138,7 +139,22 @@ class BookmarkGroupMetadataViewModel : ViewModel() {
|
||||
) {
|
||||
onUploading(true)
|
||||
|
||||
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
val sourceUri =
|
||||
if (account.settings.stripLocationOnUpload) {
|
||||
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
|
||||
if (!result.stripped) {
|
||||
onError(
|
||||
stringRes(context, R.string.metadata_strip_failed_title),
|
||||
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
|
||||
)
|
||||
onUploading(false)
|
||||
return
|
||||
}
|
||||
result.uri
|
||||
} else {
|
||||
galleryUri.uri
|
||||
}
|
||||
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
|
||||
try {
|
||||
val result =
|
||||
|
||||
+28
-26
@@ -30,12 +30,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BookmarkAdd
|
||||
import androidx.compose.material.icons.filled.BookmarkRemove
|
||||
import androidx.compose.material.icons.outlined.BookmarkAdd
|
||||
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Public
|
||||
import androidx.compose.material.icons.outlined.RemoveCircleOutline
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
@@ -48,6 +47,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.screen.loggedIn.bookmarkgroups.list.BookmarkMembershipStatusAndNumberDisplay
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
|
||||
@@ -158,6 +160,30 @@ fun BookmarkManagementOptions(
|
||||
) {
|
||||
val isBookmarkAddTapped = remember { mutableStateOf(false) }
|
||||
|
||||
if (isBookmarkAddTapped.value) {
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.add_bookmark_dialog_title),
|
||||
onDismiss = { isBookmarkAddTapped.value = false },
|
||||
) {
|
||||
M3ActionSection {
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.BookmarkAdd,
|
||||
text = stringRes(R.string.public_bookmark_add_action_label),
|
||||
) {
|
||||
onAddBookmark(false)
|
||||
isBookmarkAddTapped.value = false
|
||||
}
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.Lock,
|
||||
text = stringRes(R.string.private_bookmark_add_action_label),
|
||||
) {
|
||||
onAddBookmark(true)
|
||||
isBookmarkAddTapped.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -196,29 +222,5 @@ fun BookmarkManagementOptions(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = isBookmarkAddTapped.value,
|
||||
onDismissRequest = { isBookmarkAddTapped.value = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.public_bookmark_add_action_label))
|
||||
},
|
||||
onClick = {
|
||||
onAddBookmark(false)
|
||||
isBookmarkAddTapped.value = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.private_bookmark_add_action_label))
|
||||
},
|
||||
onClick = {
|
||||
onAddBookmark(true)
|
||||
isBookmarkAddTapped.value = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -26,14 +26,16 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
|
||||
@@ -44,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.Privat
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -92,10 +95,15 @@ fun ChatroomView(
|
||||
}
|
||||
}
|
||||
|
||||
if (room.users.size == 1) {
|
||||
// Activates NIP-17 if the user has DM relays
|
||||
ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) {
|
||||
newPostModel.nip17 = !it?.relays().isNullOrEmpty()
|
||||
// Reactively check if recipients have DM relays for NIP-17 delivery
|
||||
for (userHex in room.users) {
|
||||
LoadAddressableNote(
|
||||
ChatMessageRelayListEvent.createAddress(userHex),
|
||||
accountViewModel,
|
||||
) { note ->
|
||||
if (note != null) {
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+213
-124
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
@@ -73,6 +74,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
@@ -88,16 +90,29 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
@@ -126,13 +141,65 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
var room: ChatroomKey? by mutableStateOf(null)
|
||||
val room = MutableStateFlow<ChatroomKey?>(null)
|
||||
|
||||
var requiresNIP17: Boolean = false
|
||||
val roomUsers: StateFlow<List<User>> =
|
||||
room
|
||||
.mapNotNull {
|
||||
it?.users?.mapNotNull { userHex -> LocalCache.checkGetOrCreateUser(userHex) } ?: emptyList()
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
room.value?.users?.mapNotNull { userHex -> LocalCache.checkGetOrCreateUser(userHex) } ?: emptyList(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val recipientsMissingDmRelays: StateFlow<ImmutableList<User>> =
|
||||
roomUsers
|
||||
.transformLatest {
|
||||
val dmRelayListNoteFlows =
|
||||
it.map { user ->
|
||||
user.dmRelayListNote
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
}
|
||||
|
||||
if (dmRelayListNoteFlows.isEmpty()) {
|
||||
emitAll(MutableStateFlow(persistentListOf()))
|
||||
} else {
|
||||
val flow =
|
||||
combine(dmRelayListNoteFlows) { dmRelayListNotes ->
|
||||
dmRelayListNotes
|
||||
.mapNotNull { noteState ->
|
||||
val noteEvent = noteState.note.event as? ChatMessageRelayListEvent
|
||||
if (noteEvent == null || noteEvent.relays().isEmpty()) {
|
||||
noteState.note.author
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
emitAll(flow)
|
||||
}
|
||||
}.onStart {
|
||||
}.onCompletion {
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
roomUsers.value
|
||||
.filter { user ->
|
||||
user.dmInboxRelays().isNullOrEmpty()
|
||||
}.toImmutableList(),
|
||||
)
|
||||
|
||||
val replyTo = mutableStateOf<Note?>(null)
|
||||
|
||||
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
|
||||
var uploadsWaitingToBeSent by mutableStateOf<List<SuccessfulUploads>>(emptyList())
|
||||
@@ -141,7 +208,8 @@ class ChatNewMessageViewModel :
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false
|
||||
val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false
|
||||
|
||||
var userSuggestions: UserSuggestionState? = null
|
||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||
@@ -179,9 +247,6 @@ class ChatNewMessageViewModel :
|
||||
var wantsZapraiser by mutableStateOf(false)
|
||||
override var zapRaiserAmount = mutableStateOf<Long?>(null)
|
||||
|
||||
// NIP17 Wrapped DMs / Group messages
|
||||
var nip17 by mutableStateOf(false)
|
||||
|
||||
fun lnAddress(): String? = account.userProfile().lnAddress()
|
||||
|
||||
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
|
||||
@@ -203,30 +268,16 @@ class ChatNewMessageViewModel :
|
||||
this.uploadState =
|
||||
ChatFileUploadState(
|
||||
account.settings.defaultFileServer,
|
||||
account.settings.stripLocationOnUpload,
|
||||
)
|
||||
}
|
||||
|
||||
fun load(room: ChatroomKey) {
|
||||
this.room = room
|
||||
this.room.tryEmit(room)
|
||||
this.toUsers =
|
||||
TextFieldValue(
|
||||
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
)
|
||||
|
||||
updateNIP17StatusFromRoom()
|
||||
}
|
||||
|
||||
fun updateNIP17StatusFromRoom() {
|
||||
val room = this.room
|
||||
if (room != null) {
|
||||
this.requiresNIP17 = room.users.size > 1
|
||||
if (this.requiresNIP17) {
|
||||
this.nip17 = true
|
||||
}
|
||||
} else {
|
||||
this.requiresNIP17 = false
|
||||
this.nip17 = false
|
||||
}
|
||||
}
|
||||
|
||||
fun reply(replyNote: Note) {
|
||||
@@ -359,9 +410,6 @@ class ChatNewMessageViewModel :
|
||||
urlPreviews.update(message)
|
||||
|
||||
iMetaAttachments.addAll(draftEvent.imetas())
|
||||
|
||||
requiresNIP17 = draftEvent is NIP17Group
|
||||
nip17 = draftEvent is NIP17Group
|
||||
}
|
||||
|
||||
suspend fun sendPostSync() {
|
||||
@@ -401,18 +449,20 @@ class ChatNewMessageViewModel :
|
||||
val uploadState = uploadState ?: return
|
||||
|
||||
accountViewModel.launchSigner {
|
||||
if (nip17) {
|
||||
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) {
|
||||
uploadsWaitingToBeSent += it
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
}
|
||||
} else {
|
||||
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) {
|
||||
uploadsWaitingToBeSent += it
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
}
|
||||
ChatFileUploader(account).justUploadNIP17(
|
||||
uploadState,
|
||||
onError,
|
||||
onEncryptedUploadError = { title, message ->
|
||||
encryptedUploadErrorTitle = title
|
||||
encryptedUploadErrorMessage = message
|
||||
pendingRetryMode = RetryMode.HOLD
|
||||
},
|
||||
context,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
) {
|
||||
uploadsWaitingToBeSent += it
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,28 +472,97 @@ class ChatNewMessageViewModel :
|
||||
context: Context,
|
||||
onceUploaded: () -> Unit,
|
||||
) {
|
||||
val room = room ?: return
|
||||
val room = room.value ?: return
|
||||
val uploadState = uploadState ?: return
|
||||
|
||||
accountViewModel.launchSigner {
|
||||
if (nip17) {
|
||||
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) {
|
||||
ChatFileSender(room, account).sendNIP17(it)
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
ChatFileUploader(account).justUploadNIP17(
|
||||
uploadState,
|
||||
onError,
|
||||
onEncryptedUploadError = { title, message ->
|
||||
encryptedUploadErrorTitle = title
|
||||
encryptedUploadErrorMessage = message
|
||||
pendingRetryMode = RetryMode.SEND
|
||||
pendingRetryOnError = onError
|
||||
pendingRetryContext = context
|
||||
pendingRetryOnceUploaded = onceUploaded
|
||||
},
|
||||
context,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
) {
|
||||
ChatFileSender(room, account).sendNIP17(it)
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypted upload error state for retry dialog
|
||||
var encryptedUploadErrorTitle by mutableStateOf<String?>(null)
|
||||
var encryptedUploadErrorMessage by mutableStateOf<String?>(null)
|
||||
var pendingRetryMode by mutableStateOf<RetryMode?>(null)
|
||||
var pendingRetryOnError by mutableStateOf<((String, String) -> Unit)?>(null)
|
||||
var pendingRetryContext by mutableStateOf<Context?>(null)
|
||||
var pendingRetryOnceUploaded by mutableStateOf<(() -> Unit)?>(null)
|
||||
|
||||
enum class RetryMode { HOLD, SEND }
|
||||
|
||||
fun dismissEncryptedUploadError() {
|
||||
encryptedUploadErrorTitle = null
|
||||
encryptedUploadErrorMessage = null
|
||||
pendingRetryMode = null
|
||||
pendingRetryOnError = null
|
||||
pendingRetryContext = null
|
||||
pendingRetryOnceUploaded = null
|
||||
}
|
||||
|
||||
fun retryWithoutEncryption() {
|
||||
val mode = pendingRetryMode ?: return
|
||||
val onError = pendingRetryOnError
|
||||
val context = pendingRetryContext
|
||||
val onceUploaded = pendingRetryOnceUploaded
|
||||
val room = room.value
|
||||
val uploadState = uploadState
|
||||
|
||||
dismissEncryptedUploadError()
|
||||
|
||||
if (room == null || uploadState == null || context == null) return
|
||||
|
||||
uploadState.encryptFiles = false
|
||||
|
||||
accountViewModel.launchSigner {
|
||||
when (mode) {
|
||||
RetryMode.HOLD -> {
|
||||
ChatFileUploader(account).justUploadNIP17Unencrypted(
|
||||
uploadState,
|
||||
onError ?: accountViewModel.toastManager::toast,
|
||||
context,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
) {
|
||||
uploadsWaitingToBeSent += it
|
||||
draftTag.newVersion()
|
||||
onceUploaded?.invoke()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) {
|
||||
ChatFileSender(room, account).sendNIP04(it)
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
|
||||
RetryMode.SEND -> {
|
||||
ChatFileUploader(account).justUploadNIP17Unencrypted(
|
||||
uploadState,
|
||||
onError ?: accountViewModel.toastManager::toast,
|
||||
context,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
) {
|
||||
ChatFileSender(room, account).sendNIP17(it)
|
||||
draftTag.newVersion()
|
||||
onceUploaded?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun innerSendPost(draftTag: String?) {
|
||||
val room = room ?: return
|
||||
val room = room.value ?: return
|
||||
|
||||
val urls = findURLs(message.text)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
@@ -456,70 +575,49 @@ class ChatNewMessageViewModel :
|
||||
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
|
||||
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null
|
||||
|
||||
if (nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) {
|
||||
val replyHint = replyTo.value?.toEventHint<BaseDMGroupEvent>()
|
||||
val replyHint = replyTo.value?.toEventHint<BaseDMGroupEvent>()
|
||||
|
||||
val template =
|
||||
if (replyHint == null) {
|
||||
ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) {
|
||||
hashtags(findHashtags(message))
|
||||
references(findURLs(message))
|
||||
quotes(findNostrEventUris(message))
|
||||
val template =
|
||||
if (replyHint == null) {
|
||||
ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) {
|
||||
hashtags(findHashtags(message))
|
||||
references(findURLs(message))
|
||||
quotes(findNostrEventUris(message))
|
||||
|
||||
geoHash?.let { geohash(it) }
|
||||
localZapRaiserAmount?.let { zapraiser(it) }
|
||||
zapReceiver?.let { zapSplits(it) }
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
localExpirationDate?.let { expiration(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
}
|
||||
} else {
|
||||
ChatMessageEvent.reply(message, replyHint) {
|
||||
hashtags(findHashtags(message))
|
||||
references(findURLs(message))
|
||||
quotes(findNostrEventUris(message))
|
||||
|
||||
geoHash?.let { geohash(it) }
|
||||
localZapRaiserAmount?.let { zapraiser(it) }
|
||||
zapReceiver?.let { zapSplits(it) }
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
localExpirationDate?.let { expiration(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
if (draftTag != null) {
|
||||
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
|
||||
} else {
|
||||
accountViewModel.account.sendNip17PrivateMessage(template)
|
||||
}
|
||||
} else {
|
||||
val toUser = room.users.first().let { LocalCache.getOrCreateUser(it).toPTag() }
|
||||
|
||||
val template =
|
||||
PrivateDmEvent.build(
|
||||
toUser = toUser,
|
||||
message = message,
|
||||
imetas = usedAttachments,
|
||||
replyingTo = replyTo.value?.toEventHint<PrivateDmEvent>(),
|
||||
signer = accountViewModel.account.signer,
|
||||
) {
|
||||
geoHash?.let { geohash(it) }
|
||||
localZapRaiserAmount?.let { zapraiser(it) }
|
||||
zapReceiver?.let { zapSplits(it) }
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
localExpirationDate?.let { expiration(it) }
|
||||
}
|
||||
|
||||
if (draftTag != null) {
|
||||
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
}
|
||||
} else {
|
||||
accountViewModel.account.sendNip04PrivateMessage(template)
|
||||
ChatMessageEvent.reply(message, replyHint) {
|
||||
hashtags(findHashtags(message))
|
||||
references(findURLs(message))
|
||||
quotes(findNostrEventUris(message))
|
||||
|
||||
geoHash?.let { geohash(it) }
|
||||
localZapRaiserAmount?.let { zapraiser(it) }
|
||||
zapReceiver?.let { zapSplits(it) }
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
localExpirationDate?.let { expiration(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
if (draftTag != null) {
|
||||
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
|
||||
} else {
|
||||
accountViewModel.account.sendNip17PrivateMessage(template)
|
||||
}
|
||||
|
||||
if (draftTag == null) {
|
||||
ChatFileSender(room, accountViewModel.account).sendAll(uploadsWaitingToBeSent)
|
||||
ChatFileSender(room, accountViewModel.account).sendNIP17(uploadsWaitingToBeSent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,6 +658,9 @@ class ChatNewMessageViewModel :
|
||||
userSuggestionsMainMessage = null
|
||||
|
||||
uploadsWaitingToBeSent = emptyList()
|
||||
uploadState?.reset()
|
||||
|
||||
dismissEncryptedUploadError()
|
||||
|
||||
iMetaAttachments.reset()
|
||||
|
||||
@@ -611,12 +712,10 @@ class ChatNewMessageViewModel :
|
||||
|
||||
val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex }
|
||||
if (users.isNullOrEmpty()) {
|
||||
room = null
|
||||
updateNIP17StatusFromRoom()
|
||||
room.emit(null)
|
||||
} else {
|
||||
if (users != room?.users) {
|
||||
room = ChatroomKey(users)
|
||||
updateNIP17StatusFromRoom()
|
||||
if (users != room.value?.users) {
|
||||
room.emit(ChatroomKey(users))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -649,9 +748,6 @@ class ChatNewMessageViewModel :
|
||||
val lastWord = toUsers.currentWord()
|
||||
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
|
||||
updateRoomFromUsersInput()
|
||||
|
||||
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelaysNorm()
|
||||
nip17 = relayList != null
|
||||
}
|
||||
|
||||
userSuggestionsMainMessage = null
|
||||
@@ -691,11 +787,12 @@ class ChatNewMessageViewModel :
|
||||
|
||||
fun canPost(): Boolean =
|
||||
message.text.isNotBlank() &&
|
||||
uploadState?.isUploadingImage != true &&
|
||||
uploadState?.mediaUploadTracker?.isUploading != true &&
|
||||
!wantsInvoice &&
|
||||
(!wantsZapraiser || zapRaiserAmount.value != null) &&
|
||||
(toUsers.text.isNotBlank()) &&
|
||||
uploadState?.multiOrchestrator == null
|
||||
uploadState?.multiOrchestrator == null &&
|
||||
recipientsMissingDmRelays.value.isEmpty()
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
@@ -707,15 +804,7 @@ class ChatNewMessageViewModel :
|
||||
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
|
||||
}
|
||||
|
||||
fun toggleNIP04And24() {
|
||||
if (requiresNIP17) {
|
||||
nip17 = true
|
||||
} else {
|
||||
nip17 = !nip17
|
||||
}
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
// NIP-04 sending is deprecated. NIP-17 is always used.
|
||||
|
||||
override fun updateZapPercentage(
|
||||
index: Int,
|
||||
|
||||
+25
-8
@@ -197,7 +197,7 @@ fun NewGroupDMScreen(
|
||||
// function when the postViewModel is released
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendPostSync()
|
||||
postViewModel.room?.let {
|
||||
postViewModel.room.value?.let {
|
||||
nav.nav(routeToMessage(it, null, null, null, null, accountViewModel))
|
||||
}
|
||||
}
|
||||
@@ -287,7 +287,8 @@ fun GroupDMScreenContent(
|
||||
ImageVideoDescription(
|
||||
selectedFiles,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
|
||||
isUploading = uploading.mediaUploadTracker.isUploading,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality, _, _ ->
|
||||
postViewModel.uploadAndHold(
|
||||
accountViewModel.toastManager::toast,
|
||||
context,
|
||||
@@ -301,6 +302,15 @@ fun GroupDMScreenContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
postViewModel.encryptedUploadErrorTitle?.let { title ->
|
||||
EncryptedUploadErrorDialog(
|
||||
title = title,
|
||||
message = postViewModel.encryptedUploadErrorMessage ?: "",
|
||||
onDismiss = postViewModel::dismissEncryptedUploadError,
|
||||
onRetryWithoutEncryption = postViewModel::retryWithoutEncryption,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +332,11 @@ fun GroupDMScreenContent(
|
||||
)
|
||||
}
|
||||
|
||||
val missingRelays by postViewModel.recipientsMissingDmRelays.collectAsStateWithLifecycle()
|
||||
if (missingRelays.isNotEmpty()) {
|
||||
RecipientMissingRelaysWarning(missingRelays, accountViewModel, nav)
|
||||
}
|
||||
|
||||
BottomRowActions(postViewModel, accountViewModel)
|
||||
}
|
||||
}
|
||||
@@ -383,9 +398,12 @@ private fun BottomRowActions(
|
||||
.height(50.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
if (postViewModel.room != null) {
|
||||
val room by postViewModel.room.collectAsStateWithLifecycle()
|
||||
|
||||
if (room != null) {
|
||||
SelectFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
enabled = !postViewModel.isUploadingFile,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
@@ -393,7 +411,8 @@ private fun BottomRowActions(
|
||||
}
|
||||
|
||||
SelectFromFiles(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
isUploading = postViewModel.isUploadingFile,
|
||||
enabled = !postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
@@ -412,7 +431,7 @@ private fun BottomRowActions(
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.room != null) {
|
||||
if (room != null) {
|
||||
TakePictureButton(
|
||||
onPictureTaken = { postViewModel.pickedMedia(it) },
|
||||
)
|
||||
@@ -431,7 +450,7 @@ private fun BottomRowActions(
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.room != null) {
|
||||
if (room != null) {
|
||||
TakeVideoButton(
|
||||
onVideoTaken = { postViewModel.pickedMedia(it) },
|
||||
)
|
||||
@@ -538,8 +557,6 @@ fun SendDirectMessageTo(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
ToggleNip17Button(postViewModel, accountViewModel)
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
|
||||
+163
-8
@@ -20,33 +20,51 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
|
||||
|
||||
import android.R.attr.maxLines
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.input.InputTransformation.Companion.keyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.showCount
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.RoomChatFileUploadDialog
|
||||
@@ -59,9 +77,13 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.PostKeyboard
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
@@ -88,10 +110,12 @@ fun PrivateMessageEditFieldRow(
|
||||
nav: INav,
|
||||
) {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
channelScreenModel.sendDraftSync()
|
||||
channelScreenModel.cancel()
|
||||
if (channelScreenModel.message.text.isNotBlank()) {
|
||||
accountViewModel.launchSigner {
|
||||
channelScreenModel.sendDraftSync()
|
||||
}
|
||||
}
|
||||
channelScreenModel.cancel()
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
@@ -114,6 +138,17 @@ fun PrivateMessageEditFieldRow(
|
||||
}
|
||||
}
|
||||
|
||||
StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation)
|
||||
|
||||
channelScreenModel.encryptedUploadErrorTitle?.let { title ->
|
||||
EncryptedUploadErrorDialog(
|
||||
title = title,
|
||||
message = channelScreenModel.encryptedUploadErrorMessage ?: "",
|
||||
onDismiss = channelScreenModel::dismissEncryptedUploadError,
|
||||
onRetryWithoutEncryption = channelScreenModel::retryWithoutEncryption,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = EditFieldModifier,
|
||||
) {
|
||||
@@ -151,7 +186,12 @@ fun PrivateMessageEditFieldRow(
|
||||
}
|
||||
}
|
||||
|
||||
EditField(channelScreenModel, onSendNewMessage, accountViewModel)
|
||||
val missingRelays by channelScreenModel.recipientsMissingDmRelays.collectAsStateWithLifecycle()
|
||||
if (missingRelays.isNotEmpty()) {
|
||||
RecipientMissingRelaysWarning(missingRelays, accountViewModel, nav)
|
||||
} else {
|
||||
EditField(channelScreenModel, onSendNewMessage, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +236,90 @@ fun EditField(
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RecipientMissingRelaysWarningPreview() {
|
||||
val user1 = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
|
||||
val user2 = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b")
|
||||
|
||||
ThemeComparisonColumn {
|
||||
RecipientMissingRelaysWarning(
|
||||
persistentListOf(user1, user2),
|
||||
mockAccountViewModel(),
|
||||
EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecipientMissingRelaysWarning(
|
||||
users: ImmutableList<User>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = SpacedBy10dp,
|
||||
) {
|
||||
UserGallery(users) { user ->
|
||||
ClickableUserPicture(
|
||||
user,
|
||||
Size25dp,
|
||||
accountViewModel,
|
||||
onClick = {
|
||||
nav.nav { routeFor(user) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.recipient_missing_dm_relays),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = Font12SP,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserGallery(
|
||||
users: ImmutableList<User>,
|
||||
galleryUser: @Composable RowScope.(user: User) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy((-10).dp),
|
||||
) {
|
||||
users.take(6).forEach {
|
||||
key(it.pubkeyHex) {
|
||||
galleryUser(it)
|
||||
}
|
||||
}
|
||||
|
||||
if (users.size > 6) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Size25dp)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Text(
|
||||
text = "+" + showCount(users.size - 6),
|
||||
fontSize = 10.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun KeyboardLeadingIcon(
|
||||
channelScreenModel: ChatNewMessageViewModel,
|
||||
@@ -203,7 +327,7 @@ fun KeyboardLeadingIcon(
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 10.dp),
|
||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
|
||||
) {
|
||||
SelectFromGallery(
|
||||
isUploading = channelScreenModel.isUploadingImage,
|
||||
@@ -211,7 +335,38 @@ fun KeyboardLeadingIcon(
|
||||
modifier = Modifier,
|
||||
onImageChosen = channelScreenModel::pickedMedia,
|
||||
)
|
||||
|
||||
ToggleNip17Button(channelScreenModel, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EncryptedUploadErrorDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
onRetryWithoutEncryption: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(message)
|
||||
Text(
|
||||
stringRes(R.string.upload_without_encryption_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onRetryWithoutEncryption) {
|
||||
Text(stringRes(R.string.retry_without_encryption))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringRes(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+9
-78
@@ -25,94 +25,25 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.IncognitoIconButtonModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ToggleNip17Button(
|
||||
channelScreenModel: ChatNewMessageViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
var wantsToActivateNIP17 by remember { mutableStateOf(false) }
|
||||
|
||||
if (wantsToActivateNIP17) {
|
||||
NewFeatureNIP17AlertDialog(
|
||||
accountViewModel = accountViewModel,
|
||||
onConfirm = { channelScreenModel.toggleNIP04And24() },
|
||||
onDismiss = { wantsToActivateNIP17 = false },
|
||||
)
|
||||
}
|
||||
|
||||
fun Nip17Indicator(channelScreenModel: ChatNewMessageViewModel) {
|
||||
IconButton(
|
||||
modifier = Modifier.width(30.dp),
|
||||
onClick = {
|
||||
if (
|
||||
!accountViewModel.account.settings.hideNIP17WarningDialog &&
|
||||
!channelScreenModel.nip17 &&
|
||||
!channelScreenModel.requiresNIP17
|
||||
) {
|
||||
wantsToActivateNIP17 = true
|
||||
} else {
|
||||
channelScreenModel.toggleNIP04And24()
|
||||
}
|
||||
},
|
||||
onClick = { },
|
||||
enabled = false,
|
||||
) {
|
||||
if (channelScreenModel.nip17) {
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.incognito, 2),
|
||||
contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message),
|
||||
modifier = IncognitoIconButtonModifier,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.incognito_off, 2),
|
||||
contentDescription = stringRes(id = R.string.accessibility_turn_on_sealed_message),
|
||||
modifier = IncognitoIconButtonModifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.incognito, 2),
|
||||
contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message),
|
||||
modifier = IncognitoIconButtonModifier,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewFeatureNIP17AlertDialog(
|
||||
accountViewModel: AccountViewModel,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
QuickActionAlertDialog(
|
||||
title = stringRes(R.string.new_feature_nip17_might_not_be_available_title),
|
||||
textContent = stringRes(R.string.new_feature_nip17_might_not_be_available_description),
|
||||
buttonIconResource = R.drawable.incognito,
|
||||
buttonIconReference = 3,
|
||||
buttonText = stringRes(R.string.new_feature_nip17_activate),
|
||||
onClickDoOnce = {
|
||||
scope.launch { onConfirm() }
|
||||
onDismiss()
|
||||
},
|
||||
onClickDontShowAgain = {
|
||||
scope.launch {
|
||||
onConfirm()
|
||||
accountViewModel.account.settings.setHideNIP17WarningDialog()
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user