Merge branch 'main' into claude/strip-file-metadata-sVIEd

This commit is contained in:
Vitor Pamplona
2026-03-17 13:25:54 -04:00
committed by GitHub
292 changed files with 17160 additions and 994 deletions
+1 -1
View File
@@ -119,7 +119,7 @@ Create 8 hybrid domain skills combining general expertise with AmethystMultiplat
**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation **Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation
**SKILL.md sections:** **SKILL.md sections:**
- iOS source sets: iosMain, iosX64Main, iosArm64Main - iOS source sets: iosMain, iosArm64Main
- Swift interop: type mapping, nullability - Swift interop: type mapping, nullability
- expect/actual iOS: 10+ examples from quartz/iosMain - expect/actual iOS: 10+ examples from quartz/iosMain
- XCFramework setup: baseName = "quartz-kmpKit" - XCFramework setup: baseName = "quartz-kmpKit"
Regular → Executable
View File
+3 -3
View File
@@ -9,18 +9,18 @@
} }
] ]
} }
] ],
"Stop": [ "Stop": [
{ {
"matcher": "", "matcher": "",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "./gradlew spotlessApply", "command": "./gradlew spotlessApply 2>/dev/null || spotless-apply",
"timeout": 120 "timeout": 120
} }
] ]
} }
] ]
} }
} }
@@ -47,7 +47,7 @@
### :quartz (KMP Nostr Library) ### :quartz (KMP Nostr Library)
**Type:** Kotlin Multiplatform Library **Type:** Kotlin Multiplatform Library
**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64) **Targets:** JVM, Android, iOS (iosArm64, iosSimulatorArm64)
**Dependencies:** **Dependencies:**
- External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable - External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain - Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain
@@ -127,7 +127,6 @@ commonMain (base)
│ ├─ androidMain (Android platform) │ ├─ androidMain (Android platform)
│ └─ jvmMain (Desktop platform) │ └─ jvmMain (Desktop platform)
└─ iosMain (iOS platform) └─ iosMain (iOS platform)
├─ iosX64Main
├─ iosArm64Main ├─ iosArm64Main
└─ iosSimulatorArm64Main └─ iosSimulatorArm64Main
``` ```
+3 -3
View File
@@ -110,8 +110,8 @@ Think of source sets as a dependency graph, not folders.
│ - Jackson │ │ │ │ - Jackson │ │ │
│ - OkHttp │ └────┬─────────────┘ │ - OkHttp │ └────┬─────────────┘
└───┬───────────┬───┘ │ └───┬───────────┬───┘ │
│ │ ├─→ iosX64Main │ │
▼ ▼ ├─→ iosArm64Main ▼ ▼ ├─→ iosArm64Main
┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main ┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main
│android │ │jvmMain │ │android │ │jvmMain │
│Main │ │(Desktop) │ │Main │ │(Desktop) │
@@ -252,7 +252,7 @@ expect fun currentTimeSeconds(): Long
**iOS (iosMain):** **iOS (iosMain):**
- Active development, framework configured - Active development, framework configured
- Architecture targets: iosX64Main, iosArm64Main, iosSimulatorArm64Main - Architecture targets: macosArm64Main, iosArm64Main, iosSimulatorArm64Main
- Platform APIs via platform.posix, Security framework - Platform APIs via platform.posix, Security framework
### Web, wasm - Future Targets ### Web, wasm - Future Targets
@@ -29,7 +29,7 @@ Visual guide to source set organization with concrete examples from the codebase
│ - Jackson │ │ - Platform libs │ │ - Jackson │ │ - Platform libs │
│ - OkHttp │ └───────┬───────────┘ │ - OkHttp │ └───────┬───────────┘
└────┬─────────┬───┘ │ └────┬─────────┬───┘ │
│ │ ├─→ iosX64Main (simulator Intel) │ │
│ │ ├─→ iosArm64Main (device ARM64) │ │ ├─→ iosArm64Main (device ARM64)
│ │ └─→ iosSimulatorArm64Main (Apple Silicon) │ │ └─→ iosSimulatorArm64Main (Apple Silicon)
▼ ▼ ▼ ▼
@@ -238,7 +238,6 @@ iosMain {
} }
} }
val iosX64Main by getting { dependsOn(iosMain.get()) }
val iosArm64Main by getting { dependsOn(iosMain.get()) } val iosArm64Main by getting { dependsOn(iosMain.get()) }
val iosSimulatorArm64Main 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 - Different from Android/Desktop
**Architecture targets:** **Architecture targets:**
- iosX64Main: Intel simulator
- iosArm64Main: Device (iPhone, iPad) - iosArm64Main: Device (iPhone, iPad)
- iosSimulatorArm64Main: Apple Silicon simulator - iosSimulatorArm64Main: Apple Silicon simulator
@@ -326,7 +324,7 @@ commonMain
| androidMain | jvmAndroid | Android framework | Activity, ViewModel | | androidMain | jvmAndroid | Android framework | Activity, ViewModel |
| jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar | | jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar |
| iosMain | commonMain | iOS platform | Security framework | | iosMain | commonMain | iOS platform | Security framework |
| iosX64Main | iosMain | Simulator (Intel) | Architecture-specific | | iosMain | Simulator (Intel) | Architecture-specific |
| iosArm64Main | iosMain | Device (ARM64) | Architecture-specific | | iosArm64Main | iosMain | Device (ARM64) | Architecture-specific |
| jsMain | commonMain | JS/DOM | Web (future) | | jsMain | commonMain | JS/DOM | Web (future) |
| wasmMain | commonMain | wasm APIs | WebAssembly (future) | | wasmMain | commonMain | wasm APIs | WebAssembly (future) |
@@ -76,7 +76,6 @@ fun main() = application {
**Source sets:** **Source sets:**
- iosMain (common iOS code) - iosMain (common iOS code)
- iosX64Main (Intel simulator)
- iosArm64Main (device - iPhone/iPad) - iosArm64Main (device - iPhone/iPad)
- iosSimulatorArm64Main (Apple Silicon simulator) - iosSimulatorArm64Main (Apple Silicon simulator)
@@ -110,7 +109,7 @@ actual object Secp256k1Instance {
```kotlin ```kotlin
// quartz/build.gradle.kts // quartz/build.gradle.kts
kotlin { kotlin {
listOf(iosX64(), iosArm64(), iosSimulatorArm64()) listOf(macosArm64(), iosArm64(), iosSimulatorArm64())
.forEach { target -> .forEach { target ->
target.binaries.framework { target.binaries.framework {
baseName = "quartz-kmpKit" baseName = "quartz-kmpKit"
@@ -310,7 +309,7 @@ fun parseJson(json: String): Event {
- Manual desktop app testing - Manual desktop app testing
**iOS:** **iOS:**
- Unit tests: iosTest (iosX64Test, iosArm64Test, etc.) - Unit tests: iosTest (iosArm64Test, etc.)
- Simulator/device testing - Simulator/device testing
**Web (future):** **Web (future):**
+1 -1
View File
@@ -12,7 +12,7 @@ echo "$JAVA_HOME"
echo "$(java -version)" echo "$(java -version)"
echo "Running test... " echo "Running test... "
./gradlew test ./gradlew test --quiet
status=$? status=$?
+69
View File
@@ -0,0 +1,69 @@
name: Build APK For Claude
on:
push:
branches:
- 'claude/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-benchmark:
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 Benchmark APK
run: ./gradlew assemblePlayBenchmark
- name: Upload Play Benchmark APK
id: upload
uses: actions/upload-artifact@v6
with:
name: Play Benchmark APK
path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk
- name: Comment on PR with APK link
uses: actions/github-script@v7
with:
script: |
const artifactId = `${{ steps.upload.outputs.artifact-id }}`;
const downloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${artifactId}`;
const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${downloadUrl})`;
const branch = context.ref.replace('refs/heads/', '');
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: `${context.repo.owner}:${branch}`,
state: 'open'
});
for (const pr of prs) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body
});
}
+134 -22
View File
@@ -1,4 +1,4 @@
name: Test/Build Android name: Test/Build
on: on:
pull_request: pull_request:
@@ -6,78 +6,190 @@ on:
push: push:
branches: [main] branches: [main]
jobs: concurrency:
build: group: ${{ github.workflow }}-${{ github.ref }}
runs-on: ubuntu-latest cancel-in-progress: true
timeout-minutes: 30
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 15
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: 21 java-version: 21
- name: Cache gradle - name: Cache gradle
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.gradle/caches path: |
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} ~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Linter (gradle) - name: Linter (gradle)
run: ./gradlew spotlessCheck 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) - name: Test (gradle)
run: ./gradlew test --no-daemon run: ./gradlew test --no-daemon
- name: Android Test Report - name: Android Test Report
uses: asadmansr/android-test-report-action@v1.2.0 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) - name: Build APK (gradle)
run: ./gradlew assembleDebug run: ./gradlew assembleDebug
- name: Upload Play APK - name: Upload Play APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: Play Debug APK name: Play Debug APK
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
- name: Upload FDroid APK - name: Upload FDroid APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: FDroid Debug APK name: FDroid Debug APK
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-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 run: ./gradlew assembleBenchmark
- name: Upload Play APK Benchmark - name: Upload Play APK Benchmark
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: Play Benchmark APK name: Play Benchmark APK
path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk
- name: Upload FDroid APK Benchmark - name: Upload FDroid APK Benchmark
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: FDroid Benchmark APK name: FDroid Benchmark APK
path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk
- name: Upload Compose Reports - name: Upload Compose Reports
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: Compose Reports name: Compose Reports
path: amethyst/build/compose_compiler path: amethyst/build/compose_compiler
- name: Upload Test Results build-desktop:
uses: actions/upload-artifact@v4 needs: test
with: strategy:
name: Test Reports fail-fast: false
path: amethyst/build/reports 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 }}
+99 -35
View File
@@ -6,23 +6,42 @@ on:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
jobs: 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 runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: 21 java-version: 21
- name: Cache gradle - name: Cache gradle
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.gradle/caches path: |
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} ~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
@@ -38,7 +57,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }}
env: env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0" BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign AAB (F-Droid) - name: Sign AAB (F-Droid)
@@ -50,7 +68,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }}
env: env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0" BUILD_TOOLS_VERSION: "36.0.0"
- name: Build APK - name: Build APK
@@ -65,7 +82,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }}
env: env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0" BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign APK (F-Droid) - name: Sign APK (F-Droid)
@@ -77,20 +93,8 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }}
env: env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0" 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 # Google Play APK
- name: Upload Play APK Universal Asset - name: Upload Play APK Universal Asset
id: upload-release-asset-play-universal-apk id: upload-release-asset-play-universal-apk
@@ -98,7 +102,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip asset_content_type: application/zip
@@ -109,7 +113,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip asset_content_type: application/zip
@@ -120,7 +124,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_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_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip asset_content_type: application/zip
@@ -131,7 +135,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_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_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip asset_content_type: application/zip
@@ -142,7 +146,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_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_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip asset_content_type: application/zip
@@ -154,7 +158,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip asset_content_type: application/zip
@@ -165,7 +169,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip asset_content_type: application/zip
@@ -176,7 +180,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_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_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip asset_content_type: application/zip
@@ -187,7 +191,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_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_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip asset_content_type: application/zip
@@ -198,13 +202,11 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_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_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip asset_content_type: application/zip
# Google Play AAB # Google Play AAB
- name: Upload Google Play AAB Asset - name: Upload Google Play AAB Asset
id: upload-release-asset-play-aab id: upload-release-asset-play-aab
@@ -212,7 +214,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
asset_content_type: application/zip asset_content_type: application/zip
@@ -224,7 +226,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip asset_content_type: application/zip
@@ -236,3 +238,65 @@ jobs:
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} 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
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2
+4
View File
@@ -22,6 +22,7 @@
/.idea/AndroidProjectSystem.xml /.idea/AndroidProjectSystem.xml
/.idea/deviceManager.xml /.idea/deviceManager.xml
/.idea/inspectionProfiles/ /.idea/inspectionProfiles/
/.idea/migrations.xml
/commons/.idea/gradle.xml /commons/.idea/gradle.xml
/commons/.idea/misc.xml /commons/.idea/misc.xml
/commons/.idea/workspace.xml /commons/.idea/workspace.xml
@@ -149,3 +150,6 @@ lint/tmp/
# Local task tracking # Local task tracking
TASKS.md TASKS.md
# Claude Code local settings
.claude/settings.local.json
+3 -1
View File
@@ -15,7 +15,7 @@ Redesigns Media Player
- Turn video controller creation into a flow to fix playback lifecycle issues - Turn video controller creation into a flow to fix playback lifecycle issues
- Adds support for uploading audio - Adds support for uploading audio
Adds support for NIP events (kind 30817) Adds support for NIP-47 Wallets
Adds support for NIP-52 Calendar appointments Adds support for NIP-52 Calendar appointments
@@ -23,6 +23,8 @@ Adds support for NIP-39 External Identities with kind 10011
Adds support for NIP-C0 Code Snippets 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 NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
Adds support for BUD-10 "Blossom:" URIs in images, audios, videos, and documents. Adds support for BUD-10 "Blossom:" URIs in images, audios, videos, and documents.
+7
View File
@@ -218,6 +218,13 @@ Build and run the Desktop app (requires Java 21+):
```bash ```bash
./gradlew :desktopApp:run ./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 ## Testing
```bash ```bash
+11 -2
View File
@@ -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 { android {
namespace = 'com.vitorpamplona.amethyst' namespace = 'com.vitorpamplona.amethyst'
compileSdk = libs.versions.android.compileSdk.get().toInteger() compileSdk = libs.versions.android.compileSdk.get().toInteger()
@@ -336,9 +347,7 @@ dependencies {
fdroidImplementation libs.unifiedpush fdroidImplementation libs.unifiedpush
// Charts // Charts
implementation libs.vico.charts.core
implementation libs.vico.charts.compose implementation libs.vico.charts.compose
implementation libs.vico.charts.views
implementation libs.vico.charts.m3 implementation libs.vico.charts.m3
// GeoHash // GeoHash
@@ -171,6 +171,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -225,7 +226,6 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
@@ -507,7 +507,7 @@ class Account(
sendNewAppSpecificData() sendNewAppSpecificData()
} }
suspend fun updateTranslateTo(languageCode: Locale) { suspend fun updateTranslateTo(languageCode: String) {
if (settings.updateTranslateTo(languageCode)) { if (settings.updateTranslateTo(languageCode)) {
sendNewAppSpecificData() sendNewAppSpecificData()
} }
@@ -591,6 +591,14 @@ class Account(
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState) 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( suspend fun sendZapPaymentRequestFor(
bolt11: String, bolt11: String,
zappedNote: Note?, zappedNote: Note?,
@@ -2008,6 +2016,7 @@ class Account(
} }
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
@OptIn(kotlinx.coroutines.FlowPreview::class)
settings.saveable.debounce(1000).collect { settings.saveable.debounce(1000).collect {
if (it.accountSettings != null) { if (it.accountSettings != null) {
LocalPreferences.saveToEncryptedStorage(it.accountSettings) LocalPreferences.saveToEncryptedStorage(it.accountSettings)
@@ -63,7 +63,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.Locale
val DefaultChannels = val DefaultChannels =
listOf( listOf(
@@ -340,11 +339,11 @@ class AccountSettings(
saveAccountSettings() saveAccountSettings()
} }
fun translateToContains(languageCode: Locale) = fun translateToContains(languageCode: String) =
syncedSettings.languages.translateTo.value syncedSettings.languages.translateTo.value
.contains(languageCode.language) .contains(languageCode)
fun updateTranslateTo(languageCode: Locale): Boolean { fun updateTranslateTo(languageCode: String): Boolean {
if (syncedSettings.languages.updateTranslateTo(languageCode)) { if (syncedSettings.languages.updateTranslateTo(languageCode)) {
saveAccountSettings() saveAccountSettings()
return true return true
@@ -27,7 +27,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import java.util.Locale
@Stable @Stable
class AccountSyncedSettings( class AccountSyncedSettings(
@@ -165,11 +164,11 @@ class AccountLanguagePreferences(
dontTranslateFrom.update { it - languageCode } 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 { fun updateTranslateTo(languageCode: String): Boolean {
if (translateTo.value != languageCode.language) { if (translateTo.value != languageCode) {
translateTo.tryEmit(languageCode.language) translateTo.tryEmit(languageCode)
return true return true
} }
return false return false
@@ -358,19 +358,19 @@ object LocalCache : ILocalCache, ICacheProvider {
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser) fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
override fun getOrCreateUser(key: HexKey): User { override fun getOrCreateUser(pubkey: HexKey): User {
require(isValidHex(key = key)) { "$key is not a valid hex" } require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
return users.getOrCreate(key) { return users.getOrCreate(pubkey) {
val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(key)) val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(pubkey))
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(key)) val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(pubkey))
User(it, nip65RelayListNote, dmRelayListNote) User(it, nip65RelayListNote, dmRelayListNote)
} }
} }
override fun getUserIfExists(key: String): User? { override fun getUserIfExists(pubkey: String): User? {
if (key.isEmpty()) return null if (pubkey.isEmpty()) return null
return users.get(key) return users.get(pubkey)
} }
override fun countUsers(predicate: (String, User) -> Boolean): Int { override fun countUsers(predicate: (String, User) -> Boolean): Int {
@@ -394,7 +394,7 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address) 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) fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId)
@@ -2250,6 +2250,7 @@ object LocalCache : ILocalCache, ICacheProvider {
requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } requestNote?.let { request -> zappedNote?.addZapPayment(request, note) }
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
responseCallback(event) responseCallback(event)
} }
@@ -114,7 +114,7 @@ class NwcSignerState(
fun hasWalletConnectSetup(): Boolean = nip47Setup.value != null 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. * Decrypts a NIP-47 payment request using the current signer.
@@ -138,6 +138,45 @@ class NwcSignerState(
return zapPaymentResponseDecryptionCache.value.decryptResponse(event) 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. * Sends a zap payment request to a connected Lightning wallet.
* Subscribes to responses and waits up to 60s for a reply. * Subscribes to responses and waits up to 60s for a reply.
@@ -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.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -181,7 +181,7 @@ class LabeledBookmarkListsState(
val template = val template =
listEvent.update { listEvent.update {
if (listName != null) name(listName) if (listName != null) title(listName)
if (listDescription != null) description(listDescription) if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage) if (listImage != null) image(listImage)
} }
@@ -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.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.description import com.vitorpamplona.quartz.nip51Lists.peopleList.description
import com.vitorpamplona.quartz.nip51Lists.peopleList.image 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 com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -226,7 +226,7 @@ class PeopleListsState(
val template = val template =
listEvent.update { listEvent.update {
if (listName != null) name(listName) if (listName != null) title(listName)
if (listDescription != null) description(listDescription) if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage) if (listImage != null) image(listImage)
} }
@@ -79,6 +79,7 @@ class MergedFollowListsState(
communities = community.mapTo(mutableSetOf()) { it.address.toValue() }, communities = community.mapTo(mutableSetOf()) { it.address.toValue() },
) )
@OptIn(kotlinx.coroutines.FlowPreview::class)
val flow: StateFlow<AllFollows> = val flow: StateFlow<AllFollows> =
combine( combine(
listOf( listOf(
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service.cashu.v4 package com.vitorpamplona.amethyst.service.cashu.v4
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString import kotlinx.serialization.cbor.ByteString
@@ -34,6 +35,7 @@ class V4Token(
val t: Array<V4T>?, val t: Array<V4T>?,
) )
@OptIn(ExperimentalSerializationApi::class)
@Serializable @Serializable
class V4T( class V4T(
// identifier // identifier
@@ -42,6 +44,7 @@ class V4T(
val p: Array<V4Proof>, val p: Array<V4Proof>,
) )
@OptIn(ExperimentalSerializationApi::class)
@Serializable @Serializable
class V4Proof( class V4Proof(
// amount // amount
@@ -57,6 +60,7 @@ class V4Proof(
val w: String? = null, val w: String? = null,
) )
@OptIn(ExperimentalSerializationApi::class)
@Serializable @Serializable
class V4DleqProof( class V4DleqProof(
@ByteString @ByteString
@@ -51,14 +51,14 @@ class EventWatcherSubAssembler(
} }
override fun updateFilter( override fun updateFilter(
key: List<EventFinderQueryState>, keys: List<EventFinderQueryState>,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter>? { ): List<RelayBasedFilter>? {
if (key.isEmpty()) { if (keys.isEmpty()) {
return null return null
} }
lastNotesOnFilter = key.map { it.note } lastNotesOnFilter = keys.map { it.note }
return groupByRelayPresence(lastNotesOnFilter, latestEOSEs) return groupByRelayPresence(lastNotesOnFilter, latestEOSEs)
.map { group -> .map { group ->
@@ -264,6 +264,7 @@ fun EditPostView(
ImageVideoDescription( ImageVideoDescription(
it, it,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context) postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context)
accountViewModel.account.settings.changeDefaultFileServer(server) accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -372,6 +373,7 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) {
) { ) {
SelectFromGallery( SelectFromGallery(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -379,7 +381,8 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) {
} }
SelectFromFiles( SelectFromFiles(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingFile,
enabled = !postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName 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.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
@@ -79,7 +80,9 @@ open class EditPostViewModel : ViewModel() {
var message by mutableStateOf(TextFieldValue("")) var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null) 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 userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null
@@ -180,7 +183,7 @@ open class EditPostViewModel : ViewModel() {
val myAccount = account val myAccount = account
val myMultiOrchestrator = multiOrchestrator ?: return@launch val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
val results = val results =
myMultiOrchestrator.upload( myMultiOrchestrator.upload(
@@ -243,7 +246,7 @@ open class EditPostViewModel : ViewModel() {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
} }
isUploadingImage = false mediaUploadTracker.finishUpload()
} }
} }
@@ -255,7 +258,7 @@ open class EditPostViewModel : ViewModel() {
multiOrchestrator = null multiOrchestrator = null
urlPreview = null urlPreview = null
isUploadingImage = false mediaUploadTracker.finishUpload()
wantsInvoice = false wantsInvoice = false
@@ -296,7 +299,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>) { fun selectImage(uris: ImmutableList<SelectedMedia>) {
multiOrchestrator = MultiOrchestrator(uris) multiOrchestrator = MultiOrchestrator(uris)
@@ -53,7 +53,7 @@ class BlossomServersViewModel : ViewModel() {
fun refresh() { fun refresh() {
isModified = false isModified = false
_fileServers.update { _fileServers.update {
val obtainedFileServers = obtainFileServers() ?: emptyList() val obtainedFileServers = obtainFileServers()
obtainedFileServers.mapNotNull { serverUrl -> obtainedFileServers.mapNotNull { serverUrl ->
try { try {
ServerName( ServerName(
@@ -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
}
}
@@ -48,6 +48,7 @@ import java.util.concurrent.atomic.AtomicBoolean
@Composable @Composable
fun SelectFromFiles( fun SelectFromFiles(
isUploading: Boolean, isUploading: Boolean,
enabled: Boolean = true,
tint: Color, tint: Color,
modifier: Modifier, modifier: Modifier,
onFilesChosen: (ImmutableList<SelectedMedia>) -> Unit, onFilesChosen: (ImmutableList<SelectedMedia>) -> Unit,
@@ -64,19 +65,20 @@ fun SelectFromFiles(
) )
} }
FileSelectButton(isUploading, tint, modifier) { showFileSelect = true } FileSelectButton(isUploading, enabled, tint, modifier) { showFileSelect = true }
} }
@Composable @Composable
private fun FileSelectButton( private fun FileSelectButton(
isUploading: Boolean, isUploading: Boolean,
enabled: Boolean,
tint: Color, tint: Color,
modifier: Modifier, modifier: Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
IconButton( IconButton(
modifier = modifier, modifier = modifier,
enabled = !isUploading, enabled = enabled && !isUploading,
onClick = { onClick() }, onClick = { onClick() },
) { ) {
if (!isUploading) { if (!isUploading) {
@@ -69,6 +69,7 @@ class SelectedMedia(
@Composable @Composable
fun SelectFromGallery( fun SelectFromGallery(
isUploading: Boolean, isUploading: Boolean,
enabled: Boolean = true,
tint: Color, tint: Color,
modifier: Modifier, modifier: Modifier,
onImageChosen: (ImmutableList<SelectedMedia>) -> Unit, onImageChosen: (ImmutableList<SelectedMedia>) -> Unit,
@@ -85,7 +86,7 @@ fun SelectFromGallery(
) )
} }
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true } GallerySelectButton(isUploading, enabled, tint, modifier) { showGallerySelect = true }
} }
@Composable @Composable
@@ -107,19 +108,20 @@ fun SelectSingleFromGallery(
) )
} }
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true } GallerySelectButton(isUploading, true, tint, modifier) { showGallerySelect = true }
} }
@Composable @Composable
private fun GallerySelectButton( private fun GallerySelectButton(
isUploading: Boolean, isUploading: Boolean,
enabled: Boolean,
tint: Color, tint: Color,
modifier: Modifier, modifier: Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
IconButton( IconButton(
modifier = modifier, modifier = modifier,
enabled = !isUploading, enabled = enabled && !isUploading,
onClick = { onClick() }, onClick = { onClick() },
) { ) {
if (!isUploading) { if (!isUploading) {
@@ -119,11 +119,12 @@ object ShareHelper {
bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header) bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header)
// MP4/MOV alternative: moov, mdat, or free at offset 4 // MP4/MOV alternative: moov, mdat, or free at offset 4
bytesRead >= 8 && ( bytesRead >= 8 &&
matchesMagicNumbers(header, 4, MOV_MOOV) || (
matchesMagicNumbers(header, 4, MOV_MDAT) || matchesMagicNumbers(header, 4, MOV_MOOV) ||
matchesMagicNumbers(header, 4, MOV_FREE) matchesMagicNumbers(header, 4, MOV_MDAT) ||
) -> "mp4" matchesMagicNumbers(header, 4, MOV_FREE)
) -> "mp4"
else -> defaultExtension else -> defaultExtension
} }
@@ -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)
}
@@ -37,8 +37,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
@@ -120,6 +122,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.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen 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.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.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -138,7 +144,17 @@ fun AppNavigation(
) { ) {
val nav = rememberNav() 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( NavHost(
navController = nav.controller, navController = nav.controller,
startDestination = Route.Home, startDestination = Route.Home,
@@ -152,6 +168,11 @@ fun AppNavigation(
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) } composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(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) } composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) } composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) } composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
@@ -46,6 +46,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Delete 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.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.GroupAdd import androidx.compose.material.icons.outlined.GroupAdd
@@ -463,6 +464,14 @@ fun ListContent(
route = Route.Drafts, route = Route.Drafts,
) )
NavigationRow(
title = R.string.wallet,
icon = Icons.Outlined.AccountBalanceWallet,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Wallet,
)
NavigationRow( NavigationRow(
title = R.string.route_chess, title = R.string.route_chess,
icon = R.drawable.ic_chess, icon = R.drawable.ic_chess,
@@ -43,6 +43,14 @@ sealed class Route {
@Serializable object Chess : 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 Search : Route()
@Serializable object SecurityFilters : Route() @Serializable object SecurityFilters : Route()
@@ -62,7 +62,7 @@ fun BadgeCompose(
nav: INav, nav: INav,
) { ) {
val noteState by observeNote(likeSetCard.note, accountViewModel) val noteState by observeNote(likeSetCard.note, accountViewModel)
val note = noteState?.note val note = noteState.note
val context = LocalContext.current.applicationContext val context = LocalContext.current.applicationContext
@@ -30,10 +30,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
@@ -49,10 +49,10 @@ fun showAmountInteger(amount: BigDecimal?): String {
if (amount.abs() < BigDecimal(0.01)) return "" if (amount.abs() < BigDecimal(0.01)) return ""
return when { return when {
amount >= OneGiga -> dfG.get().format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) 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 >= 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)) amount >= TenKilo -> dfK.get()?.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) ?: ""
else -> dfN.get().format(amount) else -> dfN.get()?.format(amount) ?: ""
} }
} }
@@ -118,13 +118,13 @@ class PollNoteViewModel : ViewModel() {
it.zappedValue.value = zappedValue it.zappedValue.value = zappedValue
it.tally.value = tallyValue.toFloat() it.tally.value = tallyValue.toFloat()
it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!! 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 { fun checkIfCanZap(): Boolean {
val account = account ?: return false val account = account
val note = pollNote ?: return false val note = pollNote ?: return false
return account.userProfile() != note.author && !wasZappedByLoggedInAccount return account.userProfile() != note.author && !wasZappedByLoggedInAccount
} }
@@ -78,6 +78,7 @@ import kotlinx.collections.immutable.toImmutableList
fun ImageVideoDescription( fun ImageVideoDescription(
uris: MultiOrchestrator, uris: MultiOrchestrator,
defaultServer: ServerName, defaultServer: ServerName,
isUploading: Boolean,
onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit, onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit,
onDelete: (SelectedMediaProcessing) -> Unit, onDelete: (SelectedMediaProcessing) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
@@ -319,6 +320,7 @@ fun ImageVideoDescription(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 10.dp), .padding(vertical = 10.dp),
enabled = !isUploading,
onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) }, onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) },
shape = QuoteBorder, shape = QuoteBorder,
colors = colors =
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName 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.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
@@ -149,7 +150,9 @@ open class CommentPostViewModel :
val urlPreviews = PreviewState() 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 userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null
@@ -487,7 +490,7 @@ open class CommentPostViewModel :
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val myMultiOrchestrator = multiOrchestrator ?: return@launch val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
val results = val results =
myMultiOrchestrator.upload( myMultiOrchestrator.upload(
@@ -551,7 +554,7 @@ open class CommentPostViewModel :
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
} }
isUploadingImage = false mediaUploadTracker.finishUpload()
} }
} }
@@ -564,7 +567,7 @@ open class CommentPostViewModel :
externalIdentity = null externalIdentity = null
multiOrchestrator = null multiOrchestrator = null
isUploadingImage = false mediaUploadTracker.finishUpload()
notifying = null notifying = null
@@ -676,7 +679,7 @@ open class CommentPostViewModel :
fun canPost(): Boolean = fun canPost(): Boolean =
message.text.isNotBlank() && message.text.isNotBlank() &&
!isUploadingImage && !mediaUploadTracker.isUploading &&
!wantsInvoice && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) && (!wantsZapraiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null multiOrchestrator == null
@@ -300,6 +300,7 @@ private fun GenericCommentPostBody(
ImageVideoDescription( ImageVideoDescription(
it, it,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
accountViewModel.account.settings.changeDefaultFileServer(server) accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -391,6 +392,7 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) {
) { ) {
SelectFromGallery( SelectFromGallery(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -398,7 +400,8 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) {
} }
SelectFromFiles( SelectFromFiles(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingFile,
enabled = !postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -96,7 +96,7 @@ fun RenderLiveChessChallenge(
nav: INav, nav: INav,
) { ) {
val event = (note.event as? LiveChessGameChallengeEvent) ?: return val event = (note.event as? LiveChessGameChallengeEvent) ?: return
val gameId = event.gameId() ?: return val gameId = event.gameId()
val chessViewModel: ChessViewModelNew = val chessViewModel: ChessViewModelNew =
viewModel( viewModel(
@@ -72,9 +72,9 @@ private fun ObserverAndRenderNIP95(
val content by val content by
remember(noteState) { remember(noteState) {
// Creates a new object when the event arrives to force an update of the image. // 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 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 blurHash = eventHeader.blurhash()
val dimensions = eventHeader.dimensions() val dimensions = eventHeader.dimensions()
val description = eventHeader.alt() ?: eventHeader.content val description = eventHeader.alt() ?: eventHeader.content
@@ -163,7 +163,7 @@ fun RenderTextModificationEvent(
} }
LaunchedEffect(key1 = noteState) { LaunchedEffect(key1 = noteState) {
val newAuthor = accountViewModel.isLoggedUser(noteState?.note?.author) val newAuthor = accountViewModel.isLoggedUser(noteState.note.author)
if (isAuthorTheLoggedUser.value != newAuthor) { if (isAuthorTheLoggedUser.value != newAuthor) {
isAuthorTheLoggedUser.value = newAuthor isAuthorTheLoggedUser.value = newAuthor
@@ -49,6 +49,7 @@ fun AccountSwitcherAndLeftDrawerLayout(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
accountSessionManager: AccountSessionManager, accountSessionManager: AccountSessionManager,
nav: INav, nav: INav,
gesturesEnabled: Boolean = true,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -83,6 +84,7 @@ fun AccountSwitcherAndLeftDrawerLayout(
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = nav.drawerState, drawerState = nav.drawerState,
gesturesEnabled = gesturesEnabled,
drawerContent = { drawerContent = {
DrawerContent(nav, openSheetFunction, accountViewModel) DrawerContent(nav, openSheetFunction, accountViewModel)
BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer) BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer)
@@ -158,7 +158,6 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.Locale
@Stable @Stable
class AccountViewModel( class AccountViewModel(
@@ -1009,7 +1008,7 @@ class AccountViewModel(
fun removeDontTranslateFrom(languageCode: String) = launchSigner { account.removeDontTranslateFrom(languageCode) } 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( fun prefer(
source: String, source: String,
@@ -141,7 +141,8 @@ class ChatNewMessageViewModel :
val urlPreviews = PreviewState() 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 userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null
@@ -403,7 +404,16 @@ class ChatNewMessageViewModel :
accountViewModel.launchSigner { accountViewModel.launchSigner {
if (nip17) { if (nip17) {
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { ChatFileUploader(account).justUploadNIP17(
uploadState,
onError,
onEncryptedUploadError = { title, message ->
encryptedUploadErrorTitle = title
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.HOLD
},
context,
) {
uploadsWaitingToBeSent += it uploadsWaitingToBeSent += it
draftTag.newVersion() draftTag.newVersion()
onceUploaded() onceUploaded()
@@ -428,7 +438,19 @@ class ChatNewMessageViewModel :
accountViewModel.launchSigner { accountViewModel.launchSigner {
if (nip17) { if (nip17) {
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { ChatFileUploader(account).justUploadNIP17(
uploadState,
onError,
onEncryptedUploadError = { title, message ->
encryptedUploadErrorTitle = title
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.SEND
pendingRetryOnError = onError
pendingRetryContext = context
pendingRetryOnceUploaded = onceUploaded
},
context,
) {
ChatFileSender(room, account).sendNIP17(it) ChatFileSender(room, account).sendNIP17(it)
draftTag.newVersion() draftTag.newVersion()
onceUploaded() onceUploaded()
@@ -443,6 +465,69 @@ class ChatNewMessageViewModel :
} }
} }
// 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
val uploadState = uploadState
dismissEncryptedUploadError()
if (uploadState == null || context == null) return
uploadState.encryptFiles = false
accountViewModel.launchSigner {
when (mode) {
RetryMode.HOLD -> {
ChatFileUploader(account).justUploadNIP17Unencrypted(
uploadState,
onError ?: accountViewModel.toastManager::toast,
context,
) {
uploadsWaitingToBeSent += it
draftTag.newVersion()
onceUploaded?.invoke()
}
}
RetryMode.SEND -> {
if (room == null) return@launchSigner
ChatFileUploader(account).justUploadNIP17Unencrypted(
uploadState,
onError ?: accountViewModel.toastManager::toast,
context,
) {
ChatFileSender(room, account).sendNIP17(it)
draftTag.newVersion()
onceUploaded?.invoke()
}
}
}
}
}
private suspend fun innerSendPost(draftTag: String?) { private suspend fun innerSendPost(draftTag: String?) {
val room = room ?: return val room = room ?: return
@@ -561,6 +646,9 @@ class ChatNewMessageViewModel :
userSuggestionsMainMessage = null userSuggestionsMainMessage = null
uploadsWaitingToBeSent = emptyList() uploadsWaitingToBeSent = emptyList()
uploadState?.reset()
dismissEncryptedUploadError()
iMetaAttachments.reset() iMetaAttachments.reset()
@@ -692,7 +780,7 @@ class ChatNewMessageViewModel :
fun canPost(): Boolean = fun canPost(): Boolean =
message.text.isNotBlank() && message.text.isNotBlank() &&
uploadState?.isUploadingImage != true && uploadState?.mediaUploadTracker?.isUploading != true &&
!wantsInvoice && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) && (!wantsZapraiser || zapRaiserAmount.value != null) &&
(toUsers.text.isNotBlank()) && (toUsers.text.isNotBlank()) &&
@@ -287,6 +287,7 @@ fun GroupDMScreenContent(
ImageVideoDescription( ImageVideoDescription(
selectedFiles, selectedFiles,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
isUploading = uploading.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.uploadAndHold( postViewModel.uploadAndHold(
accountViewModel.toastManager::toast, accountViewModel.toastManager::toast,
@@ -301,6 +302,15 @@ fun GroupDMScreenContent(
) )
} }
} }
postViewModel.encryptedUploadErrorTitle?.let { title ->
EncryptedUploadErrorDialog(
title = title,
message = postViewModel.encryptedUploadErrorMessage ?: "",
onDismiss = postViewModel::dismissEncryptedUploadError,
onRetryWithoutEncryption = postViewModel::retryWithoutEncryption,
)
}
} }
} }
@@ -386,6 +396,7 @@ private fun BottomRowActions(
if (postViewModel.room != null) { if (postViewModel.room != null) {
SelectFromGallery( SelectFromGallery(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -393,7 +404,8 @@ private fun BottomRowActions(
} }
SelectFromFiles( SelectFromFiles(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingFile,
enabled = !postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -27,8 +27,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -88,10 +90,12 @@ fun PrivateMessageEditFieldRow(
nav: INav, nav: INav,
) { ) {
BackHandler { BackHandler {
accountViewModel.launchSigner { if (channelScreenModel.message.text.isNotBlank()) {
channelScreenModel.sendDraftSync() accountViewModel.launchSigner {
channelScreenModel.cancel() channelScreenModel.sendDraftSync()
}
} }
channelScreenModel.cancel()
nav.popBack() nav.popBack()
} }
@@ -114,6 +118,15 @@ fun PrivateMessageEditFieldRow(
} }
} }
channelScreenModel.encryptedUploadErrorTitle?.let { title ->
EncryptedUploadErrorDialog(
title = title,
message = channelScreenModel.encryptedUploadErrorMessage ?: "",
onDismiss = channelScreenModel::dismissEncryptedUploadError,
onRetryWithoutEncryption = channelScreenModel::retryWithoutEncryption,
)
}
Column( Column(
modifier = EditFieldModifier, modifier = EditFieldModifier,
) { ) {
@@ -215,3 +228,36 @@ fun KeyboardLeadingIcon(
ToggleNip17Button(channelScreenModel, accountViewModel) 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))
}
},
)
}
@@ -24,11 +24,14 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.ciphers.AESGCM
class ChatFileSender( class ChatFileSender(
@@ -39,6 +42,8 @@ class ChatFileSender(
uploads.forEach { uploads.forEach {
if (it.cipher != null) { if (it.cipher != null) {
sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher)
} else {
sendNIP17AsHiddenLink(it.result, it.caption, it.contentWarningReason)
} }
} }
} }
@@ -70,6 +75,31 @@ class ChatFileSender(
) )
} }
suspend fun sendNIP17AsHiddenLink(
result: UploadOrchestrator.OrchestratorResult.ServerResult,
caption: String?,
contentWarningReason: String?,
) {
val iMetaAttachments = IMetaAttachments()
iMetaAttachments.add(result, caption, contentWarningReason)
val toUsers = chatroom.users.map { LocalCache.getOrCreateUser(it).toPTag() }
val template =
ChatMessageEvent.build(result.url, toUsers) {
references(listOf(result.url))
if (!caption.isNullOrEmpty()) {
alt(caption)
}
contentWarningReason?.let { contentWarning(it) }
imetas(iMetaAttachments.filterIsIn(setOf(result.url)))
}
account.sendNip17PrivateMessage(template)
}
// ------ // ------
// NIP 04 // NIP 04
// ------ // ------
@@ -75,5 +75,6 @@ fun RoomChatFileUploadDialog(
onCancel, onCancel,
accountViewModel, accountViewModel,
nav, nav,
isNip17 = channelScreenModel.nip17,
) )
} }
@@ -39,59 +39,62 @@ class ChatFileUploader(
suspend fun justUploadNIP17( suspend fun justUploadNIP17(
viewState: ChatFileUploadState, viewState: ChatFileUploadState,
onError: (title: String, message: String) -> Unit, onError: (title: String, message: String) -> Unit,
onEncryptedUploadError: (title: String, message: String) -> Unit,
context: Context, context: Context,
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit, onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
) { ) {
val orchestrator = viewState.multiOrchestrator ?: return val orchestrator = viewState.multiOrchestrator ?: return
viewState.isUploadingImage = true viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia())
val cipher = AESGCM() if (viewState.encryptFiles) {
val cipher = AESGCM()
val results = val results =
orchestrator.uploadEncrypted( orchestrator.uploadEncrypted(
viewState.caption, viewState.caption,
viewState.contentWarningReason, viewState.contentWarningReason,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
cipher, cipher,
viewState.selectedServer, viewState.selectedServer,
account, account,
context, context,
stripMetadata = viewState.stripLocationMetadata, )
)
if (results.allGood) { if (results.allGood) {
val list = val list =
results.successful.mapNotNull { state -> results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher)
} else { } else {
null null
}
} }
}
onceUploaded(list) onceUploaded(list)
viewState.reset() viewState.reset()
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
onEncryptedUploadError(
stringRes(context, R.string.failed_to_upload_encrypted_media_title),
stringRes(context, R.string.failed_to_upload_encrypted_media_message) + "\n\n" + errorMessages.joinToString(".\n"),
)
}
} else { } else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() justUploadNIP17Unencrypted(viewState, onError, context, onceUploaded)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
} }
viewState.isUploadingImage = false viewState.mediaUploadTracker.finishUpload()
} }
// ------ suspend fun justUploadNIP17Unencrypted(
// NIP 04
// ------
suspend fun justUploadNIP04(
viewState: ChatFileUploadState, viewState: ChatFileUploadState,
onError: (title: String, message: String) -> Unit, onError: (title: String, message: String) -> Unit,
context: Context, context: Context,
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit, onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
) { ) {
val orchestrator = viewState.multiOrchestrator ?: return val orchestrator = viewState.multiOrchestrator ?: return
viewState.isUploadingImage = true viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia())
val results = val results =
orchestrator.upload( orchestrator.upload(
@@ -122,6 +125,51 @@ class ChatFileUploader(
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
} }
viewState.isUploadingImage = false viewState.mediaUploadTracker.finishUpload()
}
// ------
// NIP 04
// ------
suspend fun justUploadNIP04(
viewState: ChatFileUploadState,
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
) {
val orchestrator = viewState.multiOrchestrator ?: return
viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia())
val results =
orchestrator.upload(
viewState.caption,
viewState.contentWarningReason,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
viewState.selectedServer,
account,
context,
stripMetadata = viewState.stripLocationMetadata,
)
if (results.allGood) {
val list =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null)
} else {
null
}
}
onceUploaded(list)
viewState.reset()
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
viewState.mediaUploadTracker.finishUpload()
} }
} }
@@ -105,7 +105,7 @@ class ChannelMetadataViewModel : ViewModel() {
fun createOrUpdate(onDone: (PublicChatChannel) -> Unit) { fun createOrUpdate(onDone: (PublicChatChannel) -> Unit) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
account?.let { account -> account.let { account ->
val channel = originalChannel val channel = originalChannel
if (channel == null) { if (channel == null) {
val template = val template =
@@ -205,7 +205,7 @@ class ChannelMetadataViewModel : ViewModel() {
onUploaded: (String) -> Unit, onUploaded: (String) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
) { ) {
val account = account ?: return val account = account
onUploading(true) onUploading(true)
val strippedUri = val strippedUri =
@@ -134,7 +134,8 @@ open class ChannelNewMessageViewModel :
var message by mutableStateOf(TextFieldValue("")) var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null) var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false) val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false
val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false
var userSuggestions: UserSuggestionState? = null var userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null
@@ -337,7 +338,7 @@ open class ChannelNewMessageViewModel :
val myMultiOrchestrator = uploadState.multiOrchestrator ?: return@launch val myMultiOrchestrator = uploadState.multiOrchestrator ?: return@launch
isUploadingImage = true uploadState.mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
val results = val results =
myMultiOrchestrator.upload( myMultiOrchestrator.upload(
@@ -379,7 +380,7 @@ open class ChannelNewMessageViewModel :
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
} }
isUploadingImage = false uploadState.mediaUploadTracker.finishUpload()
} }
} }
@@ -547,6 +548,8 @@ open class ChannelNewMessageViewModel :
userSuggestions?.reset() userSuggestions?.reset()
userSuggestionsMainMessage = null userSuggestionsMainMessage = null
uploadState?.reset()
iMetaAttachments.reset() iMetaAttachments.reset()
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -633,7 +636,7 @@ open class ChannelNewMessageViewModel :
fun canPost(): Boolean = fun canPost(): Boolean =
message.text.isNotBlank() && message.text.isNotBlank() &&
uploadState?.isUploadingImage != true && uploadState?.mediaUploadTracker?.isUploading != true &&
!wantsInvoice && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount != null) && (!wantsZapraiser || zapRaiserAmount != null) &&
uploadState?.multiOrchestrator == null uploadState?.multiOrchestrator == null
@@ -48,6 +48,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.components.zonedDrawerSwipe
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -124,7 +125,12 @@ fun MessagesPager(
HorizontalPager( HorizontalPager(
contentPadding = paddingValues, contentPadding = paddingValues,
state = pagerState, state = pagerState,
userScrollEnabled = false, userScrollEnabled = true,
modifier =
Modifier.zonedDrawerSwipe(
pagerState = pagerState,
openDrawer = nav::openDrawer,
),
) { page -> ) { page ->
ChatroomListFeedView( ChatroomListFeedView(
feedContentState = tabs[page].feedContentState, feedContentState = tabs[page].feedContentState,
@@ -81,6 +81,7 @@ fun ChatFileUploadDialog(
onCancel: () -> Unit, onCancel: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
isNip17: Boolean = false,
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
@@ -132,7 +133,7 @@ fun ChatFileUploadDialog(
) { ) {
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
ImageVideoPostChat(state, accountViewModel) ImageVideoPostChat(state, accountViewModel, isNip17)
} }
} }
} }
@@ -144,6 +145,7 @@ fun ChatFileUploadDialog(
private fun ImageVideoPostChat( private fun ImageVideoPostChat(
fileUploadState: ChatFileUploadState, fileUploadState: ChatFileUploadState,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
isNip17: Boolean = false,
) { ) {
val fileServers by accountViewModel.account.blossomServers.hostNameFlow val fileServers by accountViewModel.account.blossomServers.hostNameFlow
.collectAsState() .collectAsState()
@@ -190,6 +192,16 @@ private fun ImageVideoPostChat(
onCheckedChange = fileUploadState::updateContentWarning, onCheckedChange = fileUploadState::updateContentWarning,
) )
if (isNip17) {
SettingSwitchItem(
title = R.string.encrypt_files_label,
description = R.string.encrypt_files_description,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
checked = fileUploadState.encryptFiles,
onCheckedChange = { fileUploadState.encryptFiles = it },
)
}
SettingsRow(R.string.file_server, R.string.file_server_description) { SettingsRow(R.string.file_server, R.string.file_server_description) {
TextSpinner( TextSpinner(
label = "", label = "",
@@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName 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.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -37,7 +38,9 @@ class ChatFileUploadState(
val defaultServer: ServerName, val defaultServer: ServerName,
defaultStripLocationMetadata: Boolean = true, defaultStripLocationMetadata: Boolean = true,
) { ) {
var isUploadingImage by mutableStateOf(false) val mediaUploadTracker = MediaUploadTracker()
val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage
val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile
var selectedServer by mutableStateOf(defaultServer) var selectedServer by mutableStateOf(defaultServer)
var caption by mutableStateOf("") var caption by mutableStateOf("")
@@ -53,8 +56,8 @@ class ChatFileUploadState(
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1) var mediaQualitySlider by mutableIntStateOf(1)
// Strip location and sensitive metadata from files before upload
var stripLocationMetadata by mutableStateOf(defaultStripLocationMetadata) var stripLocationMetadata by mutableStateOf(defaultStripLocationMetadata)
var encryptFiles by mutableStateOf(true)
fun load(uris: ImmutableList<SelectedMedia>) { fun load(uris: ImmutableList<SelectedMedia>) {
reset() reset()
@@ -68,16 +71,17 @@ class ChatFileUploadState(
fun reset() { fun reset() {
multiOrchestrator = null multiOrchestrator = null
isUploadingImage = false mediaUploadTracker.finishUpload()
caption = "" caption = ""
selectedServer = defaultServer selectedServer = defaultServer
encryptFiles = true
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
multiOrchestrator?.remove(selected) multiOrchestrator?.remove(selected)
} }
fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null fun canPost(): Boolean = !mediaUploadTracker.isUploading && multiOrchestrator != null
fun hasPickedMedia() = multiOrchestrator != null fun hasPickedMedia() = multiOrchestrator != null
@@ -297,8 +297,10 @@ fun ChessLobbyContent(
val userPubkey = accountViewModel.account.userProfile().pubkeyHex val userPubkey = accountViewModel.account.userProfile().pubkeyHex
val hasContent = val hasContent =
activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || activeGames.isNotEmpty() ||
publicGames.isNotEmpty() || challenges.isNotEmpty() spectatingGames.isNotEmpty() ||
publicGames.isNotEmpty() ||
challenges.isNotEmpty()
if (!hasContent) { if (!hasContent) {
// Empty state - use LazyColumn so pull-to-refresh works // Empty state - use LazyColumn so pull-to-refresh works
@@ -269,6 +269,7 @@ private fun NewProductBody(
ImageVideoDescription( ImageVideoDescription(
uris = it, uris = it,
defaultServer = accountViewModel.account.settings.defaultFileServer, defaultServer = accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
accountViewModel.account.settings.changeDefaultFileServer(server) accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -359,6 +360,7 @@ private fun BottomRowActions(postViewModel: NewProductViewModel) {
) { ) {
SelectFromGallery( SelectFromGallery(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -366,7 +368,8 @@ private fun BottomRowActions(postViewModel: NewProductViewModel) {
} }
SelectFromFiles( SelectFromFiles(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingFile,
enabled = !postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName 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.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
@@ -133,7 +134,9 @@ open class NewProductViewModel :
val urlPreviews = PreviewState() 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 userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null
@@ -217,7 +220,7 @@ open class NewProductViewModel :
} }
open fun quote(quote: Note) { open fun quote(quote: Note) {
val accountViewModel = accountViewModel ?: return val accountViewModel = accountViewModel
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
@@ -304,7 +307,6 @@ open class NewProductViewModel :
} }
suspend fun sendPostSync() { suspend fun sendPostSync() {
val accountViewModel = accountViewModel ?: return
val template = createTemplate() ?: return val template = createTemplate() ?: return
val version = draftTag.current val version = draftTag.current
@@ -317,8 +319,6 @@ open class NewProductViewModel :
} }
suspend fun sendDraftSync() { suspend fun sendDraftSync() {
val accountViewModel = accountViewModel ?: return
if (message.text.isBlank()) { if (message.text.isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else { } else {
@@ -328,7 +328,7 @@ open class NewProductViewModel :
} }
private suspend fun createTemplate(): EventTemplate<out Event>? { private suspend fun createTemplate(): EventTemplate<out Event>? {
val accountViewModel = accountViewModel ?: return null val accountViewModel = accountViewModel
val tagger = val tagger =
NewMessageTagger( NewMessageTagger(
@@ -337,7 +337,7 @@ open class NewProductViewModel :
) )
tagger.run() tagger.run()
val emojis = findEmoji(tagger.message, account?.emoji?.myEmojis?.value) val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
val urls = findURLs(tagger.message) val urls = findURLs(tagger.message)
val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() } val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() }
@@ -396,10 +396,10 @@ open class NewProductViewModel :
context: Context, context: Context,
) { ) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val myAccount = account ?: return@launch val myAccount = account
val myMultiOrchestrator = multiOrchestrator ?: return@launch val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
val results = val results =
myMultiOrchestrator.upload( myMultiOrchestrator.upload(
@@ -444,7 +444,7 @@ open class NewProductViewModel :
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
} }
isUploadingImage = false mediaUploadTracker.finishUpload()
} }
} }
@@ -454,7 +454,7 @@ open class NewProductViewModel :
message = TextFieldValue("") message = TextFieldValue("")
multiOrchestrator = null multiOrchestrator = null
isUploadingImage = false mediaUploadTracker.finishUpload()
wantsInvoice = false wantsInvoice = false
wantsZapraiser = false wantsZapraiser = false
@@ -498,8 +498,8 @@ open class NewProductViewModel :
this.multiOrchestrator?.remove(selected) this.multiOrchestrator?.remove(selected)
} }
override fun updateMessage(it: TextFieldValue) { override fun updateMessage(newMessage: TextFieldValue) {
message = it message = newMessage
urlPreviews.update(message) urlPreviews.update(message)
if (message.selection.collapsed) { if (message.selection.collapsed) {
@@ -575,7 +575,7 @@ open class NewProductViewModel :
fun canPost(): Boolean = fun canPost(): Boolean =
message.text.isNotBlank() && message.text.isNotBlank() &&
!isUploadingImage && !mediaUploadTracker.isUploading &&
!wantsInvoice && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) && (!wantsZapraiser || zapRaiserAmount.value != null) &&
title.text.isNotBlank() && title.text.isNotBlank() &&
@@ -613,7 +613,7 @@ open class NewProductViewModel :
override fun updateZapFromText() { override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!) val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
tagger.run() tagger.run()
tagger.pTags?.forEach { taggedUser -> tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) { if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -83,7 +83,8 @@ class HashtagFeedFilter(
event is PrivateDmEvent || event is PrivateDmEvent ||
event is PollNoteEvent || event is PollNoteEvent ||
event is AudioHeaderEvent event is AudioHeaderEvent
) && event.isTaggedHash(hashTag) ) &&
event.isTaggedHash(hashTag)
fun acceptableViaScope( fun acceptableViaScope(
event: Event?, event: Event?,
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.zonedDrawerSwipe
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState
import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys
@@ -211,7 +212,12 @@ private fun HomePages(
HorizontalPager( HorizontalPager(
contentPadding = it, contentPadding = it,
state = pagerState, state = pagerState,
userScrollEnabled = false, userScrollEnabled = true,
modifier =
Modifier.zonedDrawerSwipe(
pagerState = pagerState,
openDrawer = nav::openDrawer,
),
) { page -> ) { page ->
HomeFeeds( HomeFeeds(
feedState = tabs[page].feedState, feedState = tabs[page].feedState,
@@ -356,6 +356,7 @@ private fun NewPostScreenBody(
ImageVideoDescription( ImageVideoDescription(
it, it,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 -> onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265) postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265)
accountViewModel.account.settings.changeDefaultFileServer(server) accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -494,6 +495,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
) { ) {
SelectFromGallery( SelectFromGallery(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -501,7 +503,8 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
} }
SelectFromFiles( SelectFromFiles(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingFile,
enabled = !postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
@@ -178,7 +179,9 @@ open class ShortNotePostViewModel :
val urlPreviews = PreviewState() 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 userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null
@@ -829,7 +832,7 @@ open class ShortNotePostViewModel :
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val myMultiOrchestrator = multiOrchestrator ?: return@launch val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
val results = val results =
myMultiOrchestrator.upload( myMultiOrchestrator.upload(
@@ -885,7 +888,7 @@ open class ShortNotePostViewModel :
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
} }
isUploadingImage = false mediaUploadTracker.finishUpload()
} }
} }
@@ -897,7 +900,7 @@ open class ShortNotePostViewModel :
forkedFromNote = null forkedFromNote = null
multiOrchestrator = null multiOrchestrator = null
isUploadingImage = false mediaUploadTracker.finishUpload()
voiceAnonymization.clear() voiceAnonymization.clear()
deleteVoiceLocalFile() deleteVoiceLocalFile()
voiceRecording = null voiceRecording = null
@@ -1032,19 +1035,20 @@ open class ShortNotePostViewModel :
fun canPost(): Boolean { fun canPost(): Boolean {
// Voice messages can be posted without text (with either uploaded or pending recording) // Voice messages can be posted without text (with either uploaded or pending recording)
if (voiceMetadata != null || voiceRecording != null) { if (voiceMetadata != null || voiceRecording != null) {
return !isUploadingVoice && !isUploadingImage && processingPreset == null return !isUploadingVoice && !mediaUploadTracker.isUploading && processingPreset == null
} }
// Regular text/media posts require text // Regular text/media posts require text
return message.text.isNotBlank() && return message.text.isNotBlank() &&
!isUploadingImage && !mediaUploadTracker.isUploading &&
!isUploadingVoice && !isUploadingVoice &&
!wantsInvoice && !wantsInvoice &&
(!wantsZapRaiser || zapRaiserAmount.value != null) && (!wantsZapRaiser || zapRaiserAmount.value != null) &&
( (
!wantsPoll || !wantsPoll ||
( (
pollOptions.isNotEmpty() && pollOptions.all { it.value.label.isNotEmpty() } && pollOptions.isNotEmpty() &&
pollOptions.all { it.value.label.isNotEmpty() } &&
closedAt > TimeUtils.oneMinuteFromNow() closedAt > TimeUtils.oneMinuteFromNow()
) )
) && ) &&
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel import com.patrykandpatrick.vico.compose.cartesian.data.LineCartesianLayerModel
import com.patrykandpatrick.vico.core.common.data.ExtraStore import com.patrykandpatrick.vico.compose.common.data.ExtraStore
import com.patrykandpatrick.vico.core.common.data.MutableExtraStore import com.patrykandpatrick.vico.compose.common.data.MutableExtraStore
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
@@ -21,9 +21,9 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext
import com.patrykandpatrick.vico.core.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter
import com.vitorpamplona.amethyst.ui.note.showAmountIntegerWithZero import com.vitorpamplona.amethyst.ui.note.showAmountIntegerWithZero
import com.vitorpamplona.amethyst.ui.note.showAmountWithZero import com.vitorpamplona.amethyst.ui.note.showAmountWithZero
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ShowDecimals import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ShowDecimals
@@ -21,9 +21,9 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext
import com.patrykandpatrick.vico.core.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Stable @Stable
@@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart
import android.util.LruCache import android.util.LruCache
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext
import com.patrykandpatrick.vico.core.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -21,34 +21,34 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberEnd import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer.AreaFill.Companion.single
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer.Line
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.common.fill import com.patrykandpatrick.vico.compose.common.Fill
import com.patrykandpatrick.vico.core.cartesian.axis.Axis
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.core.common.shader.ShaderProvider
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
fun makeLine(color: Color): LineCartesianLayer.Line = fun makeLine(color: Color): Line =
LineCartesianLayer.Line( Line(
fill = LineCartesianLayer.LineFill.single(fill(color)), fill = LineCartesianLayer.LineFill.single(Fill(color)),
areaFill = areaFill =
LineCartesianLayer.AreaFill.single( single(
fill( Fill(
ShaderProvider.verticalGradient( brush =
color.copy(alpha = 0.4f).toArgb(), Brush.verticalGradient(
Color.Transparent.toArgb(), colors = listOf(color.copy(alpha = 0.4f), Color.Transparent),
), ),
), ),
), ),
pointConnector = LineCartesianLayer.PointConnector.cubic(), pointConnector = LineCartesianLayer.PointConnector.cubic(),
@@ -84,7 +84,10 @@ fun ShowChart(model: CartesianChartModel) {
), ),
endAxis = endAxis =
VerticalAxis.rememberEnd( VerticalAxis.rememberEnd(
label = rememberAxisLabelComponent(color = BitcoinOrange), label =
rememberAxisLabelComponent(
style = TextStyle(color = BitcoinOrange, fontSize = 12.sp),
),
valueFormatter = AmountValueFormatter(), valueFormatter = AmountValueFormatter(),
itemPlacer = VerticalAxis.ItemPlacer.count({ 7 }), itemPlacer = VerticalAxis.ItemPlacer.count({ 7 }),
), ),
@@ -246,6 +246,7 @@ fun PublicMessageScreenContent(
ImageVideoDescription( ImageVideoDescription(
it, it,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
accountViewModel.account.settings.changeDefaultFileServer(server) accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -321,6 +322,7 @@ private fun BottomRowActions(
) { ) {
SelectFromGallery( SelectFromGallery(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -328,7 +330,8 @@ private fun BottomRowActions(
} }
SelectFromFiles( SelectFromFiles(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingFile,
enabled = !postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier,
) { ) {
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName 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.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
@@ -149,7 +150,9 @@ class NewPublicMessageViewModel :
val urlPreviews = PreviewState() 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 userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null
@@ -319,7 +322,7 @@ class NewPublicMessageViewModel :
} }
suspend fun sendPostSync() { suspend fun sendPostSync() {
val template = createTemplate() ?: return val template = createTemplate()
val extraNotesToBroadcast = mutableListOf<Event>() val extraNotesToBroadcast = mutableListOf<Event>()
if (nip95attachments.isNotEmpty()) { if (nip95attachments.isNotEmpty()) {
@@ -351,7 +354,7 @@ class NewPublicMessageViewModel :
broadcast.add(it.second) broadcast.add(it.second)
} }
val template = createTemplate() ?: return val template = createTemplate()
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, broadcast) accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, broadcast)
} }
} }
@@ -439,7 +442,7 @@ class NewPublicMessageViewModel :
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val myMultiOrchestrator = multiOrchestrator ?: return@launch val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
val results = val results =
myMultiOrchestrator.upload( myMultiOrchestrator.upload(
@@ -456,7 +459,7 @@ class NewPublicMessageViewModel :
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
nip95attachments = nip95attachments + nip95 nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let { note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
@@ -494,7 +497,7 @@ class NewPublicMessageViewModel :
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
} }
isUploadingImage = false mediaUploadTracker.finishUpload()
} }
} }
@@ -523,6 +526,8 @@ class NewPublicMessageViewModel :
userSuggestions?.reset() userSuggestions?.reset()
userSuggestionsMainMessage = null userSuggestionsMainMessage = null
mediaUploadTracker.finishUpload()
iMetaAttachments.reset() iMetaAttachments.reset()
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -628,7 +633,7 @@ class NewPublicMessageViewModel :
fun canPost(): Boolean = fun canPost(): Boolean =
message.text.isNotBlank() && message.text.isNotBlank() &&
!isUploadingImage && !mediaUploadTracker.isUploading &&
!wantsInvoice && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) && (!wantsZapraiser || zapRaiserAmount.value != null) &&
(toUsers.text.isNotBlank()) && (toUsers.text.isNotBlank()) &&
@@ -63,6 +63,7 @@ class UserProfileFollowersUserFeedViewModel(
} }
} }
@OptIn(kotlinx.coroutines.FlowPreview::class)
val followersFlow: StateFlow<List<User>> = val followersFlow: StateFlow<List<User>> =
account.cache account.cache
.observeEvents(followerFilter) .observeEvents(followerFilter)
@@ -55,6 +55,7 @@ class UserProfileFollowsUserFeedViewModel(
return LocalCache.load(nonHiddenFollows).sortedWith(sortingModel) return LocalCache.load(nonHiddenFollows).sortedWith(sortingModel)
} }
@OptIn(kotlinx.coroutines.FlowPreview::class)
val followsFlow: StateFlow<List<User>> = val followsFlow: StateFlow<List<User>> =
contactList contactList
.flow() .flow()
@@ -77,12 +77,13 @@ class UserProfileGalleryFeedFilter(
val noteEvent = it.event val noteEvent = it.event
return ( return (
( (
it.event?.pubKey == user.pubkeyHex && ( it.event?.pubKey == user.pubkeyHex &&
noteEvent is PictureEvent || (
noteEvent is RegularVideoEvent || noteEvent is PictureEvent ||
(noteEvent is ReplaceableVideoEvent && it is AddressableNote) || noteEvent is RegularVideoEvent ||
(noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) (noteEvent is ReplaceableVideoEvent && it is AddressableNote) ||
) (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent())
)
) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
) && ) &&
params.match(noteEvent, it.relays) && params.match(noteEvent, it.relays) &&
@@ -56,7 +56,7 @@ fun WatchApp(
LaunchedEffect(key1 = appState) { LaunchedEffect(key1 = appState) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
(appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.let { metaData -> (appState.note.event as? AppDefinitionEvent)?.appMetaData()?.let { metaData ->
metaData.picture?.ifBlank { null }?.let { newLogo -> metaData.picture?.ifBlank { null }?.let { newLogo ->
if (newLogo != appLogo) appLogo = newLogo if (newLogo != appLogo) appLogo = newLogo
} }
@@ -112,6 +112,7 @@ class UserProfileZapsViewModel(
return results.map { (user, amount) -> ZapAmount(user, amount) }.sortedWith(sortingModel) return results.map { (user, amount) -> ZapAmount(user, amount) }.sortedWith(sortingModel)
} }
@OptIn(kotlinx.coroutines.FlowPreview::class)
val receivedZapAmountsByUser: StateFlow<List<ZapAmount>> = val receivedZapAmountsByUser: StateFlow<List<ZapAmount>> =
account.cache account.cache
.observeEvents(zapsToUser) .observeEvents(zapsToUser)
@@ -188,6 +188,14 @@ fun MappedAllRelayListView(
val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle()
val relayFeedsFeedState by relayFeedsViewModel.relays.collectAsStateWithLifecycle() val relayFeedsFeedState by relayFeedsViewModel.relays.collectAsStateWithLifecycle()
val outboxCounts by nip65ViewModel.homeCountResults.collectAsStateWithLifecycle()
val inboxCounts by nip65ViewModel.notifCountResults.collectAsStateWithLifecycle()
val dmCounts by dmViewModel.countResults.collectAsStateWithLifecycle()
val privateHomeCounts by privateOutboxViewModel.countResults.collectAsStateWithLifecycle()
val proxyCounts by proxyViewModel.countResults.collectAsStateWithLifecycle()
val indexerCounts by indexerViewModel.countResults.collectAsStateWithLifecycle()
val searchCounts by searchViewModel.countResults.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { topBar = {
SavingTopBar( SavingTopBar(
@@ -261,7 +269,7 @@ fun MappedAllRelayListView(
SettingsCategoryFirstModifier, SettingsCategoryFirstModifier,
) )
} }
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, nav) renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, nav, outboxCounts)
item { item {
SettingsCategory( SettingsCategory(
@@ -270,7 +278,7 @@ fun MappedAllRelayListView(
SettingsCategorySpacingModifier, SettingsCategorySpacingModifier,
) )
} }
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, nav) renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, nav, inboxCounts)
item { item {
SettingsCategoryWithButton( SettingsCategoryWithButton(
@@ -282,7 +290,7 @@ fun MappedAllRelayListView(
}, },
) )
} }
renderDMItems(dmFeedState, dmViewModel, accountViewModel, nav) renderDMItems(dmFeedState, dmViewModel, accountViewModel, nav, dmCounts)
item { item {
SettingsCategory( SettingsCategory(
@@ -291,7 +299,7 @@ fun MappedAllRelayListView(
SettingsCategorySpacingModifier, SettingsCategorySpacingModifier,
) )
} }
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, nav) renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, nav, privateHomeCounts)
item { item {
SettingsCategory( SettingsCategory(
@@ -300,7 +308,7 @@ fun MappedAllRelayListView(
SettingsCategorySpacingModifier, SettingsCategorySpacingModifier,
) )
} }
renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, nav) renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, nav, proxyCounts)
item { item {
SettingsCategory( SettingsCategory(
@@ -320,7 +328,7 @@ fun MappedAllRelayListView(
ResetIndexerRelays(indexerViewModel) ResetIndexerRelays(indexerViewModel)
} }
} }
renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, nav) renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, nav, indexerCounts)
item { item {
SettingsCategoryWithButton( SettingsCategoryWithButton(
@@ -331,7 +339,7 @@ fun MappedAllRelayListView(
ResetSearchRelays(searchViewModel) ResetSearchRelays(searchViewModel)
} }
} }
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, nav) renderSearchItems(searchFeedState, searchViewModel, accountViewModel, nav, searchCounts)
item { item {
SettingsCategory( SettingsCategory(
@@ -47,6 +47,7 @@ import androidx.compose.material.icons.automirrored.filled.Feed
import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.automirrored.filled.Label
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.automirrored.filled.Message import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AttachMoney import androidx.compose.material.icons.filled.AttachMoney
import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Code
@@ -61,7 +62,6 @@ import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Payment import androidx.compose.material.icons.filled.Payment
import androidx.compose.material.icons.filled.PrivacyTip import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Tag
import androidx.compose.material.icons.filled.Topic import androidx.compose.material.icons.filled.Topic
@@ -931,7 +931,7 @@ private fun OutboxEventsCard(eventIds: Set<HexKey>) {
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Icon( Icon(
imageVector = Icons.Default.Send, imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(12.dp), modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer, tint = MaterialTheme.colorScheme.onTertiaryContainer,
@@ -63,6 +63,7 @@ fun BasicRelaySetupInfoClickableRow(
onClick: () -> Unit, onClick: () -> Unit,
nip11CachedRetriever: Nip11CachedRetriever, nip11CachedRetriever: Nip11CachedRetriever,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
countResult: RelayCountResult? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
@@ -104,6 +105,11 @@ fun BasicRelaySetupInfoClickableRow(
UsedBy(item, accountViewModel, nav) UsedBy(item, accountViewModel, nav)
RelayEventCountRow(
countResult = countResult,
modifier = ReactionRowHeightChatMaxWidth,
)
RelayStatusRow( RelayStatusRow(
item = item, item = item,
onClick = onClick, onClick = onClick,
@@ -32,6 +32,7 @@ fun BasicRelaySetupInfoDialog(
item: BasicRelaySetupInfo, item: BasicRelaySetupInfo,
nip11CachedRetriever: Nip11CachedRetriever, nip11CachedRetriever: Nip11CachedRetriever,
onDelete: ((BasicRelaySetupInfo) -> Unit)?, onDelete: ((BasicRelaySetupInfo) -> Unit)?,
countResult: RelayCountResult? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
@@ -43,6 +44,7 @@ fun BasicRelaySetupInfoDialog(
onClick = { nav.nav(Route.RelayInfo(item.relay.url)) }, onClick = { nav.nav(Route.RelayInfo(item.relay.url)) },
nip11CachedRetriever = nip11CachedRetriever, nip11CachedRetriever = nip11CachedRetriever,
modifier = HalfVertPadding, modifier = HalfVertPadding,
countResult = countResult,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.service.replace
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -40,6 +41,9 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
private val _relays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList()) private val _relays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow() val relays = _relays.asStateFlow()
private val _countResults = MutableStateFlow<Map<NormalizedRelayUrl, RelayCountResult>>(emptyMap())
val countResults = _countResults.asStateFlow()
var hasModified = false var hasModified = false
fun init(accountViewModel: AccountViewModel) { fun init(accountViewModel: AccountViewModel) {
@@ -50,12 +54,15 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
fun load() { fun load() {
clear() clear()
loadRelayDocuments() loadRelayDocuments()
loadCounts()
} }
abstract fun getRelayList(): List<NormalizedRelayUrl>? abstract fun getRelayList(): List<NormalizedRelayUrl>?
abstract suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) abstract suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>)
open fun countFilters(relayUrl: NormalizedRelayUrl): List<CountFilter> = emptyList()
fun create() { fun create() {
if (hasModified) { if (hasModified) {
accountViewModel.launchSigner { accountViewModel.launchSigner {
@@ -79,6 +86,40 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
} }
} }
private fun loadCounts() {
_countResults.value = emptyMap()
val client = account.client
val relayList = _relays.value
if (relayList.isEmpty()) return
relayList.forEach { item ->
val filters = countFilters(item.relay)
if (filters.isEmpty()) return@forEach
filters.forEach { countFilter ->
viewModelScope.launch(Dispatchers.IO) {
val result = client.queryCountSuspend(item.relay, countFilter.filter)
if (result != null) {
_countResults.update { currentMap ->
val current = currentMap[item.relay] ?: RelayCountResult()
val entries = current.counts.toMutableList()
val newEntry =
RelayCountResult.CountEntry(
label = countFilter.label,
count = result.count,
approximate = result.approximate,
)
val existing = entries.indexOfFirst { it.label == countFilter.label }
if (existing >= 0) entries[existing] = newEntry else entries.add(newEntry)
currentMap + (item.relay to RelayCountResult(entries))
}
}
}
}
}
}
open fun relayListBuilder(): List<BasicRelaySetupInfo> { open fun relayListBuilder(): List<BasicRelaySetupInfo> {
val relayList = getRelayList() ?: emptyList() val relayList = getRelayList() ?: emptyList()
@@ -0,0 +1,105 @@
/*
* 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.common
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.countToHumanReadable
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font10SP
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
private val PillShape = RoundedCornerShape(12.dp)
@Composable
fun RelayEventCountRow(
countResult: RelayCountResult?,
modifier: Modifier,
) {
if (countResult == null || countResult.counts.isEmpty()) return
val pillColor = MaterialTheme.colorScheme.allGoodColor
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = modifier,
) {
countResult.counts.forEachIndexed { index, entry ->
if (index > 0) {
Spacer(modifier = Modifier.width(6.dp))
}
val countText =
if (entry.approximate) {
"~${countToHumanReadable(entry.count, stringRes(entry.label))}"
} else {
countToHumanReadable(entry.count, stringRes(entry.label))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.clip(PillShape)
.border(width = 1.dp, color = pillColor.copy(alpha = 0.4f), shape = PillShape)
.background(pillColor.copy(alpha = 0.1f))
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Icon(
imageVector = Icons.Default.Storage,
contentDescription = stringRes(R.string.relay_event_count),
modifier = Size10Modifier,
tint = pillColor,
)
Spacer(modifier = Modifier.width(3.dp))
Text(
text = countText,
maxLines = 1,
fontSize = Font10SP,
fontWeight = FontWeight.Medium,
color = pillColor,
)
}
}
}
}
@@ -0,0 +1,41 @@
/*
* 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.common
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@Immutable
data class RelayCountResult(
val counts: List<CountEntry> = emptyList(),
) {
@Immutable
data class CountEntry(
val label: Int,
val count: Int,
val approximate: Boolean = false,
)
}
data class CountFilter(
val label: Int,
val filter: Filter,
)
@@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Composable @Composable
fun DMRelayList( fun DMRelayList(
@@ -64,12 +66,14 @@ fun LazyListScope.renderDMItems(
postViewModel: DMRelayListViewModel, postViewModel: DMRelayListViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
countResults: Map<NormalizedRelayUrl, RelayCountResult> = emptyMap(),
) { ) {
itemsIndexed(feedState, key = { _, item -> "DM" + item.relay.url }) { index, item -> itemsIndexed(feedState, key = { _, item -> "DM" + item.relay.url }) { index, item ->
BasicRelaySetupInfoDialog( BasicRelaySetupInfoDialog(
item, item,
onDelete = { postViewModel.deleteRelay(item) }, onDelete = { postViewModel.deleteRelay(item) },
nip11CachedRetriever = Amethyst.instance.nip11Cache, nip11CachedRetriever = Amethyst.instance.nip11Cache,
countResult = countResults[item.relay],
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -21,8 +21,13 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
@Stable @Stable
class DMRelayListViewModel : BasicRelaySetupInfoModel() { class DMRelayListViewModel : BasicRelaySetupInfoModel() {
@@ -31,4 +36,16 @@ class DMRelayListViewModel : BasicRelaySetupInfoModel() {
override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) { override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) {
account.saveDMRelayList(urlList) account.saveDMRelayList(urlList)
} }
override fun countFilters(relayUrl: NormalizedRelayUrl): List<CountFilter> =
listOf(
CountFilter(
label = R.string.dms,
filter =
Filter(
kinds = listOf(GiftWrapEvent.KIND, PrivateDmEvent.KIND),
tags = mapOf("p" to listOf(account.pubKey)),
),
),
)
} }
@@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Composable @Composable
fun IndexerRelayList( fun IndexerRelayList(
@@ -65,12 +67,14 @@ fun LazyListScope.renderIndexerItems(
postViewModel: IndexerRelayListViewModel, postViewModel: IndexerRelayListViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
countResults: Map<NormalizedRelayUrl, RelayCountResult> = emptyMap(),
) { ) {
itemsIndexed(feedState, key = { _, item -> "Indexer" + item.relay.url }) { index, item -> itemsIndexed(feedState, key = { _, item -> "Indexer" + item.relay.url }) { index, item ->
BasicRelaySetupInfoDialog( BasicRelaySetupInfoDialog(
item, item,
onDelete = { postViewModel.deleteRelay(item) }, onDelete = { postViewModel.deleteRelay(item) },
nip11CachedRetriever = Amethyst.instance.nip11Cache, nip11CachedRetriever = Amethyst.instance.nip11Cache,
countResult = countResults[item.relay],
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -21,8 +21,13 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
@Stable @Stable
class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { class IndexerRelayListViewModel : BasicRelaySetupInfoModel() {
@@ -33,4 +38,16 @@ class IndexerRelayListViewModel : BasicRelaySetupInfoModel() {
override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) { override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) {
account.saveIndexerRelayList(urlList) account.saveIndexerRelayList(urlList)
} }
override fun countFilters(relayUrl: NormalizedRelayUrl): List<CountFilter> =
listOf(
CountFilter(
label = R.string.profiles,
filter = Filter(kinds = listOf(MetadataEvent.KIND)),
),
CountFilter(
label = R.string.relay_settings_lower,
filter = Filter(kinds = listOf(AdvertisedRelayListEvent.KIND)),
),
)
} }
@@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Composable @Composable
fun PrivateOutboxRelayList( fun PrivateOutboxRelayList(
@@ -64,12 +66,14 @@ fun LazyListScope.renderPrivateOutboxItems(
postViewModel: PrivateOutboxRelayListViewModel, postViewModel: PrivateOutboxRelayListViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
countResults: Map<NormalizedRelayUrl, RelayCountResult> = emptyMap(),
) { ) {
itemsIndexed(feedState, key = { _, item -> "Outbox" + item.relay.url }) { index, item -> itemsIndexed(feedState, key = { _, item -> "Outbox" + item.relay.url }) { index, item ->
BasicRelaySetupInfoDialog( BasicRelaySetupInfoDialog(
item, item,
onDelete = { postViewModel.deleteRelay(item) }, onDelete = { postViewModel.deleteRelay(item) },
nip11CachedRetriever = Amethyst.instance.nip11Cache, nip11CachedRetriever = Amethyst.instance.nip11Cache,
countResult = countResults[item.relay],
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -21,7 +21,10 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable @Stable
@@ -33,4 +36,12 @@ class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() {
override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) { override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) {
account.savePrivateOutboxRelayList(urlList) account.savePrivateOutboxRelayList(urlList)
} }
override fun countFilters(relayUrl: NormalizedRelayUrl): List<CountFilter> =
listOf(
CountFilter(
label = R.string.events,
filter = Filter(authors = listOf(account.pubKey)),
),
)
} }
@@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Composable @Composable
fun Nip65RelayList( fun Nip65RelayList(
@@ -107,12 +109,14 @@ fun LazyListScope.renderNip65HomeItems(
postViewModel: Nip65RelayListViewModel, postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
countResults: Map<NormalizedRelayUrl, RelayCountResult> = emptyMap(),
) { ) {
itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.relay.url }) { index, item -> itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.relay.url }) { index, item ->
BasicRelaySetupInfoDialog( BasicRelaySetupInfoDialog(
item, item,
onDelete = { postViewModel.deleteHomeRelay(item) }, onDelete = { postViewModel.deleteHomeRelay(item) },
nip11CachedRetriever = Amethyst.instance.nip11Cache, nip11CachedRetriever = Amethyst.instance.nip11Cache,
countResult = countResults[item.relay],
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -133,12 +137,14 @@ fun LazyListScope.renderNip65NotifItems(
postViewModel: Nip65RelayListViewModel, postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
countResults: Map<NormalizedRelayUrl, RelayCountResult> = emptyMap(),
) { ) {
itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.relay.url }) { index, item -> itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.relay.url }) { index, item ->
BasicRelaySetupInfoDialog( BasicRelaySetupInfoDialog(
item, item,
onDelete = { postViewModel.deleteNotifRelay(item) }, onDelete = { postViewModel.deleteNotifRelay(item) },
nip11CachedRetriever = Amethyst.instance.nip11Cache, nip11CachedRetriever = Amethyst.instance.nip11Cache,
countResult = countResults[item.relay],
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -24,11 +24,16 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.service.replace
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -48,6 +53,12 @@ class Nip65RelayListViewModel : ViewModel() {
private val _notificationRelays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList()) private val _notificationRelays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList())
val notificationRelays = _notificationRelays.asStateFlow() val notificationRelays = _notificationRelays.asStateFlow()
private val _homeCountResults = MutableStateFlow<Map<NormalizedRelayUrl, RelayCountResult>>(emptyMap())
val homeCountResults = _homeCountResults.asStateFlow()
private val _notifCountResults = MutableStateFlow<Map<NormalizedRelayUrl, RelayCountResult>>(emptyMap())
val notifCountResults = _notifCountResults.asStateFlow()
var hasModified = false var hasModified = false
fun init(accountViewModel: AccountViewModel) { fun init(accountViewModel: AccountViewModel) {
@@ -58,6 +69,7 @@ class Nip65RelayListViewModel : ViewModel() {
fun load() { fun load() {
clear() clear()
loadRelayDocuments() loadRelayDocuments()
loadCounts()
} }
fun create() { fun create() {
@@ -111,6 +123,51 @@ class Nip65RelayListViewModel : ViewModel() {
} }
} }
private fun loadCounts() {
_homeCountResults.value = emptyMap()
_notifCountResults.value = emptyMap()
val client = Amethyst.instance.client
_homeRelays.value.forEach { item ->
viewModelScope.launch(Dispatchers.IO) {
val result = client.queryCountSuspend(item.relay, Filter(authors = listOf(account.pubKey)))
if (result != null) {
val countResult =
RelayCountResult(
listOf(
RelayCountResult.CountEntry(
label = R.string.events,
count = result.count,
approximate = result.approximate,
),
),
)
_homeCountResults.update { it + (item.relay to countResult) }
}
}
}
_notificationRelays.value.forEach { item ->
viewModelScope.launch(Dispatchers.IO) {
val result = client.queryCountSuspend(item.relay, Filter(tags = mapOf("p" to listOf(account.pubKey))))
if (result != null) {
val countResult =
RelayCountResult(
listOf(
RelayCountResult.CountEntry(
label = R.string.events,
count = result.count,
approximate = result.approximate,
),
),
)
_notifCountResults.update { it + (item.relay to countResult) }
}
}
}
}
fun clear() { fun clear() {
hasModified = false hasModified = false
_homeRelays.update { _homeRelays.update {
@@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Composable @Composable
fun ProxyRelayList( fun ProxyRelayList(
@@ -65,12 +67,14 @@ fun LazyListScope.renderProxyItems(
postViewModel: ProxyRelayListViewModel, postViewModel: ProxyRelayListViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
countResults: Map<NormalizedRelayUrl, RelayCountResult> = emptyMap(),
) { ) {
itemsIndexed(feedState, key = { _, item -> "Proxy" + item.relay.url }) { index, item -> itemsIndexed(feedState, key = { _, item -> "Proxy" + item.relay.url }) { index, item ->
BasicRelaySetupInfoDialog( BasicRelaySetupInfoDialog(
item, item,
onDelete = { postViewModel.deleteRelay(item) }, onDelete = { postViewModel.deleteRelay(item) },
nip11CachedRetriever = Amethyst.instance.nip11Cache, nip11CachedRetriever = Amethyst.instance.nip11Cache,
countResult = countResults[item.relay],
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Composable @Composable
fun SearchRelayList( fun SearchRelayList(
@@ -65,12 +67,14 @@ fun LazyListScope.renderSearchItems(
postViewModel: SearchRelayListViewModel, postViewModel: SearchRelayListViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
countResults: Map<NormalizedRelayUrl, RelayCountResult> = emptyMap(),
) { ) {
itemsIndexed(feedState, key = { _, item -> "Search" + item.relay.url }) { index, item -> itemsIndexed(feedState, key = { _, item -> "Search" + item.relay.url }) { index, item ->
BasicRelaySetupInfoDialog( BasicRelaySetupInfoDialog(
item, item,
onDelete = { postViewModel.deleteRelay(item) }, onDelete = { postViewModel.deleteRelay(item) },
nip11CachedRetriever = Amethyst.instance.nip11Cache, nip11CachedRetriever = Amethyst.instance.nip11Cache,
countResult = countResults[item.relay],
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -21,7 +21,10 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable @Stable
@@ -33,4 +36,12 @@ class SearchRelayListViewModel : BasicRelaySetupInfoModel() {
override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) { override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) {
account.saveSearchRelayList(urlList) account.saveSearchRelayList(urlList)
} }
override fun countFilters(relayUrl: NormalizedRelayUrl): List<CountFilter> =
listOf(
CountFilter(
label = R.string.events,
filter = Filter(),
),
)
} }
@@ -90,6 +90,7 @@ class SearchBarViewModel(
val listState: LazyListState = LazyListState(0, 0) val listState: LazyListState = LazyListState(0, 0)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
val directNip05Resolver: Flow<User?> = val directNip05Resolver: Flow<User?> =
searchTerm searchTerm
.debounce(400) .debounce(400)
@@ -55,7 +55,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -63,6 +62,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
@@ -213,7 +213,7 @@ fun TranslateToSetting(accountViewModel: AccountViewModel) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = JavaLocale(currentTranslateTo).displayName, text = JavaLocale.forLanguageTag(currentTranslateTo).displayName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
@@ -227,7 +227,7 @@ fun TranslateToSetting(accountViewModel: AccountViewModel) {
SearchableLanguageList( SearchableLanguageList(
languages = allLanguages, languages = allLanguages,
onSelect = { locale -> onSelect = { locale ->
accountViewModel.updateTranslateTo(locale) accountViewModel.updateTranslateTo(locale.language)
showPicker = false showPicker = false
}, },
) )
@@ -265,7 +265,7 @@ fun DontTranslateFromSetting(accountViewModel: AccountViewModel) {
InputChip( InputChip(
selected = true, selected = true,
onClick = { accountViewModel.removeDontTranslateFrom(languageCode) }, onClick = { accountViewModel.removeDontTranslateFrom(languageCode) },
label = { Text(JavaLocale(languageCode).displayName) }, label = { Text(JavaLocale.forLanguageTag(languageCode).displayName) },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
@@ -367,8 +367,8 @@ private fun LanguagePreferenceCard(
preference: String, preference: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val sourceName = JavaLocale(source).displayName val sourceName = JavaLocale.forLanguageTag(source).displayName
val targetName = JavaLocale(target).displayName val targetName = JavaLocale.forLanguageTag(target).displayName
OutlinedCard(modifier = Modifier.fillMaxWidth()) { OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {

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