Merge branch 'main' into claude/add-markdown-post-screen-Xma8x

This commit is contained in:
Vitor Pamplona
2026-03-22 12:38:04 -04:00
committed by GitHub
599 changed files with 63146 additions and 4409 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
**SKILL.md sections:**
- iOS source sets: iosMain, iosX64Main, iosArm64Main
- iOS source sets: iosMain, iosArm64Main
- Swift interop: type mapping, nullability
- expect/actual iOS: 10+ examples from quartz/iosMain
- XCFramework setup: baseName = "quartz-kmpKit"
Regular → Executable
View File
+3 -3
View File
@@ -9,18 +9,18 @@
}
]
}
]
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "./gradlew spotlessApply",
"command": "./gradlew spotlessApply 2>/dev/null || spotless-apply",
"timeout": 120
}
]
}
]
}
}
}
@@ -0,0 +1,77 @@
---
name: find-missing-translations
description: Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language
---
# Find Missing Translations
## Overview
Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs a table ready for translation.
## When to Use
- Need to find untranslated strings for a specific locale
- Preparing a batch of strings for a translator
- Checking translation coverage after adding new features
## Technique
### 1. Identify files
```
Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml
```
Default locale: `cs-rCZ` if none specified. User may override (e.g., `pt-rBR`, `ja`).
### 2. Extract and diff keys
Use a single bash pipeline to extract translatable keys from both files and diff them:
```bash
# Extract translatable keys from default (exclude translatable="false")
comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-<LOCALE>/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
```
This gives the list of missing key names.
### 3. Get English values for missing keys
For each missing key, extract its English value:
```bash
# For each missing key, extract the full line from default strings.xml
while IFS= read -r key; do
grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml
done < <(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-<LOCALE>/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
```
### 4. Present results
Output the missing entries as raw XML resource lines (copy-paste ready for the locale file):
```xml
<string name="attestation_valid">Valid</string>
<string name="attestation_valid_from">Valid from %1$s</string>
<string name="feed_group_lists">Lists</string>
```
Also check `<string-array>` and `<plurals>` tags using the same approach if the project uses them.
## Common Mistakes
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Not checking string-arrays/plurals** — only checking `<string>` misses other resource types
- **Modifying files** — this is a read-only research task unless the user asks to add entries
@@ -47,7 +47,7 @@
### :quartz (KMP Nostr Library)
**Type:** Kotlin Multiplatform Library
**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64)
**Targets:** JVM, Android, iOS (iosArm64, iosSimulatorArm64)
**Dependencies:**
- External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain
@@ -127,7 +127,6 @@ commonMain (base)
│ ├─ androidMain (Android platform)
│ └─ jvmMain (Desktop platform)
└─ iosMain (iOS platform)
├─ iosX64Main
├─ iosArm64Main
└─ iosSimulatorArm64Main
```
+3 -3
View File
@@ -110,8 +110,8 @@ Think of source sets as a dependency graph, not folders.
│ - Jackson │ │ │
│ - OkHttp │ └────┬─────────────┘
└───┬───────────┬───┘ │
│ │ ├─→ iosX64Main
▼ ▼ ├─→ iosArm64Main
│ │
▼ ▼ ├─→ iosArm64Main
┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main
│android │ │jvmMain │
│Main │ │(Desktop) │
@@ -252,7 +252,7 @@ expect fun currentTimeSeconds(): Long
**iOS (iosMain):**
- Active development, framework configured
- Architecture targets: iosX64Main, iosArm64Main, iosSimulatorArm64Main
- Architecture targets: macosArm64Main, iosArm64Main, iosSimulatorArm64Main
- Platform APIs via platform.posix, Security framework
### Web, wasm - Future Targets
@@ -29,7 +29,7 @@ Visual guide to source set organization with concrete examples from the codebase
│ - Jackson │ │ - Platform libs │
│ - OkHttp │ └───────┬───────────┘
└────┬─────────┬───┘ │
│ │ ├─→ iosX64Main (simulator Intel)
│ │
│ │ ├─→ iosArm64Main (device ARM64)
│ │ └─→ iosSimulatorArm64Main (Apple Silicon)
▼ ▼
@@ -238,7 +238,6 @@ iosMain {
}
}
val iosX64Main by getting { dependsOn(iosMain.get()) }
val iosArm64Main by getting { dependsOn(iosMain.get()) }
val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) }
```
@@ -249,7 +248,6 @@ val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) }
- Different from Android/Desktop
**Architecture targets:**
- iosX64Main: Intel simulator
- iosArm64Main: Device (iPhone, iPad)
- iosSimulatorArm64Main: Apple Silicon simulator
@@ -326,7 +324,7 @@ commonMain
| androidMain | jvmAndroid | Android framework | Activity, ViewModel |
| jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar |
| iosMain | commonMain | iOS platform | Security framework |
| iosX64Main | iosMain | Simulator (Intel) | Architecture-specific |
| iosMain | Simulator (Intel) | Architecture-specific |
| iosArm64Main | iosMain | Device (ARM64) | Architecture-specific |
| jsMain | commonMain | JS/DOM | Web (future) |
| wasmMain | commonMain | wasm APIs | WebAssembly (future) |
@@ -76,7 +76,6 @@ fun main() = application {
**Source sets:**
- iosMain (common iOS code)
- iosX64Main (Intel simulator)
- iosArm64Main (device - iPhone/iPad)
- iosSimulatorArm64Main (Apple Silicon simulator)
@@ -110,7 +109,7 @@ actual object Secp256k1Instance {
```kotlin
// quartz/build.gradle.kts
kotlin {
listOf(iosX64(), iosArm64(), iosSimulatorArm64())
listOf(macosArm64(), iosArm64(), iosSimulatorArm64())
.forEach { target ->
target.binaries.framework {
baseName = "quartz-kmpKit"
@@ -310,7 +309,7 @@ fun parseJson(json: String): Event {
- Manual desktop app testing
**iOS:**
- Unit tests: iosTest (iosX64Test, iosArm64Test, etc.)
- Unit tests: iosTest (iosArm64Test, etc.)
- Simulator/device testing
**Web (future):**
+1 -1
View File
@@ -609,7 +609,7 @@ SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-
import com.vitorpamplona.quartz.nip01Core.store.EventStore
import android.content.Context
val store = EventStore(context)
val store = EventStore()
// Insert
store.insert(event)
+1 -1
View File
@@ -12,7 +12,7 @@ echo "$JAVA_HOME"
echo "$(java -version)"
echo "Running test... "
./gradlew test
./gradlew test --quiet
status=$?
+134 -22
View File
@@ -1,4 +1,4 @@
name: Test/Build Android
name: Test/Build
on:
pull_request:
@@ -6,78 +6,190 @@ on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Linter (gradle)
run: ./gradlew spotlessCheck
test:
needs: lint
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Test (gradle)
run: ./gradlew test --no-daemon
- name: Android Test Report
uses: asadmansr/android-test-report-action@v1.2.0
if: ${{ always() }} # IMPORTANT: run Android Test Report regardless
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
- name: Upload Test Results
uses: actions/upload-artifact@v6
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
with:
name: Test Reports
path: amethyst/build/reports
build-android:
needs: test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build APK (gradle)
run: ./gradlew assembleDebug
- name: Upload Play APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Play Debug APK
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
- name: Upload FDroid APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: FDroid Debug APK
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
- name: Build APK (gradle)
- name: Build Benchmark APK (gradle)
run: ./gradlew assembleBenchmark
- name: Upload Play APK Benchmark
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Play Benchmark APK
path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk
- name: Upload FDroid APK Benchmark
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: FDroid Benchmark APK
path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk
- name: Upload Compose Reports
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Compose Reports
path: amethyst/build/compose_compiler
- name: Upload Test Results
uses: actions/upload-artifact@v4
with:
name: Test Reports
path: amethyst/build/reports
build-desktop:
needs: test
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
task: packageDeb
artifact-name: Desktop Linux DEB
artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb
- os: macos-latest
task: packageDmg
artifact-name: Desktop macOS DMG
artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg
- os: windows-latest
task: packageMsi
artifact-name: Desktop Windows MSI
artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi
runs-on: ${{ matrix.os }}
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build Desktop Distribution
run: ./gradlew :desktopApp:${{ matrix.task }}
- name: Upload Desktop Distribution
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-path }}
+99 -35
View File
@@ -6,23 +6,42 @@ on:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
jobs:
deploy:
create-release:
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: true
deploy-android:
needs: create-release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
@@ -38,7 +57,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign AAB (F-Droid)
@@ -50,7 +68,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- name: Build APK
@@ -65,7 +82,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign APK (F-Droid)
@@ -77,20 +93,8 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: true
# Google Play APK
- name: Upload Play APK Universal Asset
id: upload-release-asset-play-universal-apk
@@ -98,7 +102,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -109,7 +113,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -120,7 +124,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -131,7 +135,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -142,7 +146,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -154,7 +158,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -165,7 +169,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -176,7 +180,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -187,7 +191,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -198,13 +202,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
# Google Play AAB
- name: Upload Google Play AAB Asset
id: upload-release-asset-play-aab
@@ -212,7 +214,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
asset_content_type: application/zip
@@ -224,7 +226,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip
@@ -236,3 +238,65 @@ jobs:
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
deploy-desktop:
needs: create-release
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
task: packageDeb
format: deb
platform: linux
- os: macos-latest
task: packageDmg
format: dmg
platform: macos
- os: windows-latest
task: packageMsi
format: msi
platform: windows
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build Desktop Distribution
run: ./gradlew :desktopApp:${{ matrix.task }}
- name: Find distribution file
id: find-dist
run: |
DIST_FILE=$(find desktopApp/build/compose/binaries/main/${{ matrix.format }} -type f \( -name "*.deb" -o -name "*.dmg" -o -name "*.msi" \) | head -1)
echo "path=$DIST_FILE" >> $GITHUB_OUTPUT
echo "name=$(basename $DIST_FILE)" >> $GITHUB_OUTPUT
- name: Upload Desktop Distribution to Release
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ${{ steps.find-dist.outputs.path }}
asset_name: amethyst-desktop-${{ matrix.platform }}-${{ github.ref_name }}.${{ matrix.format }}
asset_content_type: application/octet-stream
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: crowdin action
uses: crowdin/github-action@v2
+13
View File
@@ -22,6 +22,7 @@
/.idea/AndroidProjectSystem.xml
/.idea/deviceManager.xml
/.idea/inspectionProfiles/
/.idea/migrations.xml
/commons/.idea/gradle.xml
/commons/.idea/misc.xml
/commons/.idea/workspace.xml
@@ -32,6 +33,10 @@
/captures
.cxx
# superpowers skill
.superpowers
docs/brainstorms
docs/superpowers
# Built application files
*.apk
@@ -149,3 +154,11 @@ lint/tmp/
# Local task tracking
TASKS.md
# Claude Code local settings
.claude/settings.local.json
# Downloaded VLC binaries (vlc-setup plugin)
desktopApp/src/jvmMain/appResources/linux/
desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/
+50 -15
View File
@@ -1,4 +1,9 @@
<a id="v1.06.0"></a>
# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2025-03-21
Adds support for creating and rendering NIP-85 Polls
- Redesign of the poll and zap poll cards
- Adds Special notification card that stays while the poll is running
Adds support for Relay Feeds
- Adds support for NIP-51 favorite relay feeds
@@ -15,7 +20,10 @@ Redesigns Media Player
- Turn video controller creation into a flow to fix playback lifecycle issues
- Adds support for uploading audio
Adds support for NIP events (kind 30817)
Adds support for NIP-47 Wallets and compete NWC spec
- Adds views for Balance and Transactions
- Add transaction filtering and pagination to wallet screen
- Added several test cases from other repos to guarantee interoperability
Adds support for NIP-52 Calendar appointments
@@ -23,34 +31,38 @@ Adds support for NIP-39 External Identities with kind 10011
Adds support for NIP-C0 Code Snippets
Adds support for NIPs on Nostr (event kind 30817)
Adds support for NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
Adds support for BUD-10 "Blossom:" URIs in images, audios, videos, and documents.
Adds support for NIP-40 Expirations in any new post.
Adds support for custom NIP-40 Expirations in any new post.
- Displays expirations on posts and DMs
Adds support for NIP-66 Relay Monitor and discovery support to Quartz
Adds support for Namecoin .bit urls to NIP-05
- Adds choice of ElectrumX server to resolve namecoins.
Adds support for Attestations (https://attestr.xyz/)
Adds basic support for Chess with Jester protocol
Adds NIP-46 Bunker support to Quartz and Amethyst Desktop
Adds a Broadcasting feedback pop-up in the Complete UI mode
Adds support for inline reply, mark as read from Push Notifications
Adds support for rendering Zap events when quoted inside of posts.
Removes NIP-04 DMs and blocks DM sending if the receiver doesn't have NIP-17 relay lists.
Removes support for NIP-96 and updates Blossom recommendations
Adds support to upload Documents to all new post screens.
Uploads:
- Adds support to upload Documents to all new post screens.
- Adds toggle to stip file metadata regardless of compression by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Adds encrypted file upload fallback option for NIP-17 chats
- Removes support for NIP-96 and updates Blossom recommendations
Content warning improvements:
- Adds optional description field for sensitive content warnings in new posts.
- Adds an optional description field for sensitive content warnings in new posts.
- Displays additional information on warning composables
Redesigns and reorganizes Setting pages
Settings redesign
- Consolidate drawer settings into a single Settings hub screen
- Redesigns Zap Amount and NWC setup screens
- Redesigns Custom zap amount screens
@@ -65,7 +77,14 @@ URL/URI parser rewrite in Kotlin multiplatform (KMP)
- Treat multibyte characters as URL terminators in RichTextParser by @npub1k0jrarx8um0lyw3nmysn50539ky4k8p7gfgzgrsvn8d7lccx3d0s38dczd
- Adds a parser for blossom: uris
Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
UI Improvements:
- Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
- New UI for DropDowns by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- New UI for feed filters by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Draft Screen requests confirmation before deleting drafts on swipe
- Swipe to switch tabs. Main screen and messages by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Adds support for rendering Zap events when quoted inside of posts.
- Adds a Broadcasting feedback pop-up in the Complete UI mode
Relay Management:
- Adds relay search tooltip when adding relays
@@ -73,7 +92,9 @@ Relay Management:
- Adds active subscriptions and outbox event in the queue to relay information
- Adds a complete list of event kind names to the subscription card to relay information
- Tracks and displays connection success rate on relay settings
- Add relay settings export functionality
- Adds relay settings export functionality
- Adds NIP-45 count queries to show how many events each relay has.
- Adds Relay sync utility to help users move posts between relays.
Search fixes
- Breaks the search filter into two subscriptions to prioritize Metadata without punishing content.
@@ -86,11 +107,14 @@ Search fixes
Profiles:
- Adds a profile picture upload button when the user has no picture
- Adds last seen to the user profile
- Adds nprofile and npub copy options to the profile
- Groups received zap amounts by sending the user in the profile tab
- Increases the limit of Zap downloads for profiles to 1000
- Simplifies profile edit screen layout by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
- Migrates profile galleries to display a thumbnail for videos
- Fixes profile galleries' aspect ratios
- Adds support for Namecoin .bit urls to NIP-05 and choice of ElectrumX server to resolve namecoins.
Bulk Follow onboarding
- Adds screens to search for a user and to copy his/her follow list
@@ -124,6 +148,7 @@ Fixes:
- Fixes bug on Show More calculations for very long texts without spaces
- Fixing IO Dispatchers and coroutine scopes of choice
- Fixes anySync parallel operation that was returning the first result, not the first positive "any".
- Fixes Req onCannotConnect listeners to the relays that actually sent the req
AI:
- Add SKILL.md for AI agent customization
@@ -135,6 +160,14 @@ Defaults:
- Adds wss://directory.yabu.me and wss://profiles.nostr1.com as index relays
- Adds electrumx.testls.space, nmc2.bitcoins.sk, 46.229.238.187 and i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion as ElectrumX servers
Quartz:
- Adds Relay Server implementation with NIP-45 COUNT and NIP-42 AUTH support
- Adds support for dynamic policies to the relay implementation.
- Migrates Quartz EventStore from Android-only to KMP
- Adds a reqUntilEoseAsFlow extension to the Nostr Client
- Adds comprehensive NIP-46 Bunker support
- Adds comprehensive support for NIP-47 non-payment methods.
Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
- Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge.
- Provide implementation for LargeCache, using a CacheMap
@@ -147,7 +180,6 @@ Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53
- Provide implementation for AESGCM
- Provide implementation for DigestInstance
- Provide implementation for LibSodium
- Adds a reqUntilEoseAsFlow extension to the Nostr Client
Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
- Adds NIP-46 Bunker Login
@@ -157,6 +189,8 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7
- Adds encrypted DMs (NIP-04/NIP-17)
- Adds proper empty states with EOSE tracking
- Adds multi-column deck layout
- Adds Full media parity — images, video, audio, encrypted DMs, upload, lightbox
- Adds advanced search with NIP-50, collapsible sections, and nav state preservation
- Clear stored credentials on logout
- Adds bunker heartbeat indicator
- Adds QR-based signer pairing
@@ -174,6 +208,7 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7
Code Quality
- Migrates to AGP 9.0
- Adds Amethyst Desktop to CI/CD and Release builds
- Removes the in-app memory counter methods
- Refactors the old NIP-05 code on Quartz
- Migrates contact list management to addressable notes
@@ -7006,4 +7041,4 @@ First public version with:
[v0.4]: https://github.com/vitorpamplona/amethyst/compare/v0.3...v0.4
[v0.3]: https://github.com/vitorpamplona/amethyst/compare/v0.2...v0.3
[v0.2]: https://github.com/vitorpamplona/amethyst/compare/v0.1...v0.2
[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1
[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1
+7
View File
@@ -218,6 +218,13 @@ Build and run the Desktop app (requires Java 21+):
```bash
./gradlew :desktopApp:run
```
Full build (including tests)
```bash
./gradlew build
```
Requirements:
- Xcode and iOS simulator
- libsodium installed (e.g. via brew: `brew install libsodium`
## Testing
```bash
+14 -2
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 {
namespace = 'com.vitorpamplona.amethyst'
compileSdk = libs.versions.android.compileSdk.get().toInteger()
@@ -336,9 +347,7 @@ dependencies {
fdroidImplementation libs.unifiedpush
// Charts
implementation libs.vico.charts.core
implementation libs.vico.charts.compose
implementation libs.vico.charts.views
implementation libs.vico.charts.m3
// GeoHash
@@ -352,6 +361,9 @@ dependencies {
// Image compression lib
implementation libs.zelory.image.compressor
// EXIF metadata stripping
implementation libs.androidx.exifinterface
// Voice anonymization DSP
implementation libs.tarsosdsp
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EventSyncTest {
companion object {
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
@Test
fun testSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = {
listOf(Constants.mom, Constants.nos)
},
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
NostrClient(socketBuilder, appScope)
},
scope = appScope,
)
sync.runSync()
}
@Test
fun testFiatjafSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = { listOf(fiatjaf) },
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
val newClient = NostrClient(socketBuilder, appScope)
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
val signer = NostrSignerInternal(KeyPair())
// Authenticates with relays.
val auth =
RelayAuthenticator(
newClient,
appScope,
signWithAllLoggedInUsers = { authTemplate ->
listOf(signer.sign(authTemplate))
},
)
newClient
},
scope = appScope,
)
sync.runSync()
}
}
+5
View File
@@ -240,6 +240,11 @@
<action android:name="com.shared.NOSTR" />
</intent-filter>
</receiver>
<receiver
android:name=".service.notifications.NotificationReplyReceiver"
android:exported="false" />
</application>
@@ -91,6 +91,7 @@ private object PrefKeys {
const val NOSTR_PUBKEY = "nostr_pubkey"
const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
@@ -322,6 +323,8 @@ object LocalPreferences {
JsonMapper.toJson(settings.defaultFileServer),
)
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNotificationFollowList.value))
@@ -461,6 +464,7 @@ object LocalPreferences {
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
@@ -503,6 +507,7 @@ object LocalPreferences {
externalSignerPackageName = externalSignerPackageName,
localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServer,
stripLocationOnUpload = stripLocationOnUpload,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
@@ -171,7 +171,8 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
@@ -225,7 +226,6 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.util.Locale
import kotlin.coroutines.cancellation.CancellationException
@OptIn(DelicateCoroutinesApi::class)
@@ -507,7 +507,7 @@ class Account(
sendNewAppSpecificData()
}
suspend fun updateTranslateTo(languageCode: Locale) {
suspend fun updateTranslateTo(languageCode: String) {
if (settings.updateTranslateTo(languageCode)) {
sendNewAppSpecificData()
}
@@ -591,6 +591,14 @@ class Account(
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState)
suspend fun sendNwcRequest(
request: Request,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse)
client.send(event, setOf(relay))
}
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
@@ -1601,7 +1609,7 @@ class Account(
client.send(newEvent, outboxRelays.flow.value + destinationRelays)
}
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
if (!isWriteable()) return
val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer)
@@ -2008,6 +2016,7 @@ class Account(
}
scope.launch(Dispatchers.IO) {
@OptIn(kotlinx.coroutines.FlowPreview::class)
settings.saveable.debounce(1000).collect {
if (it.accountSettings != null) {
LocalPreferences.saveToEncryptedStorage(it.accountSettings)
@@ -63,7 +63,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable
import java.util.Locale
val DefaultChannels =
listOf(
@@ -161,6 +160,7 @@ class AccountSettings(
var externalSignerPackageName: String? = null,
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
var stripLocationOnUpload: Boolean = true,
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -266,6 +266,13 @@ class AccountSettings(
}
}
fun changeStripLocationOnUpload(strip: Boolean) {
if (stripLocationOnUpload != strip) {
stripLocationOnUpload = strip
saveAccountSettings()
}
}
// ---
// list names
// ---
@@ -332,11 +339,11 @@ class AccountSettings(
saveAccountSettings()
}
fun translateToContains(languageCode: Locale) =
fun translateToContains(languageCode: String) =
syncedSettings.languages.translateTo.value
.contains(languageCode.language)
.contains(languageCode)
fun updateTranslateTo(languageCode: Locale): Boolean {
fun updateTranslateTo(languageCode: String): Boolean {
if (syncedSettings.languages.updateTranslateTo(languageCode)) {
saveAccountSettings()
return true
@@ -27,7 +27,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.util.Locale
@Stable
class AccountSyncedSettings(
@@ -165,11 +164,11 @@ class AccountLanguagePreferences(
dontTranslateFrom.update { it - languageCode }
}
fun translateToContains(languageCode: Locale) = translateTo.value.contains(languageCode.language)
fun translateToContains(languageCode: String) = translateTo.value.contains(languageCode)
fun updateTranslateTo(languageCode: Locale): Boolean {
if (translateTo.value != languageCode.language) {
translateTo.tryEmit(languageCode.language)
fun updateTranslateTo(languageCode: String): Boolean {
if (translateTo.value != languageCode) {
translateTo.tryEmit(languageCode)
return true
}
return false
@@ -39,6 +39,10 @@ import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
@@ -58,7 +62,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -135,8 +139,8 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -325,7 +329,7 @@ object LocalCache : ILocalCache, ICacheProvider {
newFilter.init()
observables.put(newFilter, newFilter)
observables[newFilter] = newFilter
awaitClose {
observables.remove(newFilter)
@@ -358,19 +362,19 @@ object LocalCache : ILocalCache, ICacheProvider {
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
override fun getOrCreateUser(key: HexKey): User {
require(isValidHex(key = key)) { "$key is not a valid hex" }
override fun getOrCreateUser(pubkey: HexKey): User {
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
return users.getOrCreate(key) {
val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(key))
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(key))
return users.getOrCreate(pubkey) {
val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(pubkey))
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(pubkey))
User(it, nip65RelayListNote, dmRelayListNote)
}
}
override fun getUserIfExists(key: String): User? {
if (key.isEmpty()) return null
return users.get(key)
override fun getUserIfExists(pubkey: String): User? {
if (pubkey.isEmpty()) return null
return users.get(pubkey)
}
override fun countUsers(predicate: (String, User) -> Boolean): Int {
@@ -394,7 +398,7 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address)
override fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) }
override fun getNoteIfExists(hexKey: String): Note? = if (hexKey.length == 64) notes.get(hexKey) else Address.parse(hexKey)?.let { addressables.get(it) }
fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId)
@@ -619,6 +623,30 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestationRequestEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestorRecommendationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestorProficiencyEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consumeRegularEvent(
event: Event,
relay: NormalizedRelayUrl?,
@@ -961,7 +989,7 @@ object LocalCache : ILocalCache, ICacheProvider {
@Suppress("DEPRECATION")
fun computeReplyTo(event: Event): List<Note> =
when (event) {
is PollNoteEvent -> {
is ZapPollEvent -> {
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
}
@@ -1059,7 +1087,7 @@ object LocalCache : ILocalCache, ICacheProvider {
}
fun consume(
event: PollNoteEvent,
event: ZapPollEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
@@ -2250,6 +2278,7 @@ object LocalCache : ILocalCache, ICacheProvider {
requestNote?.let { request -> zappedNote?.addZapPayment(request, note) }
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
responseCallback(event)
}
@@ -2344,8 +2373,21 @@ object LocalCache : ILocalCache, ICacheProvider {
if (key != null) {
val note = getNoteIfExists(key)
if ((note != null) && !excludeNoteEventFromSearchResults(note)) {
return listOfNotNull(note)
val noteEvent = note?.event
val newNote =
if (noteEvent is AddressableEvent) {
val addressableNote = getAddressableNoteIfExists(noteEvent.address())
if (addressableNote?.event?.id == note.idHex) {
addressableNote
} else {
note
}
} else {
note
}
if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) {
return listOfNotNull(newNote)
}
}
@@ -3050,6 +3092,10 @@ object LocalCache : ILocalCache, ICacheProvider {
is AppDefinitionEvent -> consume(event, relay, wasVerified)
is AppRecommendationEvent -> consume(event, relay, wasVerified)
is AppSpecificDataEvent -> consume(event, relay, wasVerified)
is AttestationEvent -> consume(event, relay, wasVerified)
is AttestationRequestEvent -> consume(event, relay, wasVerified)
is AttestorRecommendationEvent -> consume(event, relay, wasVerified)
is AttestorProficiencyEvent -> consume(event, relay, wasVerified)
is AudioHeaderEvent -> consume(event, relay, wasVerified)
is AudioTrackEvent -> consume(event, relay, wasVerified)
is BadgeAwardEvent -> consume(event, relay, wasVerified)
@@ -3140,7 +3186,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is PublicMessageEvent -> consume(event, relay, wasVerified)
is PeopleListEvent -> consume(event, relay, wasVerified)
is CodeSnippetEvent -> consume(event, relay, wasVerified)
is PollNoteEvent -> consume(event, relay, wasVerified)
is ZapPollEvent -> consume(event, relay, wasVerified)
is PollEvent -> consume(event, relay, wasVerified)
is PollResponseEvent -> consume(event, relay, wasVerified)
is ReactionEvent -> consume(event, relay, wasVerified)
@@ -31,13 +31,13 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectRequestCache
import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectResponseCache
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectRequestCache
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -114,7 +114,7 @@ class NwcSignerState(
fun hasWalletConnectSetup(): Boolean = nip47Setup.value != null
override fun isNIP47Author(pubkey: HexKey?): Boolean = nip47Signer.value.pubKey == pubkey
override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey
/**
* Decrypts a NIP-47 payment request using the current signer.
@@ -138,6 +138,45 @@ class NwcSignerState(
return zapPaymentResponseDecryptionCache.value.decryptResponse(event)
}
/**
* Sends a generic NIP-47 request to the connected wallet.
* Subscribes to responses and waits up to 60s for a reply.
*
* @param request the NIP-47 request to send
* @param onResponse callback to handle the response from the wallet
* @return a pair containing the request event and target relay URL
* @throws IllegalArgumentException if no NIP-47 wallet is set up
*/
suspend fun sendNwcRequest(
request: Request,
onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup")
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, nip47Signer.value)
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
)
nwcFilterAssembler.subscribe(filter)
scope.launch(Dispatchers.IO) {
delay(60000)
nwcFilterAssembler.unsubscribe(filter)
}
cache.consume(event, null, true, walletService.relayUri) {
onResponse(decryptResponse(it))
}
return Pair(event, walletService.relayUri)
}
/**
* Sends a zap payment request to a connected Lightning wallet.
* Subscribes to responses and waits up to 60s for a reply.
@@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.name
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.title
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -181,7 +181,7 @@ class LabeledBookmarkListsState(
val template =
listEvent.update {
if (listName != null) name(listName)
if (listName != null) title(listName)
if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage)
}
@@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
import com.vitorpamplona.quartz.nip51Lists.peopleList.image
import com.vitorpamplona.quartz.nip51Lists.peopleList.name
import com.vitorpamplona.quartz.nip51Lists.peopleList.title
import com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -226,7 +226,7 @@ class PeopleListsState(
val template =
listEvent.update {
if (listName != null) name(listName)
if (listName != null) title(listName)
if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage)
}
@@ -79,6 +79,7 @@ class MergedFollowListsState(
communities = community.mapTo(mutableSetOf()) { it.address.toValue() },
)
@OptIn(kotlinx.coroutines.FlowPreview::class)
val flow: StateFlow<AllFollows> =
combine(
listOf(
@@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.cashu.v4
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
@@ -34,6 +35,7 @@ class V4Token(
val t: Array<V4T>?,
)
@OptIn(ExperimentalSerializationApi::class)
@Serializable
class V4T(
// identifier
@@ -42,6 +44,7 @@ class V4T(
val p: Array<V4Proof>,
)
@OptIn(ExperimentalSerializationApi::class)
@Serializable
class V4Proof(
// amount
@@ -57,6 +60,7 @@ class V4Proof(
val w: String? = null,
)
@OptIn(ExperimentalSerializationApi::class)
@Serializable
class V4DleqProof(
@ByteString
@@ -183,7 +183,7 @@ class EventNotificationConsumer(
}
}
private fun notify(
private suspend fun notify(
event: ChatMessageEncryptedFileHeaderEvent,
account: Account,
) {
@@ -210,13 +210,13 @@ class EventNotificationConsumer(
val content = chatNote.event?.content ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri =
chatNote.toNEvent() + ACCOUNT_QUERY_PARAM +
account.signer.pubKey
.hexToByteArray()
.toNpub()
val accountNpub =
account.signer.pubKey
.hexToByteArray()
.toNpub()
val chatroomMembers = chatRoom.users.joinToString(",")
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
// TODO: Show Image on notification
notificationManager()
.sendDMNotification(
event.id,
@@ -226,12 +226,15 @@ class EventNotificationConsumer(
userPicture,
noteUri,
applicationContext,
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(),
chatroomMembers = chatroomMembers,
)
}
}
}
private fun notify(
private suspend fun notify(
event: ChatMessageEvent,
account: Account,
) {
@@ -255,20 +258,25 @@ class EventNotificationConsumer(
val content = chatNote.event?.content ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri =
chatNote.toNEvent() + ACCOUNT_QUERY_PARAM +
account.signer.pubKey
.hexToByteArray()
.toNpub()
val accountNpub =
account.signer.pubKey
.hexToByteArray()
.toNpub()
val chatroomMembers = chatRoom.users.joinToString(",")
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
notificationManager()
.sendDMNotification(
event.id,
content,
user,
event.createdAt,
userPicture,
noteUri,
applicationContext,
id = event.id,
messageBody = content,
senderName = user,
time = event.createdAt,
pictureUrl = userPicture,
uri = noteUri,
applicationContext = applicationContext,
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(),
chatroomMembers = chatroomMembers,
)
}
}
@@ -297,13 +305,25 @@ class EventNotificationConsumer(
decryptContent(note, account.signer)?.let { content ->
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri =
note.toNEvent() + ACCOUNT_QUERY_PARAM +
account.signer.pubKey
.hexToByteArray()
.toNpub()
val accountNpub =
account.signer.pubKey
.hexToByteArray()
.toNpub()
val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
notificationManager()
.sendDMNotification(event.id, content, user, event.createdAt, userPicture, noteUri, applicationContext)
.sendDMNotification(
id = event.id,
messageBody = content,
senderName = user,
time = event.createdAt,
pictureUrl = userPicture,
uri = noteUri,
applicationContext = applicationContext,
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(),
chatroomMembers = null,
)
}
}
}
@@ -0,0 +1,113 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
class NotificationReplyReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onReceive(
context: Context,
intent: Intent,
) {
val notificationId = intent.getIntExtra(NotificationUtils.KEY_NOTIFICATION_ID, 0)
val notificationManager =
ContextCompat.getSystemService(context, NotificationManager::class.java)
as NotificationManager
when (intent.action) {
NotificationUtils.MARK_READ_ACTION -> {
notificationManager.cancel(notificationId)
}
NotificationUtils.REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
val chatroomMembersStr = intent.getStringExtra(NotificationUtils.KEY_CHATROOM_MEMBERS) ?: return
val members = chatroomMembersStr.split(",").filter { it.isNotBlank() }
if (members.isEmpty()) return
val pendingResult = goAsync()
scope.launch {
// activates the relay to send the message.
val collectionJob =
scope.launch {
Amethyst.instance.relayProxyClientConnector.relayServices
.collect()
}
try {
sendReply(accountNpub, members, replyText)
notificationManager.cancel(notificationId)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("NotificationReply", "Failed to send reply: ${e.message}")
} finally {
pendingResult.finish()
// closes the relay connection.
collectionJob.cancel()
}
}
}
}
}
private suspend fun sendReply(
accountNpub: String,
chatroomMembers: List<String>,
replyText: String,
) {
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
val recipients = chatroomMembers.map { PTag(it) }
val template = ChatMessageEvent.build(msg = replyText, to = recipients)
account.sendNip17PrivateMessage(template)
}
}
@@ -25,23 +25,37 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import coil3.ImageLoader
import coil3.asDrawable
import coil3.executeBlocking
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object NotificationUtils {
private var dmChannel: NotificationChannel? = null
private var zapChannel: NotificationChannel? = null
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
const val KEY_REPLY_TEXT = "key_reply_text"
const val KEY_NOTIFICATION_ID = "key_notification_id"
const val KEY_ACCOUNT_NPUB = "key_account_npub"
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
private const val DM_SUMMARY_ID = 0x10000
private const val ZAP_SUMMARY_ID = 0x20000
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!!
@@ -50,13 +64,12 @@ object NotificationUtils {
NotificationChannel(
stringRes(applicationContext, R.string.app_notification_dms_channel_id),
stringRes(applicationContext, R.string.app_notification_dms_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
NotificationManager.IMPORTANCE_HIGH,
).apply {
description =
stringRes(applicationContext, R.string.app_notification_dms_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -78,7 +91,6 @@ object NotificationUtils {
stringRes(applicationContext, R.string.app_notification_zaps_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -87,7 +99,7 @@ object NotificationUtils {
return zapChannel!!
}
fun NotificationManager.sendZapNotification(
suspend fun NotificationManager.sendZapNotification(
id: String,
messageBody: String,
messageTitle: String,
@@ -96,109 +108,109 @@ object NotificationUtils {
uri: String,
applicationContext: Context,
) {
val zapChannel = getOrCreateZapChannel(applicationContext)
getOrCreateZapChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_zaps_channel_id)
sendNotification(
id,
messageBody,
messageTitle,
time,
pictureUrl,
uri,
channelId,
ZAP_GROUP_KEY,
applicationContext,
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
pictureUrl = pictureUrl,
uri = uri,
channelId = channelId,
notificationGroupKey = ZAP_GROUP_KEY,
category = NotificationCompat.CATEGORY_SOCIAL,
summaryId = ZAP_SUMMARY_ID,
summaryText = stringRes(applicationContext, R.string.app_notification_zaps_summary),
applicationContext = applicationContext,
)
}
fun NotificationManager.sendDMNotification(
suspend fun NotificationManager.sendDMNotification(
id: String,
messageBody: String,
messageTitle: String,
senderName: String,
time: Long,
pictureUrl: String?,
uri: String,
applicationContext: Context,
accountNpub: String? = null,
accountPictureUrl: String? = null,
chatroomMembers: String? = null,
) {
val dmChannel = getOrCreateDMChannel(applicationContext)
getOrCreateDMChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id)
sendNotification(
id,
messageBody,
messageTitle,
time,
pictureUrl,
uri,
channelId,
DM_GROUP_KEY,
applicationContext,
sendDMNotificationStyled(
id = id,
messageBody = messageBody,
senderName = senderName,
time = time,
pictureUrl = pictureUrl,
uri = uri,
channelId = channelId,
applicationContext = applicationContext,
accountNpub = accountNpub,
accountPictureUrl = accountPictureUrl,
chatroomMembers = chatroomMembers,
)
}
fun NotificationManager.sendNotification(
private suspend fun loadBitmap(
pictureUrl: String,
applicationContext: Context,
): Bitmap? =
withContext(Dispatchers.IO) {
try {
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
val imageLoader = ImageLoader(applicationContext)
val result = imageLoader.execute(request)
(result.image?.asDrawable(applicationContext.resources) as? BitmapDrawable)?.bitmap
} catch (_: Exception) {
null
}
}
private suspend fun NotificationManager.sendDMNotificationStyled(
id: String,
messageBody: String,
messageTitle: String,
senderName: String,
time: Long,
pictureUrl: String?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context,
) {
if (pictureUrl != null) {
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
val imageLoader = ImageLoader(applicationContext)
val imageResult = imageLoader.executeBlocking(request)
sendNotificationInner(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
picture = imageResult.image?.asDrawable(applicationContext.resources) as? BitmapDrawable,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext,
)
} else {
sendNotificationInner(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
picture = null,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext,
)
}
}
private fun NotificationManager.sendNotificationInner(
id: String,
messageBody: String,
messageTitle: String,
time: Long,
picture: BitmapDrawable?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context,
accountNpub: String?,
accountPictureUrl: String?,
chatroomMembers: String?,
) {
val notId = id.hashCode()
// dont notify twice
val notifications: Array<StatusBarNotification> = getActiveNotifications()
for (notification in notifications) {
if (notification.id == notId) {
return
}
}
if (isDuplicate(notId)) return
val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) }
val accountBitmap = accountPictureUrl?.let { loadBitmap(it, applicationContext) }
val senderIcon = bitmap?.let { IconCompat.createWithBitmap(it) }
val accountIcon = accountBitmap?.let { IconCompat.createWithBitmap(it) }
val sender =
Person
.Builder()
.setName(senderName)
.apply { senderIcon?.let { setIcon(it) } }
.build()
val messagingStyle =
NotificationCompat
.MessagingStyle(
Person
.Builder()
.setName("Me")
.setIcon(accountIcon)
.build(),
).addMessage(messageBody, time * 1000, sender)
val contentIntent =
Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() }
@@ -208,41 +220,198 @@ object NotificationUtils {
applicationContext,
notId,
contentIntent,
PendingIntent.FLAG_MUTABLE,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
// Build the notification
val builderPublic =
NotificationCompat
.Builder(
applicationContext,
channelId,
).setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(senderName)
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
.setLargeIcon(picture?.bitmap)
.setLargeIcon(bitmap)
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setWhen(time * 1000)
// Build the notification
val builder =
NotificationCompat
.Builder(
applicationContext,
channelId,
).setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setLargeIcon(picture?.bitmap)
.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.amethyst)
.setLargeIcon(bitmap)
.setStyle(messagingStyle)
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(DM_GROUP_KEY)
.setAutoCancel(true)
.setWhen(time * 1000)
// Direct Reply action
if (accountNpub != null && chatroomMembers != null) {
val remoteInput =
RemoteInput
.Builder(KEY_REPLY_TEXT)
.setLabel(stringRes(applicationContext, R.string.app_notification_reply_label))
.build()
val replyIntent =
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
action = REPLY_ACTION
putExtra(KEY_NOTIFICATION_ID, notId)
putExtra(KEY_ACCOUNT_NPUB, accountNpub)
putExtra(KEY_CHATROOM_MEMBERS, chatroomMembers)
}
val replyPendingIntent =
PendingIntent.getBroadcast(
applicationContext,
notId,
replyIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val replyAction =
NotificationCompat.Action
.Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.build()
builder.addAction(replyAction)
}
// Mark as Read action
val markReadIntent =
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
action = MARK_READ_ACTION
putExtra(KEY_NOTIFICATION_ID, notId)
}
val markReadPendingIntent =
PendingIntent.getBroadcast(
applicationContext,
notId + 1,
markReadIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val markReadAction =
NotificationCompat.Action
.Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.build()
builder.addAction(markReadAction)
notify(notId, builder.build())
// Group summary notification
sendGroupSummary(channelId, DM_GROUP_KEY, DM_SUMMARY_ID, stringRes(applicationContext, R.string.app_notification_dms_summary), applicationContext)
}
private suspend fun NotificationManager.sendNotification(
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
channelId: String,
notificationGroupKey: String,
category: String,
summaryId: Int,
summaryText: String,
applicationContext: Context,
) {
val notId = id.hashCode()
if (isDuplicate(notId)) return
val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) }
val contentIntent =
Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() }
val contentPendingIntent =
PendingIntent.getActivity(
applicationContext,
notId,
contentIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val builderPublic =
NotificationCompat
.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
.setLargeIcon(bitmap)
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setWhen(time * 1000)
val builder =
NotificationCompat
.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setStyle(NotificationCompat.BigTextStyle().bigText(messageBody))
.setLargeIcon(bitmap)
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(category)
.setGroup(notificationGroupKey)
.setAutoCancel(true)
.setWhen(time * 1000)
notify(notId, builder.build())
sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext)
}
private fun NotificationManager.sendGroupSummary(
channelId: String,
groupKey: String,
summaryId: Int,
summaryText: String,
applicationContext: Context,
) {
val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId }
if (activeCount < 2) return
val summaryBuilder =
NotificationCompat
.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.amethyst)
.setGroup(groupKey)
.setGroupSummary(true)
.setAutoCancel(true)
.setStyle(
NotificationCompat
.InboxStyle()
.setSummaryText(summaryText),
)
notify(summaryId, summaryBuilder.build())
}
private fun NotificationManager.isDuplicate(notId: Int): Boolean {
val notifications: Array<StatusBarNotification> = activeNotifications
for (notification in notifications) {
if (notification.id == notId) {
return true
}
}
return false
}
/** Cancels all notifications. */
@@ -32,12 +32,9 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PictureInPicture
import androidx.compose.material.icons.filled.SaveAlt
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
@@ -45,9 +42,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
@@ -134,61 +133,41 @@ fun OverflowMenuButton(
modifier = Size20Modifier,
)
}
}
DropdownMenu(
expanded = menuExpanded.value,
onDismissRequest = { menuExpanded.value = false },
containerColor = Color.Black.copy(alpha = 0.85f),
if (menuExpanded.value) {
M3ActionDialog(
title = stringRes(R.string.playback_actions_dialog_title),
onDismiss = { menuExpanded.value = false },
) {
if (showShare) {
DropdownMenuItem(
text = { Text(stringRes(R.string.share_or_save), color = Color.White) },
onClick = {
M3ActionSection {
if (showShare) {
M3ActionRow(
icon = Icons.Default.Share,
text = stringRes(R.string.share_or_save),
) {
menuExpanded.value = false
onShareClick()
},
leadingIcon = {
Icon(
Icons.Default.Share,
contentDescription = null,
tint = Color.White,
)
},
)
}
if (showSave) {
DropdownMenuItem(
text = { Text(stringRes(R.string.download_to_phone), color = Color.White) },
onClick = {
}
}
if (showSave) {
M3ActionRow(
icon = Icons.Default.SaveAlt,
text = stringRes(R.string.download_to_phone),
) {
menuExpanded.value = false
onSaveClick()
},
leadingIcon = {
Icon(
Icons.Default.SaveAlt,
contentDescription = null,
tint = Color.White,
)
},
)
}
if (showPip) {
DropdownMenuItem(
text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) },
onClick = {
}
}
if (showPip) {
M3ActionRow(
icon = Icons.Default.PictureInPicture,
text = stringRes(R.string.picture_in_picture),
) {
menuExpanded.value = false
onPipClick()
},
leadingIcon = {
Icon(
Icons.Default.PictureInPicture,
contentDescription = null,
tint = Color.White,
)
},
)
}
}
}
}
}
@@ -20,11 +20,13 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -38,7 +40,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
@@ -65,7 +67,7 @@ val NotificationsPerKeyKinds =
ChannelMessageEvent.KIND,
EphemeralChatEvent.KIND,
BadgeAwardEvent.KIND,
PollNoteEvent.KIND,
ZapPollEvent.KIND,
PollEvent.KIND,
PollResponseEvent.KIND,
PublicMessageEvent.KIND,
@@ -85,6 +87,12 @@ val NotificationsPerKeyKinds2 =
InteractiveStorySceneEvent.KIND,
)
val NotificationsPerKeyKinds3 =
listOf(
AttestationRequestEvent.KIND,
AttestorRecommendationEvent.KIND,
)
fun filterSummaryNotificationsToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
@@ -130,7 +138,17 @@ fun filterNotificationsToPubkey(
Filter(
kinds = NotificationsPerKeyKinds2,
tags = mapOf("p" to listOf(pubkey)),
limit = 500,
limit = 200,
since = since,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = NotificationsPerKeyKinds3,
tags = mapOf("p" to listOf(pubkey)),
limit = 10,
since = since,
),
),
@@ -171,7 +189,17 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays(
Filter(
kinds = NotificationsPerKeyKinds2,
tags = mapOf("p" to listOf(pubkey)),
limit = 20,
limit = 10,
since = since,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = NotificationsPerKeyKinds3,
tags = mapOf("p" to listOf(pubkey)),
limit = 2,
since = since,
),
),
@@ -51,14 +51,14 @@ class EventWatcherSubAssembler(
}
override fun updateFilter(
key: List<EventFinderQueryState>,
keys: List<EventFinderQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
if (key.isEmpty()) {
if (keys.isEmpty()) {
return null
}
lastNotesOnFilter = key.map { it.note }
lastNotesOnFilter = keys.map { it.note }
return groupByRelayPresence(lastNotesOnFilter, latestEOSEs)
.map { group ->
@@ -22,7 +22,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
@@ -45,8 +46,9 @@ val RepliesAndReactionsToAddressesKinds1 =
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
ZapPollEvent.KIND,
CommentEvent.KIND,
AttestationEvent.KIND,
)
val PostsAndChatMessagesToAddresses =
@@ -22,8 +22,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
@@ -53,6 +54,7 @@ val RepliesAndReactionsKinds =
OtsEvent.KIND,
TextNoteModificationEvent.KIND,
CommentEvent.KIND,
AttestationEvent.KIND,
)
val RepliesAndReactionsKinds2 =
@@ -63,7 +65,7 @@ val RepliesAndReactionsKinds2 =
TorrentCommentEvent.KIND,
GitReplyEvent.KIND,
PollResponseEvent.KIND,
PollNoteEvent.KIND,
ZapPollEvent.KIND,
)
fun filterRepliesAndReactionsToNotes(
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
fun filterNWCPaymentsFromRequests(
serviceKeys: Set<HexKey>,
@@ -26,7 +26,7 @@ import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
@@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -64,7 +64,7 @@ val SearchPostsByTextKinds1 =
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
ZapPollEvent.KIND,
ChannelCreateEvent.KIND,
)
@@ -0,0 +1,407 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads
import android.content.Context
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.media.MediaMuxer
import android.net.Uri
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import java.io.File
import java.nio.ByteBuffer
data class StrippingResult(
val uri: Uri,
val stripped: Boolean,
)
object MetadataStripper {
private const val DEFAULT_REMUX_BUFFER_SIZE = 8 * 1024 * 1024
private val SENSITIVE_EXIF_TAGS =
arrayOf(
ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_LONGITUDE_REF,
ExifInterface.TAG_GPS_ALTITUDE,
ExifInterface.TAG_GPS_ALTITUDE_REF,
ExifInterface.TAG_GPS_TIMESTAMP,
ExifInterface.TAG_GPS_DATESTAMP,
ExifInterface.TAG_GPS_PROCESSING_METHOD,
ExifInterface.TAG_GPS_AREA_INFORMATION,
ExifInterface.TAG_GPS_SPEED,
ExifInterface.TAG_GPS_SPEED_REF,
ExifInterface.TAG_GPS_TRACK,
ExifInterface.TAG_GPS_TRACK_REF,
ExifInterface.TAG_GPS_IMG_DIRECTION,
ExifInterface.TAG_GPS_IMG_DIRECTION_REF,
ExifInterface.TAG_GPS_DEST_LATITUDE,
ExifInterface.TAG_GPS_DEST_LATITUDE_REF,
ExifInterface.TAG_GPS_DEST_LONGITUDE,
ExifInterface.TAG_GPS_DEST_LONGITUDE_REF,
ExifInterface.TAG_GPS_DEST_BEARING,
ExifInterface.TAG_GPS_DEST_BEARING_REF,
ExifInterface.TAG_GPS_DEST_DISTANCE,
ExifInterface.TAG_GPS_DEST_DISTANCE_REF,
ExifInterface.TAG_GPS_MAP_DATUM,
ExifInterface.TAG_GPS_DOP,
ExifInterface.TAG_GPS_MEASURE_MODE,
ExifInterface.TAG_GPS_SATELLITES,
ExifInterface.TAG_GPS_STATUS,
ExifInterface.TAG_GPS_VERSION_ID,
ExifInterface.TAG_MAKE,
ExifInterface.TAG_MODEL,
ExifInterface.TAG_SOFTWARE,
ExifInterface.TAG_ARTIST,
ExifInterface.TAG_COPYRIGHT,
ExifInterface.TAG_CAMERA_OWNER_NAME,
ExifInterface.TAG_BODY_SERIAL_NUMBER,
ExifInterface.TAG_LENS_SERIAL_NUMBER,
ExifInterface.TAG_LENS_MAKE,
ExifInterface.TAG_LENS_MODEL,
ExifInterface.TAG_DATETIME,
ExifInterface.TAG_DATETIME_ORIGINAL,
ExifInterface.TAG_DATETIME_DIGITIZED,
ExifInterface.TAG_OFFSET_TIME,
ExifInterface.TAG_OFFSET_TIME_ORIGINAL,
ExifInterface.TAG_OFFSET_TIME_DIGITIZED,
ExifInterface.TAG_IMAGE_UNIQUE_ID,
ExifInterface.TAG_USER_COMMENT,
)
private fun extractorToCodecFlags(sampleFlags: Int): Int {
var flags = 0
if (sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0) {
flags = flags or MediaCodec.BUFFER_FLAG_KEY_FRAME
}
if (sampleFlags and MediaExtractor.SAMPLE_FLAG_PARTIAL_FRAME != 0) {
flags = flags or MediaCodec.BUFFER_FLAG_PARTIAL_FRAME
}
return flags
}
private fun remuxTracks(
uri: Uri,
context: Context,
outputFile: File,
preStart: (MediaMuxer, MediaExtractor, Context, Uri) -> Unit = { _, _, _, _ -> },
): Boolean {
val extractor = MediaExtractor()
var muxer: MediaMuxer? = null
var muxerStarted = false
var succeeded = false
try {
extractor.setDataSource(context, uri, null)
if (extractor.trackCount == 0) return false
// Note: MediaMuxer may still write a creation timestamp and encoder info into
// the new container. This is not controllable via the Android API and is a
// known residual privacy limitation of the remux approach.
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val trackIndexMap = mutableMapOf<Int, Int>()
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
trackIndexMap[i] = muxer.addTrack(format)
extractor.selectTrack(i)
}
preStart(muxer, extractor, context, uri)
muxer.start()
muxerStarted = true
// Size buffer to the largest track's KEY_MAX_INPUT_SIZE (covers 4K keyframes),
// falling back to 8MB if the format doesn't report it.
var maxInputSize = DEFAULT_REMUX_BUFFER_SIZE
for (i in 0 until extractor.trackCount) {
val fmt = extractor.getTrackFormat(i)
if (fmt.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
maxInputSize = maxOf(maxInputSize, fmt.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
}
}
val buffer = ByteBuffer.allocateDirect(maxInputSize)
val bufferInfo = MediaCodec.BufferInfo()
while (true) {
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) break
val outputTrack = trackIndexMap[extractor.sampleTrackIndex] ?: break
bufferInfo.offset = 0
bufferInfo.size = sampleSize
bufferInfo.presentationTimeUs = extractor.sampleTime
bufferInfo.flags = extractorToCodecFlags(extractor.sampleFlags)
muxer.writeSampleData(outputTrack, buffer, bufferInfo)
extractor.advance()
}
muxer.stop()
muxerStarted = false
succeeded = true
} finally {
if (muxerStarted) runCatching { muxer?.stop() }
muxer?.release()
extractor.release()
if (!succeeded && !outputFile.delete()) {
Log.w("MetadataStripper", "Failed to delete temp file: ${outputFile.absolutePath}")
}
}
return succeeded
}
fun stripImageMetadata(
uri: Uri,
context: Context,
): StrippingResult {
var tempFile: File? = null
return try {
val mimeType = context.contentResolver.getType(uri) ?: ""
val extension =
when {
mimeType.endsWith("jpeg", ignoreCase = true) ||
mimeType.endsWith("jpg", ignoreCase = true) -> ".jpg"
mimeType.endsWith("png", ignoreCase = true) -> ".png"
mimeType.endsWith("webp", ignoreCase = true) -> ".webp"
else -> ".tmp"
}
tempFile = File.createTempFile("stripped_", extension, context.cacheDir)
val inputStream =
context.contentResolver.openInputStream(uri)
?: run {
if (!tempFile.delete()) {
Log.w("MetadataStripper", "Failed to delete temp file: ${tempFile.absolutePath}")
}
return StrippingResult(uri, false)
}
inputStream.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
val exif = ExifInterface(tempFile.absolutePath)
for (tag in SENSITIVE_EXIF_TAGS) {
exif.setAttribute(tag, null)
}
exif.saveAttributes()
Log.d("MetadataStripper", "Stripped EXIF metadata from image")
StrippingResult(tempFile.toUri(), true)
} catch (e: Exception) {
if (e is CancellationException) throw e
if (tempFile?.delete() == false) {
Log.w("MetadataStripper", "Failed to delete temp file: ${tempFile.absolutePath}")
}
Log.d("MetadataStripper", "Failed to strip image metadata: ${e.message}")
StrippingResult(uri, false)
}
}
fun stripVideoMetadata(
uri: Uri,
context: Context,
): StrippingResult {
return try {
val tempOutputFile = File.createTempFile("stripped_video_", ".mp4", context.cacheDir)
val succeeded =
remuxTracks(uri, context, tempOutputFile) { muxer, _, ctx, sourceUri ->
// Rotation is a container-level property not included in track formats;
// read it explicitly and reapply so the output plays back with the correct orientation.
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(ctx, sourceUri)
val rotation =
retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
?.toIntOrNull() ?: 0
if (rotation != 0) muxer.setOrientationHint(rotation)
} finally {
retriever.release()
}
}
if (!succeeded) return StrippingResult(uri, false)
Log.d("MetadataStripper", "Stripped metadata from video")
StrippingResult(tempOutputFile.toUri(), true)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d("MetadataStripper", "Failed to strip video metadata: ${e.message}")
StrippingResult(uri, false)
}
}
fun stripAudioMetadata(
uri: Uri,
context: Context,
): StrippingResult {
return try {
// Verify the primary track is AAC/MP4A before remuxing
val extractor = MediaExtractor()
try {
extractor.setDataSource(context, uri, null)
if (extractor.trackCount == 0) return StrippingResult(uri, false)
val primaryMime = extractor.getTrackFormat(0).getString(MediaFormat.KEY_MIME) ?: ""
if (!primaryMime.contains("mp4a") && !primaryMime.contains("aac")) {
return StrippingResult(uri, false)
}
} finally {
extractor.release()
}
val tempOutputFile = File.createTempFile("stripped_audio_", ".m4a", context.cacheDir)
val succeeded = remuxTracks(uri, context, tempOutputFile)
if (!succeeded) return StrippingResult(uri, false)
Log.d("MetadataStripper", "Stripped metadata from audio")
StrippingResult(tempOutputFile.toUri(), true)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d("MetadataStripper", "Failed to strip audio metadata: ${e.message}")
StrippingResult(uri, false)
}
}
fun stripMp3Metadata(
uri: Uri,
context: Context,
): StrippingResult {
var tempInputFile: File? = null
return try {
tempInputFile = File.createTempFile("mp3_input_", ".mp3", context.cacheDir)
context.contentResolver.openInputStream(uri)?.use { input ->
tempInputFile.outputStream().use { output ->
input.copyTo(output)
}
} ?: run {
if (!tempInputFile.delete()) {
Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}")
}
return StrippingResult(uri, false)
}
val fileSize = tempInputFile.length()
var startOffset = 0L
var endOffset = fileSize
// Read first 10 bytes to check for ID3v2 header
val header = ByteArray(10)
tempInputFile.inputStream().use { it.read(header) }
if (fileSize >= 10 &&
header[0] == 'I'.code.toByte() &&
header[1] == 'D'.code.toByte() &&
header[2] == '3'.code.toByte()
) {
val size =
(header[6].toInt() and 0x7F shl 21) or
(header[7].toInt() and 0x7F shl 14) or
(header[8].toInt() and 0x7F shl 7) or
(header[9].toInt() and 0x7F)
startOffset = 10L + size
}
// Read last 128 bytes to check for ID3v1 tag
if (endOffset - startOffset >= 128) {
val tail = ByteArray(128)
java.io.RandomAccessFile(tempInputFile, "r").use { raf ->
raf.seek(endOffset - 128)
raf.readFully(tail)
}
if (tail[0] == 'T'.code.toByte() &&
tail[1] == 'A'.code.toByte() &&
tail[2] == 'G'.code.toByte()
) {
endOffset -= 128
}
}
if (startOffset == 0L && endOffset == fileSize) {
if (!tempInputFile.delete()) {
Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}")
}
tempInputFile = null
return StrippingResult(uri, true) // no tags found, already clean
}
val tempOutputFile = File.createTempFile("stripped_mp3_", ".mp3", context.cacheDir)
java.io.RandomAccessFile(tempInputFile, "r").use { raf ->
raf.seek(startOffset)
tempOutputFile.outputStream().use { output ->
val buffer = ByteArray(8192)
var remaining = endOffset - startOffset
while (remaining > 0) {
val toRead = minOf(buffer.size.toLong(), remaining).toInt()
val read = raf.read(buffer, 0, toRead)
if (read <= 0) break
output.write(buffer, 0, read)
remaining -= read
}
}
}
if (!tempInputFile.delete()) {
Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}")
}
tempInputFile = null
Log.d("MetadataStripper", "Stripped ID3 tags from MP3")
StrippingResult(tempOutputFile.toUri(), true)
} catch (e: Exception) {
if (e is CancellationException) throw e
if (tempInputFile?.delete() == false) {
Log.w("MetadataStripper", "Failed to delete temp file: ${tempInputFile.absolutePath}")
}
Log.d("MetadataStripper", "Failed to strip MP3 metadata: ${e.message}")
StrippingResult(uri, false)
}
}
fun strip(
uri: Uri,
mimeType: String?,
context: Context,
): StrippingResult =
when {
mimeType?.startsWith("image/", ignoreCase = true) == true -> stripImageMetadata(uri, context)
mimeType?.startsWith("video/", ignoreCase = true) == true -> stripVideoMetadata(uri, context)
mimeType?.equals("audio/mpeg", ignoreCase = true) == true -> stripMp3Metadata(uri, context)
mimeType?.startsWith("audio/", ignoreCase = true) == true -> stripAudioMetadata(uri, context)
else -> StrippingResult(uri, false)
}
}
@@ -59,6 +59,8 @@ class MultiOrchestrator(
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
): Result {
coroutineScope {
val jobs =
@@ -74,6 +76,8 @@ class MultiOrchestrator(
account,
context,
useH265,
stripMetadata,
onStrippingFailed,
)
}
}
@@ -93,6 +97,8 @@ class MultiOrchestrator(
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
): Result {
coroutineScope {
val jobs =
@@ -109,6 +115,8 @@ class MultiOrchestrator(
account,
context,
useH265,
stripMetadata,
onStrippingFailed,
)
}
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
data class ConfirmationCallbacks(
val onConfirm: () -> Unit,
val onCancel: () -> Unit,
)
@Stable
class SuspendableConfirmation {
var state by mutableStateOf<ConfirmationCallbacks?>(null)
private set
private val mutex = Mutex()
suspend fun awaitConfirmation(): Boolean =
mutex.withLock {
suspendCancellableCoroutine { continuation ->
state =
ConfirmationCallbacks(
onConfirm = {
state = null
continuation.resume(true)
},
onCancel = {
state = null
continuation.resume(false)
},
)
}
}
}
@@ -292,6 +292,38 @@ class UploadOrchestrator {
MediaCompressorResult(uri, mimeType, null)
}
private suspend fun stripAfterCompression(
originalUri: Uri,
compressed: MediaCompressorResult,
mimeType: String?,
compressionQuality: CompressorQuality,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
context: Context,
): Uri? {
if (!stripMetadata) return compressed.uri
val effectiveMimeType = compressed.contentType ?: mimeType
val isVideo = effectiveMimeType?.startsWith("video/", ignoreCase = true) == true
val compressionRequested = compressionQuality != CompressorQuality.UNCOMPRESSED
val compressionApplied = compressionRequested && compressed.uri != originalUri
val strippingResult =
if (isVideo && compressionApplied) {
// Compression was requested and actually applied to a video;
// assume it stripped metadata successfully.
StrippingResult(compressed.uri, true)
} else {
MetadataStripper.strip(compressed.uri, effectiveMimeType, context.applicationContext)
}
if (!strippingResult.stripped) {
if (!onStrippingFailed()) return null
}
return strippingResult.uri
}
suspend fun upload(
uri: Uri,
mimeType: String?,
@@ -302,13 +334,19 @@ class UploadOrchestrator {
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
val finalUri =
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
?: return error(R.string.upload_cancelled)
return when (server.type) {
ServerType.NIP95 -> uploadNIP95(compressed.uri, compressed.contentType, null, null, context)
ServerType.NIP96 -> uploadNIP96(compressed.uri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
ServerType.Blossom -> uploadBlossom(compressed.uri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context)
ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
}
}
@@ -323,9 +361,16 @@ class UploadOrchestrator {
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
val encrypted = EncryptFiles().encryptFile(context, compressed.uri, encrypt)
val finalUri =
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
?: return error(R.string.upload_cancelled)
val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt)
return when (server.type) {
ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context)
@@ -125,6 +125,8 @@ fun EditPostView(
postViewModel.load(edit, versionLookingAt)
}
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
Dialog(
onDismissRequest = { onClose() },
properties =
@@ -264,8 +266,9 @@ fun EditPostView(
ImageVideoDescription(
it,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context)
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context, stripMetadata)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -372,6 +375,7 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) {
) {
SelectFromGallery(
isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
@@ -379,7 +383,8 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) {
}
SelectFromFiles(
isUploading = postViewModel.isUploadingImage,
isUploading = postViewModel.isUploadingFile,
enabled = !postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
@@ -37,8 +37,10 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
@@ -79,7 +81,9 @@ open class EditPostViewModel : ViewModel() {
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val mediaUploadTracker = MediaUploadTracker()
val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage
val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile
var userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
@@ -87,6 +91,9 @@ open class EditPostViewModel : ViewModel() {
// Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
// Stripping failure dialog
val strippingFailureConfirmation = SuspendableConfirmation()
// Codec selection: false = H264, true = H265
var useH265Codec by mutableStateOf(false)
@@ -158,8 +165,9 @@ open class EditPostViewModel : ViewModel() {
server: ServerName,
onError: (String, String) -> Unit,
context: Context,
stripMetadata: Boolean = true,
) = try {
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context)
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context, stripMetadata)
} catch (e: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
@@ -175,12 +183,13 @@ open class EditPostViewModel : ViewModel() {
server: ServerName,
onError: (String, String) -> Unit,
context: Context,
stripMetadata: Boolean = true,
) {
viewModelScope.launch(Dispatchers.IO) {
val myAccount = account
val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true
mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
val results =
myMultiOrchestrator.upload(
@@ -191,6 +200,8 @@ open class EditPostViewModel : ViewModel() {
myAccount,
context,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (results.allGood) {
@@ -243,7 +254,7 @@ open class EditPostViewModel : ViewModel() {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
isUploadingImage = false
mediaUploadTracker.finishUpload()
}
}
@@ -255,7 +266,7 @@ open class EditPostViewModel : ViewModel() {
multiOrchestrator = null
urlPreview = null
isUploadingImage = false
mediaUploadTracker.finishUpload()
wantsInvoice = false
@@ -296,7 +307,7 @@ open class EditPostViewModel : ViewModel() {
}
}
fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && multiOrchestrator == null
fun canPost() = message.text.isNotBlank() && !mediaUploadTracker.isUploading && !wantsInvoice && multiOrchestrator == null
fun selectImage(uris: ImmutableList<SelectedMedia>) {
multiOrchestrator = MultiOrchestrator(uris)
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
@@ -59,12 +60,18 @@ open class NewMediaModel : ViewModel() {
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
var onceUploaded: () -> Unit = {}
// Stripping failure dialog
val strippingFailureConfirmation = SuspendableConfirmation()
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1)
// Codec selection: false = H264, true = H265
var useH265Codec by mutableStateOf(false)
// Strip location and sensitive metadata from files before upload
var stripMetadata by mutableStateOf(true)
open fun load(
account: Account,
uris: ImmutableList<SelectedMedia>,
@@ -73,6 +80,7 @@ open class NewMediaModel : ViewModel() {
this.account = account
this.multiOrchestrator = MultiOrchestrator(uris)
this.selectedServer = defaultServer()
this.stripMetadata = account.settings.stripLocationOnUpload
}
fun isImage(
@@ -115,6 +123,8 @@ open class NewMediaModel : ViewModel() {
myAccount,
context,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (results.allGood) {
@@ -89,6 +89,8 @@ fun NewMediaView(
postViewModel.load(account, uris)
}
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
Dialog(
onDismissRequest = { onClose() },
properties =
@@ -112,6 +114,7 @@ fun NewMediaView(
postViewModel.selectedServer?.let {
account.settings.changeDefaultFileServer(it)
}
account.settings.changeStripLocationOnUpload(postViewModel.stripMetadata)
},
)
},
@@ -269,4 +272,15 @@ fun ImageVideoPost(
onCheckedChange = { postViewModel.useH265Codec = it },
)
}
SettingSwitchItem(
title = R.string.strip_metadata_label,
description = R.string.strip_metadata_description,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp),
checked = postViewModel.stripMetadata,
onCheckedChange = { postViewModel.stripMetadata = it },
)
}
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
@@ -210,7 +211,28 @@ class NewUserMetadataViewModel : ViewModel() {
): String? {
isUploadingImageForPicture = true
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
val strippingResult =
if (account.settings.stripLocationOnUpload) {
MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
} else {
null
}
val sourceUri =
if (account.settings.stripLocationOnUpload &&
strippingResult != null &&
!strippingResult.stripped
) {
onError(
stringRes(context, R.string.metadata_strip_failed_title),
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
)
return null
} else {
strippingResult?.uri ?: galleryUri.uri
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
return try {
val result =
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.uploads.ConfirmationCallbacks
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun StrippingFailureDialog(confirmation: SuspendableConfirmation) {
val dialogState = confirmation.state ?: return
StrippingFailureDialog(dialogState)
}
@Composable
fun StrippingFailureDialog(dialogState: ConfirmationCallbacks) {
AlertDialog(
onDismissRequest = { dialogState.onCancel() },
title = { Text(stringRes(R.string.metadata_strip_failed_title)) },
text = { Text(stringRes(R.string.metadata_strip_failed_body)) },
confirmButton = {
Button(onClick = { dialogState.onConfirm() }) {
Text(stringRes(R.string.metadata_strip_failed_upload))
}
},
dismissButton = {
Button(onClick = { dialogState.onCancel() }) {
Text(stringRes(R.string.cancel))
}
},
)
}
@@ -53,7 +53,7 @@ class BlossomServersViewModel : ViewModel() {
fun refresh() {
isModified = false
_fileServers.update {
val obtainedFileServers = obtainFileServers() ?: emptyList()
val obtainedFileServers = obtainFileServers()
obtainedFileServers.mapNotNull { serverUrl ->
try {
ServerName(
@@ -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
fun SelectFromFiles(
isUploading: Boolean,
enabled: Boolean = true,
tint: Color,
modifier: Modifier,
onFilesChosen: (ImmutableList<SelectedMedia>) -> Unit,
@@ -64,19 +65,20 @@ fun SelectFromFiles(
)
}
FileSelectButton(isUploading, tint, modifier) { showFileSelect = true }
FileSelectButton(isUploading, enabled, tint, modifier) { showFileSelect = true }
}
@Composable
private fun FileSelectButton(
isUploading: Boolean,
enabled: Boolean,
tint: Color,
modifier: Modifier,
onClick: () -> Unit,
) {
IconButton(
modifier = modifier,
enabled = !isUploading,
enabled = enabled && !isUploading,
onClick = { onClick() },
) {
if (!isUploading) {
@@ -69,6 +69,7 @@ class SelectedMedia(
@Composable
fun SelectFromGallery(
isUploading: Boolean,
enabled: Boolean = true,
tint: Color,
modifier: Modifier,
onImageChosen: (ImmutableList<SelectedMedia>) -> Unit,
@@ -85,7 +86,7 @@ fun SelectFromGallery(
)
}
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true }
GallerySelectButton(isUploading, enabled, tint, modifier) { showGallerySelect = true }
}
@Composable
@@ -107,19 +108,20 @@ fun SelectSingleFromGallery(
)
}
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true }
GallerySelectButton(isUploading, true, tint, modifier) { showGallerySelect = true }
}
@Composable
private fun GallerySelectButton(
isUploading: Boolean,
enabled: Boolean,
tint: Color,
modifier: Modifier,
onClick: () -> Unit,
) {
IconButton(
modifier = modifier,
enabled = !isUploading,
enabled = enabled && !isUploading,
onClick = { onClick() },
) {
if (!isUploading) {
@@ -0,0 +1,140 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
@Composable
fun M3ActionDialog(
title: String,
onDismiss: () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(
modifier =
Modifier
.padding(vertical = 20.dp)
.verticalScroll(rememberScrollState()),
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp),
)
content()
}
}
}
}
@Composable
fun M3ActionSection(content: @Composable ColumnScope.() -> Unit) {
Surface(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
) {
Column {
content()
}
}
}
@Composable
fun M3ActionRow(
icon: ImageVector,
text: String,
isDestructive: Boolean = false,
enabled: Boolean = true,
onClick: () -> Unit,
) {
val tint =
if (isDestructive) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val textColor =
if (isDestructive) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
val alpha = if (enabled) 1f else 0.38f
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.alpha(alpha)
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Size20Modifier,
tint = tint,
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = text,
fontSize = Font14SP,
color = textColor,
)
}
}
@@ -119,11 +119,12 @@ object ShareHelper {
bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header)
// MP4/MOV alternative: moov, mdat, or free at offset 4
bytesRead >= 8 && (
matchesMagicNumbers(header, 4, MOV_MOOV) ||
matchesMagicNumbers(header, 4, MOV_MDAT) ||
matchesMagicNumbers(header, 4, MOV_FREE)
) -> "mp4"
bytesRead >= 8 &&
(
matchesMagicNumbers(header, 4, MOV_MOOV) ||
matchesMagicNumbers(header, 4, MOV_MDAT) ||
matchesMagicNumbers(header, 4, MOV_FREE)
) -> "mp4"
else -> defaultExtension
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -34,7 +33,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
@@ -61,7 +59,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import kotlinx.collections.immutable.ImmutableList
@@ -159,13 +156,11 @@ private fun BaseTextSpinner(
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it].title
optionsShowing = false
onSelect(it)
}
if (optionsShowing && options.isNotEmpty()) {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it].title
optionsShowing = false
onSelect(it)
}
}
}
@@ -211,14 +206,14 @@ fun <T> SpinnerSelectionDialog(
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
shape = RoundedCornerShape(5.dp),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
LazyColumn {
title?.let {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp, 16.dp),
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalArrangement = Arrangement.Center,
) {
Text(
@@ -227,7 +222,6 @@ fun <T> SpinnerSelectionDialog(
fontWeight = FontWeight.Bold,
)
}
HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
}
}
itemsIndexed(options) { index, item ->
@@ -237,7 +231,7 @@ fun <T> SpinnerSelectionDialog(
Modifier
.fillMaxWidth()
.clickable { onSelect(index) }
.padding(16.dp, 16.dp)
.padding(horizontal = 16.dp, vertical = 12.dp)
.semantics {
role = Role.Button
contentDescription = optionsOfLabel
@@ -245,9 +239,6 @@ fun <T> SpinnerSelectionDialog(
) {
Column { onRenderItem(item) }
}
if (index < options.lastIndex) {
HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
}
}
}
}
@@ -24,12 +24,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
@@ -47,27 +46,6 @@ import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier
@Composable
private fun CopyToClipboard(
popupExpanded: MutableState<Boolean>,
content: String,
onDismiss: () -> Unit,
) {
DropdownMenu(
expanded = popupExpanded.value,
onDismissRequest = onDismiss,
) {
val clipboardManager = LocalClipboardManager.current
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_url_to_clipboard)) },
onClick = {
clipboardManager.setText(AnnotatedString(content))
onDismiss()
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UrlPreviewCard(
@@ -81,11 +59,20 @@ fun UrlPreviewCard(
}
if (popupExpanded.value) {
CopyToClipboard(
popupExpanded = popupExpanded,
content = url,
val clipboardManager = LocalClipboardManager.current
M3ActionDialog(
title = stringRes(R.string.link_actions_dialog_title),
onDismiss = { popupExpanded.value = false },
) {
popupExpanded.value = false
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.ContentCopy,
text = stringRes(R.string.copy_url_to_clipboard),
) {
clipboardManager.setText(AnnotatedString(url))
popupExpanded.value = false
}
}
}
}
@@ -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)
}
@@ -32,15 +32,16 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Link
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -771,119 +772,101 @@ fun ShareMediaAction(
// Track if video is downloading - hoisted here to block menu dismiss during download
val isDownloadingVideo = remember { mutableStateOf(false) }
DropdownMenu(
expanded = popupExpanded.value,
onDismissRequest = { if (!isDownloadingVideo.value) onDismiss() },
) {
val clipboardManager = LocalClipboardManager.current
if (popupExpanded.value) {
M3ActionDialog(
title = stringRes(R.string.media_actions_dialog_title),
onDismiss = { if (!isDownloadingVideo.value) onDismiss() },
) {
val clipboardManager = LocalClipboardManager.current
if (videoUri != null && !videoUri.startsWith("file")) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_url_to_clipboard)) },
onClick = {
clipboardManager.setText(AnnotatedString(videoUri))
onDismiss()
},
)
}
postNostrUri?.let {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_the_note_id_to_the_clipboard)) },
onClick = {
clipboardManager.setText(AnnotatedString(it))
onDismiss()
},
)
}
postNostrUri?.let {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_media_to_gallery)) },
onClick = {
if (videoUri != null) {
val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent
if (n19 != null) {
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType) // TODO Whole list or first?
accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery)
// Copy & Gallery section
if ((videoUri != null && !videoUri.startsWith("file")) || postNostrUri != null) {
M3ActionSection {
if (videoUri != null && !videoUri.startsWith("file")) {
M3ActionRow(icon = Icons.Outlined.Link, text = stringRes(R.string.copy_url_to_clipboard)) {
clipboardManager.setText(AnnotatedString(videoUri))
onDismiss()
}
}
onDismiss()
},
)
}
content?.let {
val context = LocalContext.current
when (content) {
is MediaUrlImage -> {
videoUri?.let {
if (videoUri.isNotEmpty()) {
DropdownMenuItem(
text = { Text(stringRes(R.string.share_image)) },
onClick = {
scope.launch { shareImageFile(context, videoUri, mimeType) }
onDismiss()
},
)
postNostrUri?.let {
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_the_note_id_to_the_clipboard)) {
clipboardManager.setText(AnnotatedString(it))
onDismiss()
}
M3ActionRow(icon = Icons.Outlined.Collections, text = stringRes(R.string.add_media_to_gallery)) {
if (videoUri != null) {
val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent
if (n19 != null) {
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType)
accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery)
}
}
onDismiss()
}
}
}
}
is MediaUrlVideo -> {
videoUri?.let {
if (videoUri.isNotEmpty()) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringRes(R.string.share_video))
if (isDownloadingVideo.value) {
Spacer(modifier = Modifier.width(8.dp))
LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp)
// Share section
content?.let {
val context = LocalContext.current
M3ActionSection {
when (content) {
is MediaUrlImage -> {
videoUri?.let {
if (videoUri.isNotEmpty()) {
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_image)) {
scope.launch { shareImageFile(context, videoUri, mimeType) }
onDismiss()
}
}
}
}
is MediaUrlVideo -> {
videoUri?.let {
if (videoUri.isNotEmpty()) {
M3ActionRow(
icon = Icons.Outlined.Share,
text = stringRes(R.string.share_video),
enabled = !isDownloadingVideo.value,
) {
isDownloadingVideo.value = true
scope.launch {
shareVideoFile(
context = context,
videoUrl = videoUri,
mimeType = mimeType,
okHttpClient = { url ->
accountViewModel.httpClientBuilder.okHttpClientForVideo(url)
},
onComplete = {
isDownloadingVideo.value = false
onDismiss()
},
onError = {
isDownloadingVideo.value = false
},
)
}
}
},
enabled = !isDownloadingVideo.value,
onClick = {
isDownloadingVideo.value = true
scope.launch {
shareVideoFile(
context = context,
videoUrl = videoUri,
mimeType = mimeType,
okHttpClient = { url ->
accountViewModel.httpClientBuilder.okHttpClientForVideo(url)
},
onComplete = {
isDownloadingVideo.value = false
onDismiss()
},
onError = {
isDownloadingVideo.value = false
},
)
}
},
)
}
}
}
is MediaLocalVideo -> {
content.localFile?.let { localFile ->
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_video)) {
scope.launch { shareLocalVideoFile(context, localFile, mimeType) }
onDismiss()
}
}
}
else -> { /* No share option for other types */ }
}
}
is MediaLocalVideo -> {
content.localFile?.let { localFile ->
DropdownMenuItem(
text = { Text(stringRes(R.string.share_video)) },
onClick = {
scope.launch { shareLocalVideoFile(context, localFile, mimeType) }
onDismiss()
},
)
}
}
else -> { /* No share option for other types */ }
}
}
}
@@ -37,8 +37,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.core.util.Consumer
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
@@ -109,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
@@ -121,6 +124,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -139,7 +146,17 @@ fun AppNavigation(
) {
val nav = rememberNav()
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
val isTabPagerRoute =
navBackStackEntry?.destination?.let { dest ->
dest.hasRoute<Route.Home>() || dest.hasRoute<Route.Message>()
} ?: false
val drawerGesturesEnabled =
!isTabPagerRoute ||
nav.drawerState.isOpen ||
nav.drawerState.targetValue != nav.drawerState.currentValue
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav, drawerGesturesEnabled) {
NavHost(
navController = nav.controller,
startDestination = Route.Home,
@@ -153,6 +170,11 @@ fun AppNavigation(
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(accountViewModel, nav) }
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
@@ -197,6 +219,7 @@ fun AppNavigation(
composableFromEndArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
composableFromEnd<Route.EventSync> { EventSyncScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
@@ -415,7 +438,9 @@ private fun NavigateIfIntentRequested(
actionableNextPage?.let { nextRoute ->
val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
accountSessionManager.checkAndSwitchUserSync(npub, nextRoute)
accountSessionManager.checkAndSwitchUserSync(npub) { account ->
uriToRoute(intentNextPage, account)
}
} else {
val currentRoute = getRouteWithArguments(nextRoute::class, nav.controller)
if (!isSameRoute(currentRoute, nextRoute)) {
@@ -471,7 +496,9 @@ private fun NavigateIfIntentRequested(
scope.launch {
val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
accountSessionManager.checkAndSwitchUserSync(npub, newPage)
accountSessionManager.checkAndSwitchUserSync(npub) { newAccount ->
uriToRoute(uri, newAccount)
}
} else {
val currentRoute = getRouteWithArguments(newPage::class, nav.controller)
if (!isSameRoute(currentRoute, newPage)) {
@@ -46,10 +46,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -87,6 +89,7 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -464,12 +467,30 @@ fun ListContent(
)
NavigationRow(
title = R.string.route_chess,
icon = R.drawable.ic_chess,
iconReference = 1,
title = R.string.wallet,
icon = Icons.Outlined.AccountBalanceWallet,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
route = Route.Wallet,
)
if (isDebug) {
NavigationRow(
title = R.string.route_chess,
icon = R.drawable.ic_chess,
iconReference = 1,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
)
}
NavigationRow(
title = R.string.event_sync_title,
icon = Icons.Outlined.Sync,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.EventSync,
)
NavigationRow(
@@ -43,6 +43,14 @@ sealed class Route {
@Serializable object Chess : Route()
@Serializable object Wallet : Route()
@Serializable object WalletSend : Route()
@Serializable object WalletReceive : Route()
@Serializable object WalletTransactions : Route()
@Serializable object Search : Route()
@Serializable object SecurityFilters : Route()
@@ -126,6 +134,8 @@ sealed class Route {
@Serializable object EditRelays : Route()
@Serializable object EventSync : Route()
@Serializable object EditMediaServers : Route()
@Serializable object UpdateReactionType : Route()
@@ -21,20 +21,36 @@
package com.vitorpamplona.amethyst.ui.navigation.topbars
import android.Manifest
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ViewList
import androidx.compose.material.icons.automirrored.outlined.VolumeOff
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.SensorDoor
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -43,14 +59,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
@@ -62,7 +81,6 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.CommunityName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
@@ -74,6 +92,8 @@ import com.vitorpamplona.amethyst.ui.screen.RelayName
import com.vitorpamplona.amethyst.ui.screen.ResourceName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -120,6 +140,8 @@ fun FeedFilterSpinner(
stringRes(R.string.feed_filter_select_an_option, selectAnOption)
}
val openDropdownLabel = stringRes(R.string.open_dropdown_menu)
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
@@ -137,7 +159,7 @@ fun FeedFilterSpinner(
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
} else {
@@ -152,7 +174,7 @@ fun FeedFilterSpinner(
Row {
Text(
text = "(${myLocation.geoHash})",
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
Spacer(modifier = StdHorzSpacer)
@@ -162,7 +184,7 @@ fun FeedFilterSpinner(
) { cityName ->
Text(
text = "($cityName)",
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
}
@@ -171,7 +193,7 @@ fun FeedFilterSpinner(
LocationState.LocationResult.LackPermission -> {
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
}
@@ -179,7 +201,7 @@ fun FeedFilterSpinner(
LocationState.LocationResult.Loading -> {
Text(
text = stringRes(R.string.loading_location),
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
}
@@ -207,7 +229,7 @@ fun FeedFilterSpinner(
}.semantics {
role = Role.DropdownList
stateDescription = accessibilityDescription
onClick(label = "Open feed filter menu") {
onClick(label = openDropdownLabel) {
optionsShowing = true
return@onClick true
}
@@ -215,20 +237,18 @@ fun FeedFilterSpinner(
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(
title = explainer,
options = options,
onDismiss = { optionsShowing = false },
onSelect = {
selected = options[it]
optionsShowing = false
onSelect(it)
},
) {
RenderOption(it.name, accountViewModel)
}
if (optionsShowing && options.isNotEmpty()) {
GroupedFeedFilterDialog(
title = explainer,
options = options,
onDismiss = { optionsShowing = false },
onSelect = {
selected = options[it]
optionsShowing = false
onSelect(it)
},
) {
RenderOption(it.name, accountViewModel)
}
}
}
@@ -241,84 +261,258 @@ fun RenderOption(
when (option) {
is GeoHashName -> {
LoadCityName(option.geoHashTag) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
}
Text(text = "/g/$it", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
}
is HashtagName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface)
}
Text(text = option.name(), fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
is ResourceName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringRes(id = option.resourceId),
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringRes(id = option.resourceId),
fontSize = Font14SP,
color = MaterialTheme.colorScheme.onSurface,
)
}
is PeopleListName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
val noteState by observeNote(option.note, accountViewModel)
val noteState by observeNote(option.note, accountViewModel)
val noteEvent = noteState.note.event
val name =
when (noteEvent) {
is PeopleListEvent -> {
noteEvent.titleOrName() ?: option.note.dTag()
}
is FollowListEvent -> {
noteEvent.title() ?: option.note.dTag()
}
else -> {
option.note.dTag()
}
val noteEvent = noteState.note.event
val name =
when (noteEvent) {
is PeopleListEvent -> {
noteEvent.titleOrName() ?: option.note.dTag()
}
Text(text = name, color = MaterialTheme.colorScheme.onSurface)
}
is FollowListEvent -> {
noteEvent.title() ?: option.note.dTag()
}
else -> {
option.note.dTag()
}
}
Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
is CommunityName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
val it by observeNote(option.note, accountViewModel)
val it by observeNote(option.note, accountViewModel)
Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", color = MaterialTheme.colorScheme.onSurface)
}
Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
is RelayName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
Text(
text = option.name(),
fontSize = Font14SP,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Immutable
private data class IndexedFeedDefinition(
val originalIndex: Int,
val item: FeedDefinition,
)
private enum class FeedGroup(
@param:androidx.annotation.StringRes val labelRes: Int,
) {
FEEDS(R.string.feed_group_feeds),
HASHTAGS(R.string.feed_group_hashtags),
COMMUNITIES(R.string.feed_group_communities),
LISTS(R.string.feed_group_lists),
}
private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<FeedGroup, List<IndexedFeedDefinition>> {
val indexed = options.mapIndexed { index, item -> IndexedFeedDefinition(index, item) }
return indexed.groupBy { entry ->
when (entry.item.name) {
is HashtagName -> FeedGroup.HASHTAGS
is CommunityName -> FeedGroup.COMMUNITIES
is PeopleListName -> FeedGroup.LISTS
else -> FeedGroup.FEEDS
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GroupedFeedFilterDialog(
title: String,
options: ImmutableList<FeedDefinition>,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
onRenderItem: @Composable (FeedDefinition) -> Unit,
) {
val grouped = remember(options) { groupFeedDefinitions(options) }
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
LazyColumn(
modifier = Modifier.padding(vertical = 20.dp),
) {
Text(
text = option.name(),
color = MaterialTheme.colorScheme.onSurface,
)
item {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp),
)
}
FeedGroup.entries.forEach { group ->
val items = grouped[group]
if (!items.isNullOrEmpty()) {
item {
GroupSection(
label = stringRes(group.labelRes),
items = items,
isChipLayout = group == FeedGroup.HASHTAGS,
onSelect = onSelect,
onRenderItem = onRenderItem,
)
}
}
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GroupSection(
label: String,
items: List<IndexedFeedDefinition>,
isChipLayout: Boolean,
onSelect: (Int) -> Unit,
onRenderItem: @Composable (FeedDefinition) -> Unit,
) {
Surface(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
) {
Column {
Text(
text = label.uppercase(),
fontSize = Font12SP,
letterSpacing = 0.8.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 6.dp),
)
if (isChipLayout) {
FlowRow(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items.forEach { entry ->
Surface(
modifier = Modifier.clickable { onSelect(entry.originalIndex) },
shape = RoundedCornerShape(18.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
color = Color.Transparent,
) {
Text(
text = entry.item.name.name(),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
} else {
items.forEach { entry ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelect(entry.originalIndex) }
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
FeedIcon(
item = entry.item,
modifier = Size20Modifier,
)
Spacer(modifier = Modifier.padding(start = 12.dp))
Column(modifier = Modifier.weight(1f)) { onRenderItem(entry.item) }
}
}
}
}
}
}
@Composable
private fun FeedIcon(
item: FeedDefinition,
modifier: Modifier = Modifier,
) {
val icon =
when (item.code) {
is TopFilter.Global -> {
Icons.Outlined.Public
}
is TopFilter.AroundMe -> {
Icons.Outlined.LocationOn
}
is TopFilter.AllFollows -> {
Icons.Outlined.Groups
}
is TopFilter.AllUserFollows -> {
Icons.Outlined.Person
}
is TopFilter.DefaultFollows -> {
Icons.Outlined.Groups
}
is TopFilter.MuteList -> {
Icons.AutoMirrored.Outlined.VolumeOff
}
is TopFilter.Chess -> {
Icons.Outlined.Groups
}
is TopFilter.PeopleList -> {
Icons.AutoMirrored.Outlined.ViewList
}
else -> {
when (item.name) {
is GeoHashName -> Icons.Outlined.LocationOn
is RelayName -> Icons.Outlined.SensorDoor
is CommunityName -> Icons.Outlined.Groups
is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList
else -> Icons.Outlined.Person
}
}
}
Icon(
imageVector = icon,
contentDescription = null,
modifier = modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -62,7 +62,7 @@ fun BadgeCompose(
nav: INav,
) {
val noteState by observeNote(likeSetCard.note, accountViewModel)
val note = noteState?.note
val note = noteState.note
val context = LocalContext.current.applicationContext
@@ -102,6 +102,10 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
@@ -174,6 +178,10 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
@@ -184,7 +192,7 @@ import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -767,6 +775,22 @@ private fun RenderNoteRow(
RenderAppDefinition(baseNote, accountViewModel, nav)
}
is AttestationEvent -> {
RenderAttestation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AttestationRequestEvent -> {
RenderAttestationRequest(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AttestorRecommendationEvent -> {
RenderAttestorRecommendation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AttestorProficiencyEvent -> {
RenderAttestorProficiency(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AudioTrackEvent -> {
RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav)
}
@@ -1039,7 +1063,7 @@ private fun RenderNoteRow(
)
}
is PollNoteEvent -> {
is ZapPollEvent -> {
RenderZapPoll(
baseNote,
makeItShort,
@@ -32,10 +32,16 @@ import kotlin.math.round
private const val YEAR_DATE_FORMAT = "MMM dd, yyyy"
private const val MONTH_DATE_FORMAT = "MMM dd"
private const val YEAR_NO_DAY_DATE_FORMAT = "MMM yyyy"
private const val MONTH_NO_DAY_DATE_FORMAT = "MMM dd"
var locale: Locale = Locale.getDefault()
var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
var yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
fun timeAgo(
time: Long?,
context: Context,
@@ -116,6 +122,46 @@ fun timeAgoNoDot(
}
}
fun timeAgoNoDotNoDay(
time: Long?,
context: Context,
): String {
if (time == null) return " "
if (time == 0L) return " ${stringRes(context, R.string.never)}"
val timeDifference = TimeUtils.now() - time
return if (timeDifference > TimeUtils.ONE_YEAR) {
// Dec 12, 2022
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
}
yearNoDayFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_MONTH) {
// Dec 12
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
}
monthNoDayFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_DAY) {
// 2 days
(timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, R.string.d)
} else if (timeDifference > TimeUtils.ONE_HOUR) {
(timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, R.string.h)
} else if (timeDifference > TimeUtils.ONE_MINUTE) {
(timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, R.string.m)
} else {
stringRes(context, R.string.now)
}
}
fun timeAheadNoDot(
time: Long?,
context: Context,
@@ -30,10 +30,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -49,10 +49,10 @@ fun showAmountInteger(amount: BigDecimal?): String {
if (amount.abs() < BigDecimal(0.01)) return ""
return when {
amount >= OneGiga -> dfG.get().format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP))
amount >= OneMega -> dfM.get().format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP))
amount >= TenKilo -> dfK.get().format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP))
else -> dfN.get().format(amount)
amount >= OneGiga -> dfG.get()?.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) ?: ""
amount >= OneMega -> dfM.get()?.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) ?: ""
amount >= TenKilo -> dfK.get()?.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) ?: ""
else -> dfN.get()?.format(amount) ?: ""
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -32,7 +34,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -58,8 +59,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
@@ -93,13 +97,15 @@ import com.vitorpamplona.amethyst.ui.theme.BigPadding
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
@@ -115,7 +121,7 @@ import kotlin.uuid.Uuid
@Composable
fun ZapZapPollNotePreview() {
val event =
PollNoteEvent(
ZapPollEvent(
id = "6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72",
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
createdAt = 1729186078,
@@ -188,7 +194,7 @@ fun ZapZapPollNotePreview() {
@Composable
fun ZapZapPollNotePreview2() {
val event =
PollNoteEvent(
ZapPollEvent(
id = "3064bf97800a4b04b612fc0fd498936eae75fffbdca5bbd09d19a6dc598530ab",
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
createdAt = 1729191389,
@@ -311,13 +317,6 @@ private fun OptionNote(
modifier = Modifier.padding(vertical = 3.dp),
) {
if (!pollViewModel.canZap.value) {
val color =
if (poolOption.consensusThreadhold.value) {
Color.Green.copy(alpha = 0.32f)
} else {
MaterialTheme.colorScheme.mediumImportanceLink
}
ZapVote(
baseNote,
poolOption,
@@ -326,7 +325,7 @@ private fun OptionNote(
RenderOptionAfterVote(
baseNote,
poolOption,
color,
poolOption.consensusThreadhold.value,
canPreview,
tags,
backgroundColor,
@@ -366,7 +365,7 @@ private fun OptionNote(
private fun RenderOptionAfterVote(
baseNote: Note,
poolOption: PollOption,
color: Color,
isWinning: Boolean,
canPreview: Boolean,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
@@ -376,14 +375,25 @@ private fun RenderOptionAfterVote(
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = QuoteBorder)
.clip(SmallishBorder)
.border(
2.dp,
color,
QuoteBorder,
width = 1.dp,
color =
if (isWinning) {
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colorScheme.grayText
},
shape = SmallishBorder,
).background(
if (isWinning) {
MaterialTheme.colorScheme.allGoodColor.copy(0.2f)
} else {
MaterialTheme.colorScheme.subtleBorder
},
),
) {
DisplayProgress(poolOption, color, modifier = Modifier.matchParentSize())
DisplayProgress(poolOption, isWinning, modifier = Modifier.matchParentSize())
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -428,22 +438,29 @@ private fun RenderOptionAfterVote(
@Composable
private fun DisplayProgress(
poolOption: PollOption,
color: Color,
isWinning: Boolean,
modifier: Modifier,
) {
val progress by poolOption.tally
// Animate the progress bar when a vote is cast
val animatedProgress by animateFloatAsState(
targetValue = poolOption.tally.value,
animationSpec = tween(durationMillis = 800),
)
// The LinearProgressIndicator has some weird update issues and renders inaccurate percentages.
Box(modifier = modifier) {
Box(
modifier =
Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(color = color),
) {
}
}
val progressBarColor = if (isWinning) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.primary
Box(
modifier =
modifier
.alpha(0.32f)
.drawWithContent {
// Clip the drawing area to show only the progress amount
clipRect(right = size.width * animatedProgress) {
drawRect(progressBarColor)
}
drawContent()
},
)
}
@Composable
@@ -475,11 +492,11 @@ private fun RenderOptionBeforeVote(
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = QuoteBorder)
.clip(SmallishBorder)
.border(
2.dp,
MaterialTheme.colorScheme.primary,
QuoteBorder,
width = 1.dp,
color = MaterialTheme.colorScheme.placeholderText,
shape = SmallishBorder,
),
) {
Column(BigPadding) {
@@ -568,7 +585,19 @@ fun ZapVote(
showErrorMessageDialog = StringToastMsg(title, message)
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
onPayViaIntent = {},
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
showErrorMessageDialog = StringToastMsg(stringRes(context, R.string.error_dialog_zap_error), error)
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
)
} else {
wantsToZap = true
@@ -29,7 +29,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -53,7 +53,7 @@ class PollNoteViewModel : ViewModel() {
private lateinit var account: Account
private var pollNote: Note? = null
private var pollEvent: PollNoteEvent? = null
private var pollEvent: ZapPollEvent? = null
private var pollOptions: Map<Int, String>? = null
private var valueMaximum: Long? = null
private var valueMinimum: Long? = null
@@ -76,7 +76,7 @@ class PollNoteViewModel : ViewModel() {
fun load(note: Note?) {
if (pollNote != note) {
pollNote = note
pollEvent = pollNote?.event as PollNoteEvent
pollEvent = pollNote?.event as ZapPollEvent
pollOptions = pollEvent?.pollOptions()
valueMaximum = pollEvent?.maxAmount()
valueMinimum = pollEvent?.minAmount()
@@ -118,13 +118,13 @@ class PollNoteViewModel : ViewModel() {
it.zappedValue.value = zappedValue
it.tally.value = tallyValue.toFloat()
it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!!
it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false
it.zappedByLoggedIn.value = account.userProfile().let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) }
}
}
}
fun checkIfCanZap(): Boolean {
val account = account ?: return false
val account = account
val note = pollNote ?: return false
return account.userProfile() != note.author && !wasZappedByLoggedInAccount
}
@@ -78,7 +78,8 @@ import kotlinx.collections.immutable.toImmutableList
fun ImageVideoDescription(
uris: MultiOrchestrator,
defaultServer: ServerName,
onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit,
isUploading: Boolean,
onAdd: (String, ServerName, Boolean, Int, Boolean, Boolean) -> Unit,
onDelete: (SelectedMediaProcessing) -> Unit,
onCancel: () -> Unit,
accountViewModel: AccountViewModel,
@@ -107,6 +108,8 @@ fun ImageVideoDescription(
// Codec selection: false = H264, true = H265
var useH265Codec by remember { mutableStateOf(false) }
var stripMetadata by remember { mutableStateOf(accountViewModel.account.settings.stripLocationOnUpload) }
Column(
modifier =
Modifier
@@ -247,36 +250,52 @@ fun ImageVideoDescription(
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.padding(vertical = 8.dp),
) {
Column(
modifier = Modifier.weight(1.0f),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.media_compression_quality_label),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.media_compression_quality_explainer),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
// Hide privacy toggle when video compression is selected (compression already strips metadata)
val isVideoWithCompression =
uris.first().media.isVideo() == true && mediaQualitySlider != 3
if (!isVideoWithCompression) {
SettingSwitchItem(
title = R.string.strip_metadata_label,
description = R.string.strip_metadata_description,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp),
checked = stripMetadata,
onCheckedChange = { stripMetadata = it },
)
}
val firstMedia = uris.first().media
if (firstMedia.isVideo() == true || firstMedia.isImage() == true || firstMedia.isAudio() == true) {
if (firstMedia.isVideo() == true || firstMedia.isImage() == true) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.padding(vertical = 8.dp),
) {
Column(
modifier = Modifier.weight(1.0f),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.media_compression_quality_label),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.media_compression_quality_explainer),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
@@ -301,7 +320,7 @@ fun ImageVideoDescription(
}
}
if (uris.first().media.isVideo() == true) {
if (uris.first().media.isVideo() == true && mediaQualitySlider != 3) {
SettingSwitchItem(
title = R.string.video_codec_h265_label,
description = R.string.video_codec_h265_description,
@@ -319,7 +338,11 @@ fun ImageVideoDescription(
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) },
enabled = !isUploading,
onClick = {
val effectiveStripMetadata = if (isVideoWithCompression) false else stripMetadata
onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec, effectiveStripMetadata)
},
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CancellationException
@Composable
fun ZapPollConsensusThreshold(pollViewModel: ShortNotePostViewModel) {
var text by rememberSaveable { mutableStateOf("") }
pollViewModel.isValidConsensusThreshold.value = true
if (text.isNotEmpty()) {
try {
val int = text.toInt()
if (int !in 0..100) {
pollViewModel.isValidConsensusThreshold.value = false
} else {
pollViewModel.zapPollConsensusThreshold = int
}
} catch (e: Exception) {
if (e is CancellationException) throw e
pollViewModel.isValidConsensusThreshold.value = false
}
}
val colorInValid =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.error,
unfocusedBorderColor = Color.Red,
)
val colorValid =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid,
label = {
Text(
text = stringRes(R.string.poll_consensus_threshold),
color = MaterialTheme.colorScheme.placeholderText,
)
},
placeholder = {
Text(
text = stringRes(R.string.poll_consensus_threshold_percent),
color = MaterialTheme.colorScheme.placeholderText,
)
},
)
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollConsensusThresholdPreview() {
ZapPollConsensusThreshold(ShortNotePostViewModel())
}
@@ -0,0 +1,170 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TimePickerDialog
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
// Get current time details
val currentTime = Instant.ofEpochMilli(model.zapPollClosedAt * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime()
val datePickerState =
rememberDatePickerState(
initialSelectedDateMillis = model.zapPollClosedAt * 1000,
yearRange = currentTime.year..2050,
selectableDates =
object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
// Only allow today and future dates
return utcTimeMillis >= System.currentTimeMillis() - 86400000 // minus 24h buffer
}
},
)
val timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = false, // Set to true if you prefer military time
)
val context = LocalContext.current
OutlinedCard(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.DateRange, contentDescription = stringResource(R.string.accessibility_select_date))
Spacer(Modifier.width(12.dp))
if (model.zapPollClosedAt < TimeUtils.oneMinuteFromNow()) {
Text(stringRes(R.string.poll_closing_date_time) + " " + model.zapPollClosedAt, style = MaterialTheme.typography.bodyLarge)
} else {
Text(
text = stringRes(R.string.poll_closing_in, timeAheadNoDot(model.zapPollClosedAt, context)),
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
// --- Date Picker Dialog ---
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
showDatePicker = false
showTimePicker = true
}) { Text(stringResource(R.string.next)) }
},
) {
DatePicker(state = datePickerState)
}
}
// --- Time Picker Dialog ---
if (showTimePicker) {
TimePickerDialog(
title = {
Text(stringResource(R.string.closing_time))
},
onDismissRequest = { showTimePicker = false },
confirmButton = {
TextButton(
onClick = {
val datetimeLocalTimeZone =
datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis ->
(localDayAtZeroHourMillis / 1000) +
(timePickerState.hour * TimeUtils.ONE_HOUR) +
(timePickerState.minute * TimeUtils.ONE_MINUTE)
} ?: TimeUtils.oneDayAhead()
// Get the offset from UTC for the current instant in the local time zone
val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now())
model.zapPollClosedAt = datetimeLocalTimeZone - offset.totalSeconds
showTimePicker = false
},
) { Text(stringResource(R.string.confirm)) }
},
) {
TimePicker(state = timePickerState)
}
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollDeadlinePickerPreview() {
ZapPollDeadlinePicker(
ShortNotePostViewModel(),
)
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun ZapPollField(postViewModel: ShortNotePostViewModel) {
val optionsList = postViewModel.zapPollOptions
Column(
modifier = Modifier.fillMaxWidth(),
) {
optionsList.forEach { value ->
ZapPollOption(postViewModel, value.key)
}
Spacer(Modifier.height(8.dp))
ZapPollDeadlinePicker(postViewModel)
ZapPollVoteValueRange(postViewModel)
Button(
onClick = {
// postViewModel.pollOptions[postViewModel.pollOptions.size] = ""
optionsList[optionsList.size] = ""
},
border =
BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
),
colors =
ButtonDefaults.outlinedButtonColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Icon(Icons.Default.Add, contentDescription = stringRes(R.string.add_poll_option_button))
}
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun ZapPollOption(
pollViewModel: ShortNotePostViewModel,
optionIndex: Int,
) {
Row {
val deleteIcon: @Composable (() -> Unit) = {
IconButton(
onClick = {
pollViewModel.removeZapPollOption(optionIndex)
},
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringRes(R.string.clear),
)
}
}
OutlinedTextField(
modifier = Modifier.weight(1F),
value = pollViewModel.zapPollOptions[optionIndex] ?: "",
onValueChange = {
pollViewModel.updateZapPollOption(optionIndex, it)
},
label = {
Text(
text = stringRes(R.string.poll_option_index).format(optionIndex + 1),
color = MaterialTheme.colorScheme.placeholderText,
)
},
placeholder = {
Text(
text = stringRes(R.string.poll_option_description),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
// colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else
// colorInValid,
trailingIcon = if (optionIndex > 1) deleteIcon else null,
)
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollOptionPreview() {
ZapPollOption(ShortNotePostViewModel(), 0)
}
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun ZapPollVoteValueRange(pollViewModel: ShortNotePostViewModel) {
val colorInValid =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.error,
unfocusedBorderColor = Color.Red,
)
val colorValid =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
OutlinedTextField(
value = pollViewModel.zapPollValueMinimum?.toString() ?: "",
onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
colors = if (pollViewModel.isValidValueMinimum.value) colorValid else colorInValid,
label = {
Text(
text = stringRes(R.string.poll_zap_value_min),
color = MaterialTheme.colorScheme.placeholderText,
)
},
placeholder = {
Text(
text = stringRes(R.string.sats),
color = MaterialTheme.colorScheme.placeholderText,
)
},
)
Spacer(modifier = DoubleHorzSpacer)
OutlinedTextField(
value = pollViewModel.zapPollValueMaximum?.toString() ?: "",
onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
colors = if (pollViewModel.isValidValueMaximum.value) colorValid else colorInValid,
label = {
Text(
text = stringRes(R.string.poll_zap_value_max),
color = MaterialTheme.colorScheme.placeholderText,
)
},
placeholder = {
Text(
text = stringRes(R.string.sats),
color = MaterialTheme.colorScheme.placeholderText,
)
},
)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = stringRes(R.string.poll_zap_value_min_max_explainer),
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(vertical = 10.dp),
)
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollVoteValueRangePreview() {
Column(
modifier = Modifier.fillMaxWidth(),
) {
ZapPollVoteValueRange(ShortNotePostViewModel())
}
}
@@ -21,10 +21,22 @@
package com.vitorpamplona.amethyst.ui.note.elements
import android.content.Intent
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bookmark
import androidx.compose.material.icons.outlined.BookmarkAdd
import androidx.compose.material.icons.outlined.BookmarkRemove
import androidx.compose.material.icons.outlined.CellTower
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.LockOpen
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.PersonRemove
import androidx.compose.material.icons.outlined.PlaylistAdd
import androidx.compose.material.icons.outlined.Report
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
@@ -44,6 +56,9 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
@@ -53,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -78,16 +92,16 @@ fun MoreOptionsButton(
onClick = { popupExpanded.value = true },
) {
VerticalDotsIcon()
}
if (popupExpanded.value) {
NoteDropDownMenu(
note = baseNote,
onDismiss = { popupExpanded.value = false },
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (popupExpanded.value) {
NoteDropDownMenu(
note = baseNote,
onDismiss = { popupExpanded.value = false },
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -146,213 +160,150 @@ fun NoteDropDownMenu(
)
}
DropdownMenu(
expanded = true,
onDismissRequest = onDismiss,
M3ActionDialog(
title = stringRes(R.string.note_actions_dialog_title),
onDismiss = onDismiss,
) {
val clipboardManager = LocalClipboardManager.current
val actContext = LocalContext.current
val scope = rememberCoroutineScope()
if (!state.isFollowingAuthor) {
DropdownMenuItem(
text = { Text(stringRes(R.string.follow)) },
onClick = {
val author = note.author ?: return@DropdownMenuItem
// Follow section
M3ActionSection {
if (!state.isFollowingAuthor) {
M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.follow(author)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.unfollow)) },
onClick = {
val author = note.author ?: return@DropdownMenuItem
}
} else {
M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.unfollow(author)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
}
DropdownMenuItem(
text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) },
onClick = {
val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem
}
}
M3ActionRow(icon = Icons.Outlined.PlaylistAdd, text = stringRes(R.string.follow_set_add_author_from_note_action)) {
val authorHexKey = note.author?.pubkeyHex ?: return@M3ActionRow
nav.nav(Route.PeopleListManagement(authorHexKey))
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_text)) },
onClick = {
}
}
// Copy & Share section
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) {
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
accountViewModel.decrypt(lastNoteVersion) {
clipboardManager.setText(AnnotatedString(it))
}
accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) }
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_user_pubkey)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
note.author?.let {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}"))
onDismiss()
}
}
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_note_id)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString(note.toNostrUri()))
onDismiss()
}
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note),
)
putExtra(
Intent.EXTRA_TITLE,
stringRes(actContext, R.string.quick_action_share_browser_link),
)
putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note))
putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link))
}
val shareIntent =
Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
ContextCompat.startActivity(actContext, shareIntent, null)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
if (state.isLoggedUser && note.isDraft()) {
DropdownMenuItem(
text = { Text(stringRes(R.string.edit_draft)) },
onClick = {
nav.nav {
routeEditDraftTo(note, accountViewModel.account)
}
},
)
}
if (note.event is TextNoteEvent && !note.isDraft()) {
if (state.isLoggedUser) {
DropdownMenuItem(
text = { Text(stringRes(R.string.edit_post)) },
onClick = {
wantsToEditPost.value = true
},
)
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.propose_an_edit)) },
onClick = {
wantsToEditPost.value = true
},
)
}
}
DropdownMenuItem(
text = { Text(stringRes(R.string.broadcast)) },
onClick = {
// Edit & Broadcast section
M3ActionSection {
if (state.isLoggedUser && note.isDraft()) {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_draft)) {
nav.nav { routeEditDraftTo(note, accountViewModel.account) }
}
}
if (note.event is TextNoteEvent && !note.isDraft()) {
if (state.isLoggedUser) {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_post)) {
wantsToEditPost.value = true
}
} else {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.propose_an_edit)) {
wantsToEditPost.value = true
}
}
}
M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.broadcast)) {
accountViewModel.broadcast(note)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.timestamp_pending)) },
onClick = {
onDismiss()
},
)
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.timestamp_it)) },
onClick = {
}
}
// Timestamp & Bookmarks section
M3ActionSection {
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() }
} else {
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) {
accountViewModel.timestamp(note)
onDismiss()
},
)
}
HorizontalDivider(thickness = DividerThickness)
note.let {
}
}
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
DropdownMenuItem(
text = { Text(stringRes(R.string.manage_bookmark_label, noteBookmarkType)) },
onClick = {
if (note.event is LongTextNoteEvent) {
val noteAddress = (note as AddressableNote).address
nav.nav(Route.ArticleBookmarkManagement(noteAddress))
} else {
nav.nav(Route.PostBookmarkManagement(note.idHex))
}
onDismiss()
},
)
}
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(
text = { Text(stringRes(R.string.remove_from_private_bookmarks)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
if (note.event is LongTextNoteEvent) {
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
} else {
nav.nav(Route.PostBookmarkManagement(note.idHex))
}
onDismiss()
}
if (state.isPrivateBookmarkNote) {
M3ActionRow(icon = Icons.Outlined.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) {
accountViewModel.removePrivateBookmark(note)
onDismiss()
},
)
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_to_private_bookmarks)) },
onClick = {
}
} else {
M3ActionRow(icon = Icons.Outlined.Lock, text = stringRes(R.string.add_to_private_bookmarks)) {
accountViewModel.addPrivateBookmark(note)
onDismiss()
},
)
}
if (state.isPublicBookmarkNote) {
DropdownMenuItem(
text = { Text(stringRes(R.string.remove_from_public_bookmarks)) },
onClick = {
}
}
if (state.isPublicBookmarkNote) {
M3ActionRow(icon = Icons.Outlined.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) {
accountViewModel.removePublicBookmark(note)
onDismiss()
},
)
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_to_public_bookmarks)) },
onClick = {
}
} else {
M3ActionRow(icon = Icons.Outlined.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) {
accountViewModel.addPublicBookmark(note)
onDismiss()
},
)
}
}
}
HorizontalDivider(thickness = DividerThickness)
if (state.isLoggedUser) {
DropdownMenuItem(
text = { Text(stringRes(R.string.request_deletion)) },
onClick = {
// Moderation section
M3ActionSection {
if (state.isLoggedUser) {
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) {
accountViewModel.delete(note)
onDismiss()
},
)
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.block_report)) },
onClick = { reportDialogShowing = true },
)
}
} else {
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) {
reportDialogShowing = true
}
}
}
}
@@ -42,9 +42,11 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
@@ -149,7 +151,9 @@ open class CommentPostViewModel :
val urlPreviews = PreviewState()
var isUploadingImage by mutableStateOf(false)
val mediaUploadTracker = MediaUploadTracker()
val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage
val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile
var userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
@@ -159,6 +163,9 @@ open class CommentPostViewModel :
// Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
// Stripping failure dialog
val strippingFailureConfirmation = SuspendableConfirmation()
// Invoices
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
@@ -467,8 +474,9 @@ open class CommentPostViewModel :
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
stripMetadata: Boolean = true,
) = try {
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context)
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, stripMetadata)
} catch (_: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
@@ -483,11 +491,12 @@ open class CommentPostViewModel :
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
stripMetadata: Boolean = true,
) {
viewModelScope.launch(Dispatchers.IO) {
val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true
mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
val results =
myMultiOrchestrator.upload(
@@ -497,6 +506,8 @@ open class CommentPostViewModel :
server,
account,
context,
stripMetadata = stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (results.allGood) {
@@ -551,7 +562,7 @@ open class CommentPostViewModel :
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
isUploadingImage = false
mediaUploadTracker.finishUpload()
}
}
@@ -564,7 +575,7 @@ open class CommentPostViewModel :
externalIdentity = null
multiOrchestrator = null
isUploadingImage = false
mediaUploadTracker.finishUpload()
notifying = null
@@ -676,7 +687,7 @@ open class CommentPostViewModel :
fun canPost(): Boolean =
message.text.isNotBlank() &&
!isUploadingImage &&
!mediaUploadTracker.isUploading &&
!wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null
@@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -139,6 +140,8 @@ fun GenericCommentPostScreen(
) {
WatchAndLoadMyEmojiList(accountViewModel)
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
@@ -299,8 +302,9 @@ private fun GenericCommentPostBody(
ImageVideoDescription(
it,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -395,6 +399,7 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) {
) {
SelectFromGallery(
isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
@@ -402,7 +407,8 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) {
}
SelectFromFiles(
isUploading = postViewModel.isUploadingImage,
isUploading = postViewModel.isUploadingFile,
enabled = !postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
@@ -0,0 +1,638 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.KindChip
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Preview
@Composable
fun RenderAttestationPreview() {
val event =
AttestationEvent(
id = "f8e05e4fa964d9dfbeb339ae0450d0b3424aaf8f6083d95f38c780335c3dbd56",
pubKey = "c4f5e7a75a8ce3683d529cff06368439c529e5243c6b125ba68789198856cac7",
createdAt = 1773941524,
content = "This is Frank's new npub.",
sig = "61dde5dc5738bfa637aa04451eec63d45f649902a0772abbaf55711aad5b7ce376bc7712c937f9ffc504655c4fd7e39f78139dc3f1a90953d150b43f3e2428fb",
tags =
arrayOf(
arrayOf("d", "af5aa898:fe108febb997:1773941524"),
arrayOf("e", "fe108febb99796c4091775e00aa1fc3ffc489ad22fdf1f8c559b2472815c09c7"),
arrayOf("s", "verified"),
arrayOf("v", "valid"),
arrayOf("client", "attestr.xyz"),
),
)
LocalCache.justConsume(event, null, true)
val note = LocalCache.getOrCreateNote(event.id)
ThemeComparisonColumn(
toPreview = {
RenderAttestation(
baseNote = note,
quotesLeft = 3,
backgroundColor = remember { mutableStateOf(Color.Transparent) },
accountViewModel = mockAccountViewModel(),
nav = EmptyNav(),
)
},
)
}
@Composable
fun RenderAttestation(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by baseNote
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val noteEvent = noteState.note.event as? AttestationEvent ?: return
RenderAttestation(
baseNote,
noteEvent,
quotesLeft,
backgroundColor,
accountViewModel,
nav,
)
}
@Composable
fun RenderAttestation(
note: Note,
noteEvent: AttestationEvent,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val validity = remember(noteEvent) { noteEvent.validity() }
val status = remember(noteEvent) { noteEvent.status() }
val validFrom = remember(noteEvent) { noteEvent.validFrom() }
val validTo = remember(noteEvent) { noteEvent.validTo() }
val content = remember(noteEvent) { noteEvent.content.ifBlank { null } }
val statusColor = remember(status, validity) { attestationColor(status, validity) }
val statusIcon = remember(status, validity) { attestationIcon(status, validity) }
val statusLabel = attestationStatusLabel(status, validity)
val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() }
val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() }
val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() }
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.border(1.dp, statusColor.copy(alpha = 0.3f), RoundedCornerShape(12.dp))
.background(statusColor.copy(alpha = 0.06f))
.padding(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = statusIcon,
contentDescription = stringRes(R.string.attestation),
tint = statusColor,
modifier = Modifier.size(24.dp),
)
Text(
text = statusLabel,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = statusColor,
)
}
if (validFrom != null || validTo != null) {
Spacer(modifier = DoubleVertSpacer)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
validFrom?.let {
Text(
text = stringRes(R.string.attestation_valid_from, formatTimestamp(it)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
validTo?.let {
Text(
text = stringRes(R.string.attestation_valid_to, formatTimestamp(it)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (quotesLeft > 0) {
if (aboutAddress != null) {
LoadAddressableNote(aboutAddress, accountViewModel) {
if (it != null) {
Spacer(modifier = DoubleVertSpacer)
NoteCompose(
baseNote = it,
modifier = MaterialTheme.colorScheme.replyModifier,
isQuotedNote = true,
unPackReply = ReplyRenderType.NONE,
makeItShort = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
} else if (aboutEvent != null) {
LoadNote(aboutEvent, accountViewModel) {
if (it != null) {
Spacer(modifier = DoubleVertSpacer)
NoteCompose(
baseNote = it,
modifier = MaterialTheme.colorScheme.replyModifier,
isQuotedNote = true,
unPackReply = ReplyRenderType.NONE,
makeItShort = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
} else if (aboutPubkey != null) {
LoadUser(aboutPubkey, accountViewModel) {
if (it != null) {
Spacer(modifier = DoubleVertSpacer)
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
}
}
}
}
content?.let {
Spacer(modifier = DoubleVertSpacer)
TranslatableRichTextViewer(
content = it,
canPreview = true,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth(),
tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList },
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
fun RenderAttestationRequest(
note: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by note
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val noteEvent = noteState.note.event as? AttestationRequestEvent ?: return
val content = remember(noteEvent) { noteEvent.content.ifBlank { null } }
val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() }
val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() }
val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() }
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.border(
1.dp,
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
RoundedCornerShape(12.dp),
).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.06f))
.padding(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = stringRes(R.string.attestation_request),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
Text(
text = stringRes(R.string.attestation_request),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
content?.let {
Spacer(modifier = DoubleVertSpacer)
TranslatableRichTextViewer(
content = it,
canPreview = true,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth(),
tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList },
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
if (quotesLeft > 0) {
if (aboutAddress != null) {
LoadAddressableNote(aboutAddress, accountViewModel) {
if (it != null) {
Spacer(modifier = DoubleVertSpacer)
Text(
text = stringRes(R.string.attestation_requests_attestation_to),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
NoteCompose(
baseNote = it,
modifier = MaterialTheme.colorScheme.replyModifier,
isQuotedNote = true,
unPackReply = ReplyRenderType.NONE,
makeItShort = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
} else if (aboutEvent != null) {
LoadNote(aboutEvent, accountViewModel) {
if (it != null) {
Spacer(modifier = DoubleVertSpacer)
Text(
text = stringRes(R.string.attestation_requests_attestation_to),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
NoteCompose(
baseNote = it,
modifier = MaterialTheme.colorScheme.replyModifier,
isQuotedNote = true,
unPackReply = ReplyRenderType.NONE,
makeItShort = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
} else if (aboutPubkey != null) {
LoadUser(aboutPubkey, accountViewModel) {
if (it != null) {
Spacer(modifier = DoubleVertSpacer)
Text(
text = stringRes(R.string.attestation_requests_attestation_to),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderAttestorRecommendation(
note: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by note
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val noteEvent = noteState.note.event as? AttestorRecommendationEvent ?: return
val kinds = remember(noteEvent) { noteEvent.kinds() }
val description = remember(noteEvent) { noteEvent.description() }
val aboutPubKey = remember(noteEvent) { noteEvent.dTag() }
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.border(
1.dp,
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f),
RoundedCornerShape(12.dp),
).background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.06f))
.padding(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Recommend,
contentDescription = stringRes(R.string.attestor_recommendation),
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(24.dp),
)
Text(
text = stringRes(R.string.attestor_recommendation),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.tertiary,
)
}
LoadUser(aboutPubKey, accountViewModel) {
if (it != null) {
Spacer(modifier = DoubleVertSpacer)
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
}
}
if (kinds.isNotEmpty()) {
Spacer(modifier = DoubleVertSpacer)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringRes(R.string.attestor_recommendation_for_kinds),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Kinds
kinds.forEach { kind ->
KindChip(kind)
}
}
}
description?.let {
Spacer(modifier = DoubleVertSpacer)
TranslatableRichTextViewer(
content = it,
canPreview = true,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth(),
tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList },
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderAttestorProficiency(
note: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by note
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val noteEvent = noteState.note.event as? AttestorProficiencyEvent ?: return
val kinds = remember(noteEvent) { noteEvent.kinds() }
val description = remember(noteEvent) { noteEvent.description() }
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.border(
1.dp,
MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f),
RoundedCornerShape(12.dp),
).background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.06f))
.padding(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = stringRes(R.string.attestor_proficiency),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(24.dp),
)
Text(
text = stringRes(R.string.attestor_proficiency),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.secondary,
)
}
if (kinds.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
kinds.forEach { kind ->
Text(
text = "Kind $kind",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.secondary,
modifier =
Modifier
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
description?.let {
Spacer(modifier = Modifier.height(8.dp))
TranslatableRichTextViewer(
content = it,
canPreview = true,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth(),
tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList },
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
private fun attestationColor(
status: AttestationStatus?,
validity: Validity?,
): Color =
when {
status == AttestationStatus.REVOKED -> Color(0xFFB71C1C)
status == AttestationStatus.REJECTED -> Color(0xFFB71C1C)
validity == Validity.INVALID -> Color(0xFFB71C1C)
status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32)
validity == Validity.VALID -> Color(0xFF2E7D32)
status == AttestationStatus.VERIFYING -> Color(0xFFF57F17)
status == AttestationStatus.ACCEPTED -> Color(0xFF1565C0)
else -> Color(0xFF757575)
}
private fun attestationIcon(
status: AttestationStatus?,
validity: Validity?,
): ImageVector =
when {
status == AttestationStatus.REVOKED -> Icons.Default.Close
status == AttestationStatus.REJECTED -> Icons.Default.Close
validity == Validity.INVALID -> Icons.Default.Close
status == AttestationStatus.VERIFIED -> Icons.Default.VerifiedUser
validity == Validity.VALID -> Icons.Default.CheckCircle
status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop
status == AttestationStatus.ACCEPTED -> Icons.Default.CheckCircle
else -> Icons.Default.VerifiedUser
}
@Composable
private fun attestationStatusLabel(
status: AttestationStatus?,
validity: Validity?,
): String =
when {
status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked)
status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected)
validity == Validity.INVALID -> stringRes(R.string.attestation_invalid)
status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified)
validity == Validity.VALID -> stringRes(R.string.attestation_valid)
status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying)
status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted)
else -> stringRes(R.string.attestation)
}
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
return sdf.format(Date(timestamp * 1000))
}
@@ -96,7 +96,7 @@ fun RenderLiveChessChallenge(
nav: INav,
) {
val event = (note.event as? LiveChessGameChallengeEvent) ?: return
val gameId = event.gameId() ?: return
val gameId = event.gameId()
val chessViewModel: ChessViewModelNew =
viewModel(
@@ -72,9 +72,9 @@ private fun ObserverAndRenderNIP95(
val content by
remember(noteState) {
// Creates a new object when the event arrives to force an update of the image.
val note = noteState?.note
val note = noteState.note
val uri = header.toNostrUri()
val localDir = note?.idHex?.let { File(Amethyst.instance.nip95cache, it) }
val localDir = note.idHex.let { File(Amethyst.instance.nip95cache, it) }
val blurHash = eventHeader.blurhash()
val dimensions = eventHeader.dimensions()
val description = eventHeader.alt() ?: eventHeader.content
@@ -163,7 +163,7 @@ fun RenderTextModificationEvent(
}
LaunchedEffect(key1 = noteState) {
val newAuthor = accountViewModel.isLoggedUser(noteState?.note?.author)
val newAuthor = accountViewModel.isLoggedUser(noteState.note.author)
if (isAuthorTheLoggedUser.value != newAuthor) {
isAuthorTheLoggedUser.value = newAuthor
@@ -45,7 +45,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
@@ -61,7 +61,7 @@ fun RenderZapPoll(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? PollNoteEvent ?: return
val noteEvent = note.event as? ZapPollEvent ?: return
val eventContent = noteEvent.content
val showReply by
@@ -112,11 +112,11 @@ class AccountSessionManager(
}
}
private suspend fun loginWithDefaultAccount(route: Route? = null) {
private suspend fun loginWithDefaultAccount(routeBuilder: ((account: Account) -> Route?)? = null) {
val accountSettings = localPreferences.loadAccountConfigFromEncryptedStorage()
if (accountSettings != null) {
startUI(accountSettings, route)
startUI(accountSettings, routeBuilder)
} else {
requestLoginUI()
}
@@ -184,11 +184,11 @@ class AccountSessionManager(
fun startUI(
accountSettings: AccountSettings,
route: Route? = null,
routeBuilder: ((account: Account) -> Route?)? = null,
) {
val account = accountsCache.loadAccount(accountSettings)
_accountContent.update {
AccountState.LoggedIn(account, route)
AccountState.LoggedIn(account, routeBuilder?.invoke(account))
}
}
@@ -280,7 +280,7 @@ class AccountSessionManager(
localPreferences.setDefaultAccount(accountSettings)
startUI(accountSettings, route = Route.ImportFollowsSelectUser)
startUI(accountSettings, routeBuilder = { Route.ImportFollowsSelectUser })
scope.launch(Dispatchers.IO) {
delay(2000) // waits for the new user to connect to the new relays.
@@ -329,12 +329,12 @@ class AccountSessionManager(
suspend fun checkAndSwitchUserSync(
npub: String,
route: Route,
routeBuilder: ((account: Account) -> Route?)? = null,
): Boolean {
if (npub != localPreferences.currentAccount()) {
val account = localPreferences.allSavedAccounts().firstOrNull { it.npub == npub }
if (account != null) {
switchUserSync(account, route)
switchUserSync(account, routeBuilder)
return true
}
}
@@ -343,10 +343,10 @@ class AccountSessionManager(
private suspend fun switchUserSync(
accountInfo: AccountInfo,
route: Route? = null,
routeBuilder: ((account: Account) -> Route?)? = null,
) {
localPreferences.switchToAccount(accountInfo)
loginWithDefaultAccount(route)
loginWithDefaultAccount(routeBuilder)
}
fun currentAccountNPub() =
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
import kotlinx.coroutines.CoroutineScope
@@ -68,6 +69,7 @@ class AccountFeedContentStates(
val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(account), scope, LocalCache)
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
val notificationsOpenPolls = OpenPollsState(account, scope)
val notificationSummary = NotificationSummaryState(account)
val feedListOptions = TopNavFilterState(account, scope)
@@ -49,6 +49,7 @@ fun AccountSwitcherAndLeftDrawerLayout(
accountViewModel: AccountViewModel,
accountSessionManager: AccountSessionManager,
nav: INav,
gesturesEnabled: Boolean = true,
content: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
@@ -83,6 +84,7 @@ fun AccountSwitcherAndLeftDrawerLayout(
ModalNavigationDrawer(
drawerState = nav.drawerState,
gesturesEnabled = gesturesEnabled,
drawerContent = {
DrawerContent(nav, openSheetFunction, accountViewModel)
BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer)
@@ -35,6 +35,7 @@ import coil3.asDrawable
import coil3.imageLoader
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
@@ -79,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorType
@@ -93,8 +95,10 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -123,7 +127,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
@@ -144,8 +148,10 @@ import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -158,7 +164,6 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
@Stable
class AccountViewModel(
@@ -176,6 +181,66 @@ class AccountViewModel(
val broadcastTracker = BroadcastTracker()
val feedStates = AccountFeedContentStates(account, viewModelScope)
val eventSync =
EventSync(
accountPubKey = account.signer.pubKey,
relayDb = {
val stats = Amethyst.instance.relayStats.snapshot()
val relays =
account.cache.relayHints.relayDB
.keys()
.filter { url ->
val relayStat = stats[url]
// has connected at least once OR never tried.
if (relayStat != null) {
relayStat.connectionCompleted > 0 || relayStat.connectionTentatives == 0
} else {
true
}
}
val sortMap = relays.associateWith { stats.get(it)?.receivedBytes }
relays.sortedByDescending { sortMap[it] }
},
outboxTargets = { account.nip65RelayList.outboxFlow.value },
inboxTargets = { account.nip65RelayList.inboxFlow.value },
dmTargets = { account.dmRelayList.flow.value },
clientBuilder = {
// creates a new client to make sure these events don't end up polluting the local cache.
// Create a new scope that inherits the ViewModel's lifecycle
// but uses a SupervisorJob so child failures are independent.
val customScope = CoroutineScope(viewModelScope.coroutineContext + SupervisorJob())
// Provides a relay pool
val newClient = NostrClient(Amethyst.instance.websocketBuilder, customScope)
// Authenticates with relays.
val auth =
RelayAuthenticator(
newClient,
customScope,
signWithAllLoggedInUsers = { authTemplate ->
if (account.signer.isWriteable()) {
try {
listOf(account.signer.sign(authTemplate))
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
emptyList()
}
} else {
emptyList()
}
},
)
newClient
},
scope = viewModelScope,
)
val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5)
@OptIn(ExperimentalCoroutinesApi::class)
@@ -1009,7 +1074,7 @@ class AccountViewModel(
fun removeDontTranslateFrom(languageCode: String) = launchSigner { account.removeDontTranslateFrom(languageCode) }
fun updateTranslateTo(languageCode: Locale) = launchSigner { account.updateTranslateTo(languageCode) }
fun updateTranslateTo(languageCode: String) = launchSigner { account.updateTranslateTo(languageCode) }
fun prefer(
source: String,
@@ -21,10 +21,19 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display
import android.content.Intent
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BookmarkRemove
import androidx.compose.material.icons.outlined.CellTower
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.LockOpen
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.PersonRemove
import androidx.compose.material.icons.outlined.Report
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
@@ -42,6 +51,9 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
@@ -52,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.Dispatchers
@@ -76,20 +87,20 @@ fun BookmarkGroupItemOptions(
onClick = { popupExpanded.value = true },
) {
VerticalDotsIcon()
}
if (popupExpanded.value) {
BookmarkGroupItemOptionsMenu(
note = baseNote,
isBookmarkItemPrivate = isBookmarkItemPrivate,
onDismiss = { popupExpanded.value = false },
onMoveBookmarkToPublic = onMoveBookmarkToPublic,
onMoveBookmarkToPrivate = onMoveBookmarkToPrivate,
onDeleteBookmarkItem = onDeleteBookmarkItem,
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (popupExpanded.value) {
BookmarkGroupItemOptionsMenu(
note = baseNote,
isBookmarkItemPrivate = isBookmarkItemPrivate,
onDismiss = { popupExpanded.value = false },
onMoveBookmarkToPublic = onMoveBookmarkToPublic,
onMoveBookmarkToPrivate = onMoveBookmarkToPrivate,
onDeleteBookmarkItem = onDeleteBookmarkItem,
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -142,89 +153,71 @@ fun BookmarkGroupItemOptionsMenu(
)
}
DropdownMenu(
expanded = true,
onDismissRequest = onDismiss,
M3ActionDialog(
title = stringRes(R.string.bookmark_item_actions_dialog_title),
onDismiss = onDismiss,
) {
val clipboardManager = LocalClipboardManager.current
val actContext = LocalContext.current
val scope = rememberCoroutineScope()
DropdownMenuItem(
text = { Text(stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label)) },
onClick = if (isBookmarkItemPrivate) onMoveBookmarkToPublic else onMoveBookmarkToPrivate,
)
DropdownMenuItem(
text = { Text(stringRes(R.string.bookmark_remove_action_label)) },
onClick = {
// Bookmark Management section
M3ActionSection {
M3ActionRow(
icon = if (isBookmarkItemPrivate) Icons.Outlined.LockOpen else Icons.Outlined.Lock,
text = stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label),
) {
if (isBookmarkItemPrivate) onMoveBookmarkToPublic() else onMoveBookmarkToPrivate()
}
M3ActionRow(
icon = Icons.Outlined.BookmarkRemove,
text = stringRes(R.string.bookmark_remove_action_label),
isDestructive = true,
) {
onDeleteBookmarkItem()
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
}
}
if (!state.isFollowingAuthor) {
DropdownMenuItem(
text = { Text(stringRes(R.string.follow)) },
onClick = {
val author = note.author ?: return@DropdownMenuItem
// Follow section
M3ActionSection {
if (!state.isFollowingAuthor) {
M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.follow(author)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.unfollow)) },
onClick = {
val author = note.author ?: return@DropdownMenuItem
}
} else {
M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.unfollow(author)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
}
// DropdownMenuItem(
// text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) },
// onClick = {
// val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem
// nav.nav(Route.PeopleListManagement(authorHexKey))
// onDismiss()
// },
// )
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_text)) },
onClick = {
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
accountViewModel.decrypt(lastNoteVersion) {
clipboardManager.setText(AnnotatedString(it))
}
}
}
// Copy & Share section
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) {
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) }
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_user_pubkey)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
note.author?.let {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}"))
onDismiss()
}
}
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_note_id)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString(note.toNostrUri()))
onDismiss()
}
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
@@ -243,74 +236,57 @@ fun BookmarkGroupItemOptionsMenu(
Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
ContextCompat.startActivity(actContext, shareIntent, null)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
if (state.isLoggedUser && note.isDraft()) {
DropdownMenuItem(
text = { Text(stringRes(R.string.edit_draft)) },
onClick = {
}
}
// Edit & Broadcast section
M3ActionSection {
if (state.isLoggedUser && note.isDraft()) {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_draft)) {
nav.nav {
routeEditDraftTo(note, accountViewModel.account)
}
},
)
}
if (note.event is TextNoteEvent && !note.isDraft()) {
if (state.isLoggedUser) {
DropdownMenuItem(
text = { Text(stringRes(R.string.edit_post)) },
onClick = {
wantsToEditPost.value = true
},
)
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.propose_an_edit)) },
onClick = {
wantsToEditPost.value = true
},
)
}
}
}
DropdownMenuItem(
text = { Text(stringRes(R.string.broadcast)) },
onClick = {
if (note.event is TextNoteEvent && !note.isDraft()) {
if (state.isLoggedUser) {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_post)) {
wantsToEditPost.value = true
}
} else {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.propose_an_edit)) {
wantsToEditPost.value = true
}
}
}
M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.broadcast)) {
accountViewModel.broadcast(note)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.timestamp_pending)) },
onClick = {
}
}
// Timestamp & Moderation section
M3ActionSection {
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) {
onDismiss()
},
)
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.timestamp_it)) },
onClick = {
}
} else {
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) {
accountViewModel.timestamp(note)
onDismiss()
},
)
}
HorizontalDivider(thickness = DividerThickness)
if (state.isLoggedUser) {
DropdownMenuItem(
text = { Text(stringRes(R.string.request_deletion)) },
onClick = {
}
}
if (state.isLoggedUser) {
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) {
accountViewModel.delete(note)
onDismiss()
},
)
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.block_report)) },
onClick = { reportDialogShowing = true },
)
}
} else {
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) {
reportDialogShowing = true
}
}
}
}
@@ -30,11 +30,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CellTower
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
@@ -57,6 +57,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
@@ -65,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch
@@ -304,6 +306,31 @@ fun BookmarkGroupActionsMenuButton(
) {
val isActionListOpen = remember { mutableStateOf(false) }
if (isActionListOpen.value) {
M3ActionDialog(
title = stringRes(R.string.list_actions_dialog_title),
onDismiss = { isActionListOpen.value = false },
) {
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.CellTower,
text = stringRes(R.string.bookmark_list_broadcast_btn_label),
) {
onBroadcastList()
isActionListOpen.value = false
}
M3ActionRow(
icon = Icons.Outlined.Delete,
text = stringRes(R.string.bookmark_list_delete_btn_label),
isDestructive = true,
) {
onDeleteList()
isActionListOpen.value = false
}
}
}
}
ClickableBox(
modifier =
StdPadding
@@ -319,44 +346,5 @@ fun BookmarkGroupActionsMenuButton(
onClick = { isActionListOpen.value = true },
) {
VerticalDotsIcon()
BookmarkGroupActionsMenu(
onCloseMenu = { isActionListOpen.value = false },
isOpen = isActionListOpen.value,
onBroadcastList = onBroadcastList,
onDeleteList = onDeleteList,
)
}
}
@Composable
fun BookmarkGroupActionsMenu(
onCloseMenu: () -> Unit,
isOpen: Boolean,
onBroadcastList: () -> Unit,
onDeleteList: () -> Unit,
) {
DropdownMenu(
expanded = isOpen,
onDismissRequest = onCloseMenu,
) {
DropdownMenuItem(
text = {
Text(stringRes(R.string.bookmark_list_broadcast_btn_label))
},
onClick = {
onBroadcastList()
onCloseMenu()
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.bookmark_list_delete_btn_label))
},
onClick = {
onDeleteList()
onCloseMenu()
},
)
}
}
@@ -32,10 +32,12 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Article
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
@@ -57,6 +59,9 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.stringRes
@@ -258,18 +263,18 @@ private fun BookmarkGroupOptionsButton(
onClick = { isMenuOpen.value = true },
) {
VerticalDotsIcon()
GroupOptionsMenu(
groupName = bookmarkGroupName,
groupDescription = bookmarkGroupDescription,
isExpanded = isMenuOpen.value,
onDismiss = { isMenuOpen.value = false },
onGroupRename = onGroupRename,
onGroupDescriptionChange = onGroupDescriptionChange,
onGroupClone = onGroupCloneCreate,
onDelete = onGroupDelete,
)
}
GroupOptionsMenu(
groupName = bookmarkGroupName,
groupDescription = bookmarkGroupDescription,
isExpanded = isMenuOpen.value,
onDismiss = { isMenuOpen.value = false },
onGroupRename = onGroupRename,
onGroupDescriptionChange = onGroupDescriptionChange,
onGroupClone = onGroupCloneCreate,
onDelete = onGroupDelete,
)
}
@Composable
@@ -288,45 +293,32 @@ private fun GroupOptionsMenu(
val optionalCloneName = remember { mutableStateOf<String?>(null) }
val optionalCloneDescription = remember { mutableStateOf<String?>(null) }
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss,
) {
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_rename_btn_label))
},
onClick = {
onGroupRename()
onDismiss()
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_desc_modify_label))
},
onClick = {
onGroupDescriptionChange()
onDismiss()
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_copy_action_btn_label))
},
onClick = {
isCopyDialogOpen.value = true
onDismiss()
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.quick_action_delete))
},
onClick = {
onDelete()
},
)
if (isExpanded) {
M3ActionDialog(
title = stringRes(R.string.group_actions_dialog_title),
onDismiss = onDismiss,
) {
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_rename_btn_label)) {
onGroupRename()
onDismiss()
}
M3ActionRow(icon = Icons.Outlined.Description, text = stringRes(R.string.follow_set_desc_modify_label)) {
onGroupDescriptionChange()
onDismiss()
}
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.follow_set_copy_action_btn_label)) {
isCopyDialogOpen.value = true
onDismiss()
}
}
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) {
onDelete()
onDismiss()
}
}
}
}
if (isCopyDialogOpen.value) {
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
@@ -138,7 +139,22 @@ class BookmarkGroupMetadataViewModel : ViewModel() {
) {
onUploading(true)
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
val sourceUri =
if (account.settings.stripLocationOnUpload) {
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
if (!result.stripped) {
onError(
stringRes(context, R.string.metadata_strip_failed_title),
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
)
onUploading(false)
return
}
result.uri
} else {
galleryUri.uri
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
try {
val result =
@@ -30,12 +30,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BookmarkAdd
import androidx.compose.material.icons.filled.BookmarkRemove
import androidx.compose.material.icons.outlined.BookmarkAdd
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.RemoveCircleOutline
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
@@ -48,6 +47,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkMembershipStatusAndNumberDisplay
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
@@ -158,6 +160,30 @@ fun BookmarkManagementOptions(
) {
val isBookmarkAddTapped = remember { mutableStateOf(false) }
if (isBookmarkAddTapped.value) {
M3ActionDialog(
title = stringRes(R.string.add_bookmark_dialog_title),
onDismiss = { isBookmarkAddTapped.value = false },
) {
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.BookmarkAdd,
text = stringRes(R.string.public_bookmark_add_action_label),
) {
onAddBookmark(false)
isBookmarkAddTapped.value = false
}
M3ActionRow(
icon = Icons.Outlined.Lock,
text = stringRes(R.string.private_bookmark_add_action_label),
) {
onAddBookmark(true)
isBookmarkAddTapped.value = false
}
}
}
}
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -196,29 +222,5 @@ fun BookmarkManagementOptions(
)
}
}
DropdownMenu(
expanded = isBookmarkAddTapped.value,
onDismissRequest = { isBookmarkAddTapped.value = false },
) {
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.public_bookmark_add_action_label))
},
onClick = {
onAddBookmark(false)
isBookmarkAddTapped.value = false
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.private_bookmark_add_action_label))
},
onClick = {
onAddBookmark(true)
isBookmarkAddTapped.value = false
},
)
}
}
}
@@ -26,14 +26,16 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
@@ -44,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.Privat
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import kotlinx.coroutines.launch
@Composable
@@ -92,10 +95,15 @@ fun ChatroomView(
}
}
if (room.users.size == 1) {
// Activates NIP-17 if the user has DM relays
ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) {
newPostModel.nip17 = !it?.relays().isNullOrEmpty()
// Reactively check if recipients have DM relays for NIP-17 delivery
for (userHex in room.users) {
LoadAddressableNote(
ChatMessageRelayListEvent.createAddress(userHex),
accountViewModel,
) { note ->
if (note != null) {
EventFinderFilterAssemblerSubscription(note, accountViewModel)
}
}
}
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
@@ -73,6 +74,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
@@ -88,16 +90,29 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
@Stable
@@ -126,13 +141,65 @@ class ChatNewMessageViewModel :
}
}
var room: ChatroomKey? by mutableStateOf(null)
val room = MutableStateFlow<ChatroomKey?>(null)
var requiresNIP17: Boolean = false
val roomUsers: StateFlow<List<User>> =
room
.mapNotNull {
it?.users?.mapNotNull { userHex -> LocalCache.checkGetOrCreateUser(userHex) } ?: emptyList()
}.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
room.value?.users?.mapNotNull { userHex -> LocalCache.checkGetOrCreateUser(userHex) } ?: emptyList(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val recipientsMissingDmRelays: StateFlow<ImmutableList<User>> =
roomUsers
.transformLatest {
val dmRelayListNoteFlows =
it.map { user ->
user.dmRelayListNote
.flow()
.metadata.stateFlow
}
if (dmRelayListNoteFlows.isEmpty()) {
emitAll(MutableStateFlow(persistentListOf()))
} else {
val flow =
combine(dmRelayListNoteFlows) { dmRelayListNotes ->
dmRelayListNotes
.mapNotNull { noteState ->
val noteEvent = noteState.note.event as? ChatMessageRelayListEvent
if (noteEvent == null || noteEvent.relays().isEmpty()) {
noteState.note.author
} else {
null
}
}.toImmutableList()
}
emitAll(flow)
}
}.onStart {
}.onCompletion {
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
roomUsers.value
.filter { user ->
user.dmInboxRelays().isNullOrEmpty()
}.toImmutableList(),
)
val replyTo = mutableStateOf<Note?>(null)
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
// Stripping failure dialog
val strippingFailureConfirmation = SuspendableConfirmation()
val iMetaAttachments = IMetaAttachments()
var uploadsWaitingToBeSent by mutableStateOf<List<SuccessfulUploads>>(emptyList())
@@ -141,7 +208,8 @@ class ChatNewMessageViewModel :
val urlPreviews = PreviewState()
var isUploadingImage by mutableStateOf(false)
val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false
val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false
var userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
@@ -179,9 +247,6 @@ class ChatNewMessageViewModel :
var wantsZapraiser by mutableStateOf(false)
override var zapRaiserAmount = mutableStateOf<Long?>(null)
// NIP17 Wrapped DMs / Group messages
var nip17 by mutableStateOf(false)
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
@@ -203,30 +268,16 @@ class ChatNewMessageViewModel :
this.uploadState =
ChatFileUploadState(
account.settings.defaultFileServer,
account.settings.stripLocationOnUpload,
)
}
fun load(room: ChatroomKey) {
this.room = room
this.room.tryEmit(room)
this.toUsers =
TextFieldValue(
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
updateNIP17StatusFromRoom()
}
fun updateNIP17StatusFromRoom() {
val room = this.room
if (room != null) {
this.requiresNIP17 = room.users.size > 1
if (this.requiresNIP17) {
this.nip17 = true
}
} else {
this.requiresNIP17 = false
this.nip17 = false
}
}
fun reply(replyNote: Note) {
@@ -359,9 +410,6 @@ class ChatNewMessageViewModel :
urlPreviews.update(message)
iMetaAttachments.addAll(draftEvent.imetas())
requiresNIP17 = draftEvent is NIP17Group
nip17 = draftEvent is NIP17Group
}
suspend fun sendPostSync() {
@@ -401,18 +449,20 @@ class ChatNewMessageViewModel :
val uploadState = uploadState ?: return
accountViewModel.launchSigner {
if (nip17) {
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) {
uploadsWaitingToBeSent += it
draftTag.newVersion()
onceUploaded()
}
} else {
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) {
uploadsWaitingToBeSent += it
draftTag.newVersion()
onceUploaded()
}
ChatFileUploader(account).justUploadNIP17(
uploadState,
onError,
onEncryptedUploadError = { title, message ->
encryptedUploadErrorTitle = title
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.HOLD
},
context,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
) {
uploadsWaitingToBeSent += it
draftTag.newVersion()
onceUploaded()
}
}
}
@@ -422,28 +472,97 @@ class ChatNewMessageViewModel :
context: Context,
onceUploaded: () -> Unit,
) {
val room = room ?: return
val room = room.value ?: return
val uploadState = uploadState ?: return
accountViewModel.launchSigner {
if (nip17) {
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) {
ChatFileSender(room, account).sendNIP17(it)
draftTag.newVersion()
onceUploaded()
ChatFileUploader(account).justUploadNIP17(
uploadState,
onError,
onEncryptedUploadError = { title, message ->
encryptedUploadErrorTitle = title
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.SEND
pendingRetryOnError = onError
pendingRetryContext = context
pendingRetryOnceUploaded = onceUploaded
},
context,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
) {
ChatFileSender(room, account).sendNIP17(it)
draftTag.newVersion()
onceUploaded()
}
}
}
// Encrypted upload error state for retry dialog
var encryptedUploadErrorTitle by mutableStateOf<String?>(null)
var encryptedUploadErrorMessage by mutableStateOf<String?>(null)
var pendingRetryMode by mutableStateOf<RetryMode?>(null)
var pendingRetryOnError by mutableStateOf<((String, String) -> Unit)?>(null)
var pendingRetryContext by mutableStateOf<Context?>(null)
var pendingRetryOnceUploaded by mutableStateOf<(() -> Unit)?>(null)
enum class RetryMode { HOLD, SEND }
fun dismissEncryptedUploadError() {
encryptedUploadErrorTitle = null
encryptedUploadErrorMessage = null
pendingRetryMode = null
pendingRetryOnError = null
pendingRetryContext = null
pendingRetryOnceUploaded = null
}
fun retryWithoutEncryption() {
val mode = pendingRetryMode ?: return
val onError = pendingRetryOnError
val context = pendingRetryContext
val onceUploaded = pendingRetryOnceUploaded
val room = room.value
val uploadState = uploadState
dismissEncryptedUploadError()
if (room == null || uploadState == null || context == null) return
uploadState.encryptFiles = false
accountViewModel.launchSigner {
when (mode) {
RetryMode.HOLD -> {
ChatFileUploader(account).justUploadNIP17Unencrypted(
uploadState,
onError ?: accountViewModel.toastManager::toast,
context,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
) {
uploadsWaitingToBeSent += it
draftTag.newVersion()
onceUploaded?.invoke()
}
}
} else {
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) {
ChatFileSender(room, account).sendNIP04(it)
draftTag.newVersion()
onceUploaded()
RetryMode.SEND -> {
ChatFileUploader(account).justUploadNIP17Unencrypted(
uploadState,
onError ?: accountViewModel.toastManager::toast,
context,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
) {
ChatFileSender(room, account).sendNIP17(it)
draftTag.newVersion()
onceUploaded?.invoke()
}
}
}
}
}
private suspend fun innerSendPost(draftTag: String?) {
val room = room ?: return
val room = room.value ?: return
val urls = findURLs(message.text)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
@@ -456,70 +575,49 @@ class ChatNewMessageViewModel :
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null
if (nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) {
val replyHint = replyTo.value?.toEventHint<BaseDMGroupEvent>()
val replyHint = replyTo.value?.toEventHint<BaseDMGroupEvent>()
val template =
if (replyHint == null) {
ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) {
hashtags(findHashtags(message))
references(findURLs(message))
quotes(findNostrEventUris(message))
val template =
if (replyHint == null) {
ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) {
hashtags(findHashtags(message))
references(findURLs(message))
quotes(findNostrEventUris(message))
geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
} else {
ChatMessageEvent.reply(message, replyHint) {
hashtags(findHashtags(message))
references(findURLs(message))
quotes(findNostrEventUris(message))
geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
}
if (draftTag != null) {
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
} else {
accountViewModel.account.sendNip17PrivateMessage(template)
}
} else {
val toUser = room.users.first().let { LocalCache.getOrCreateUser(it).toPTag() }
val template =
PrivateDmEvent.build(
toUser = toUser,
message = message,
imetas = usedAttachments,
replyingTo = replyTo.value?.toEventHint<PrivateDmEvent>(),
signer = accountViewModel.account.signer,
) {
geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
}
if (draftTag != null) {
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
emojis(emojis)
imetas(usedAttachments)
}
} else {
accountViewModel.account.sendNip04PrivateMessage(template)
ChatMessageEvent.reply(message, replyHint) {
hashtags(findHashtags(message))
references(findURLs(message))
quotes(findNostrEventUris(message))
geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
}
if (draftTag != null) {
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
} else {
accountViewModel.account.sendNip17PrivateMessage(template)
}
if (draftTag == null) {
ChatFileSender(room, accountViewModel.account).sendAll(uploadsWaitingToBeSent)
ChatFileSender(room, accountViewModel.account).sendNIP17(uploadsWaitingToBeSent)
}
}
@@ -560,6 +658,9 @@ class ChatNewMessageViewModel :
userSuggestionsMainMessage = null
uploadsWaitingToBeSent = emptyList()
uploadState?.reset()
dismissEncryptedUploadError()
iMetaAttachments.reset()
@@ -611,12 +712,10 @@ class ChatNewMessageViewModel :
val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex }
if (users.isNullOrEmpty()) {
room = null
updateNIP17StatusFromRoom()
room.emit(null)
} else {
if (users != room?.users) {
room = ChatroomKey(users)
updateNIP17StatusFromRoom()
if (users != room.value?.users) {
room.emit(ChatroomKey(users))
}
}
}
@@ -649,9 +748,6 @@ class ChatNewMessageViewModel :
val lastWord = toUsers.currentWord()
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
updateRoomFromUsersInput()
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelaysNorm()
nip17 = relayList != null
}
userSuggestionsMainMessage = null
@@ -691,11 +787,12 @@ class ChatNewMessageViewModel :
fun canPost(): Boolean =
message.text.isNotBlank() &&
uploadState?.isUploadingImage != true &&
uploadState?.mediaUploadTracker?.isUploading != true &&
!wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) &&
(toUsers.text.isNotBlank()) &&
uploadState?.multiOrchestrator == null
uploadState?.multiOrchestrator == null &&
recipientsMissingDmRelays.value.isEmpty()
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
@@ -707,15 +804,7 @@ class ChatNewMessageViewModel :
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
}
fun toggleNIP04And24() {
if (requiresNIP17) {
nip17 = true
} else {
nip17 = !nip17
}
draftTag.newVersion()
}
// NIP-04 sending is deprecated. NIP-17 is always used.
override fun updateZapPercentage(
index: Int,
@@ -197,7 +197,7 @@ fun NewGroupDMScreen(
// function when the postViewModel is released
accountViewModel.launchSigner {
postViewModel.sendPostSync()
postViewModel.room?.let {
postViewModel.room.value?.let {
nav.nav(routeToMessage(it, null, null, null, null, accountViewModel))
}
}
@@ -287,7 +287,8 @@ fun GroupDMScreenContent(
ImageVideoDescription(
selectedFiles,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
isUploading = uploading.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _, _ ->
postViewModel.uploadAndHold(
accountViewModel.toastManager::toast,
context,
@@ -301,6 +302,15 @@ fun GroupDMScreenContent(
)
}
}
postViewModel.encryptedUploadErrorTitle?.let { title ->
EncryptedUploadErrorDialog(
title = title,
message = postViewModel.encryptedUploadErrorMessage ?: "",
onDismiss = postViewModel::dismissEncryptedUploadError,
onRetryWithoutEncryption = postViewModel::retryWithoutEncryption,
)
}
}
}
@@ -322,6 +332,11 @@ fun GroupDMScreenContent(
)
}
val missingRelays by postViewModel.recipientsMissingDmRelays.collectAsStateWithLifecycle()
if (missingRelays.isNotEmpty()) {
RecipientMissingRelaysWarning(missingRelays, accountViewModel, nav)
}
BottomRowActions(postViewModel, accountViewModel)
}
}
@@ -383,9 +398,12 @@ private fun BottomRowActions(
.height(50.dp),
verticalAlignment = CenterVertically,
) {
if (postViewModel.room != null) {
val room by postViewModel.room.collectAsStateWithLifecycle()
if (room != null) {
SelectFromGallery(
isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
@@ -393,7 +411,8 @@ private fun BottomRowActions(
}
SelectFromFiles(
isUploading = postViewModel.isUploadingImage,
isUploading = postViewModel.isUploadingFile,
enabled = !postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
@@ -412,7 +431,7 @@ private fun BottomRowActions(
}
}
if (postViewModel.room != null) {
if (room != null) {
TakePictureButton(
onPictureTaken = { postViewModel.pickedMedia(it) },
)
@@ -431,7 +450,7 @@ private fun BottomRowActions(
}
}
if (postViewModel.room != null) {
if (room != null) {
TakeVideoButton(
onVideoTaken = { postViewModel.pickedMedia(it) },
)
@@ -538,8 +557,6 @@ fun SendDirectMessageTo(
focusedBorderColor = Color.Transparent,
),
)
ToggleNip17Button(postViewModel, accountViewModel)
}
HorizontalDivider(thickness = DividerThickness)
@@ -20,33 +20,51 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.R.attr.maxLines
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.input.InputTransformation.Companion.keyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.showCount
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.RoomChatFileUploadDialog
@@ -59,9 +77,13 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.PostKeyboard
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Preview
@Composable
@@ -88,10 +110,12 @@ fun PrivateMessageEditFieldRow(
nav: INav,
) {
BackHandler {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
channelScreenModel.cancel()
if (channelScreenModel.message.text.isNotBlank()) {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
}
}
channelScreenModel.cancel()
nav.popBack()
}
@@ -114,6 +138,17 @@ fun PrivateMessageEditFieldRow(
}
}
StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation)
channelScreenModel.encryptedUploadErrorTitle?.let { title ->
EncryptedUploadErrorDialog(
title = title,
message = channelScreenModel.encryptedUploadErrorMessage ?: "",
onDismiss = channelScreenModel::dismissEncryptedUploadError,
onRetryWithoutEncryption = channelScreenModel::retryWithoutEncryption,
)
}
Column(
modifier = EditFieldModifier,
) {
@@ -151,7 +186,12 @@ fun PrivateMessageEditFieldRow(
}
}
EditField(channelScreenModel, onSendNewMessage, accountViewModel)
val missingRelays by channelScreenModel.recipientsMissingDmRelays.collectAsStateWithLifecycle()
if (missingRelays.isNotEmpty()) {
RecipientMissingRelaysWarning(missingRelays, accountViewModel, nav)
} else {
EditField(channelScreenModel, onSendNewMessage, accountViewModel)
}
}
}
@@ -196,6 +236,90 @@ fun EditField(
)
}
@Preview
@Composable
fun RecipientMissingRelaysWarningPreview() {
val user1 = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
val user2 = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b")
ThemeComparisonColumn {
RecipientMissingRelaysWarning(
persistentListOf(user1, user2),
mockAccountViewModel(),
EmptyNav(),
)
}
}
@Composable
fun RecipientMissingRelaysWarning(
users: ImmutableList<User>,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy10dp,
) {
UserGallery(users) { user ->
ClickableUserPicture(
user,
Size25dp,
accountViewModel,
onClick = {
nav.nav { routeFor(user) }
},
)
}
Text(
text = stringRes(R.string.recipient_missing_dm_relays),
color = MaterialTheme.colorScheme.error,
fontSize = Font12SP,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
fun UserGallery(
users: ImmutableList<User>,
galleryUser: @Composable RowScope.(user: User) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy((-10).dp),
) {
users.take(6).forEach {
key(it.pubkeyHex) {
galleryUser(it)
}
}
if (users.size > 6) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.size(Size25dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
) {
Text(
text = "+" + showCount(users.size - 6),
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
@Composable
fun KeyboardLeadingIcon(
channelScreenModel: ChatNewMessageViewModel,
@@ -203,7 +327,7 @@ fun KeyboardLeadingIcon(
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 10.dp),
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
) {
SelectFromGallery(
isUploading = channelScreenModel.isUploadingImage,
@@ -211,7 +335,38 @@ fun KeyboardLeadingIcon(
modifier = Modifier,
onImageChosen = channelScreenModel::pickedMedia,
)
ToggleNip17Button(channelScreenModel, accountViewModel)
}
}
@Composable
fun EncryptedUploadErrorDialog(
title: String,
message: String,
onDismiss: () -> Unit,
onRetryWithoutEncryption: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(message)
Text(
stringRes(R.string.upload_without_encryption_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
},
confirmButton = {
TextButton(onClick = onRetryWithoutEncryption) {
Text(stringRes(R.string.retry_without_encryption))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.cancel))
}
},
)
}
@@ -25,94 +25,25 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.IncognitoIconButtonModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.launch
@Composable
fun ToggleNip17Button(
channelScreenModel: ChatNewMessageViewModel,
accountViewModel: AccountViewModel,
) {
var wantsToActivateNIP17 by remember { mutableStateOf(false) }
if (wantsToActivateNIP17) {
NewFeatureNIP17AlertDialog(
accountViewModel = accountViewModel,
onConfirm = { channelScreenModel.toggleNIP04And24() },
onDismiss = { wantsToActivateNIP17 = false },
)
}
fun Nip17Indicator(channelScreenModel: ChatNewMessageViewModel) {
IconButton(
modifier = Modifier.width(30.dp),
onClick = {
if (
!accountViewModel.account.settings.hideNIP17WarningDialog &&
!channelScreenModel.nip17 &&
!channelScreenModel.requiresNIP17
) {
wantsToActivateNIP17 = true
} else {
channelScreenModel.toggleNIP04And24()
}
},
onClick = { },
enabled = false,
) {
if (channelScreenModel.nip17) {
Icon(
painter = painterRes(R.drawable.incognito, 2),
contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message),
modifier = IncognitoIconButtonModifier,
tint = MaterialTheme.colorScheme.primary,
)
} else {
Icon(
painter = painterRes(R.drawable.incognito_off, 2),
contentDescription = stringRes(id = R.string.accessibility_turn_on_sealed_message),
modifier = IncognitoIconButtonModifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
Icon(
painter = painterRes(R.drawable.incognito, 2),
contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message),
modifier = IncognitoIconButtonModifier,
tint = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
fun NewFeatureNIP17AlertDialog(
accountViewModel: AccountViewModel,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
val scope = rememberCoroutineScope()
QuickActionAlertDialog(
title = stringRes(R.string.new_feature_nip17_might_not_be_available_title),
textContent = stringRes(R.string.new_feature_nip17_might_not_be_available_description),
buttonIconResource = R.drawable.incognito,
buttonIconReference = 3,
buttonText = stringRes(R.string.new_feature_nip17_activate),
onClickDoOnce = {
scope.launch { onConfirm() }
onDismiss()
},
onClickDontShowAgain = {
scope.launch {
onConfirm()
accountViewModel.account.settings.setHideNIP17WarningDialog()
}
onDismiss()
},
onDismiss = onDismiss,
)
}

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