Merge branch 'vitorpamplona:main' into main

This commit is contained in:
Nathan Day
2026-03-25 11:01:43 +00:00
committed by GitHub
582 changed files with 56829 additions and 5007 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
+1 -1
View File
@@ -16,7 +16,7 @@
"hooks": [
{
"type": "command",
"command": "./gradlew spotlessApply",
"command": "./gradlew spotlessApply 2>/dev/null || spotless-apply",
"timeout": 120
}
]
+2 -2
View File
@@ -745,8 +745,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
versionCode = 430
versionName = "1.04.2"
versionCode = 435
versionName = "1.06.3"
vectorDrawables {
useSupportLibrary = true
@@ -0,0 +1,96 @@
---
name: find-missing-translations
description: Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language
---
# Find Missing Translations
## Overview
Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
## When to Use
- Need to find untranslated strings for a specific locale
- Preparing a batch of strings for a translator
- Checking translation coverage after adding new features
## Target Locales
The default set of locales (unless the user specifies otherwise):
| Locale | Language | Directory |
|--------|----------|-----------|
| `cs-rCZ` | Czech | `values-cs-rCZ` |
| `pt-rBR` | Brazilian Portuguese | `values-pt-rBR` |
| `sv-rSE` | Swedish | `values-sv-rSE` |
| `de-rDE` | German | `values-de-rDE` |
## Technique
### 1. Identify files
```
Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml
```
### 2. Find missing keys using cs-rCZ as reference
Always diff against `cs-rCZ` first — it is the most complete locale and serves as the reference. Any keys missing in `cs-rCZ` will also be missing in the other target locales.
```bash
# Extract translatable keys from default (exclude translatable="false")
comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
```
This gives the list of missing key names. Do NOT diff each locale separately — assume the same keys are missing in all target locales.
### 3. Get English values for missing keys
For each missing key, extract its English value:
```bash
# For each missing key, extract the full line from default strings.xml
while IFS= read -r key; do
grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml
done < <(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
```
### 4. Present results and ask to translate
Output the missing entries as raw XML resource lines (copy-paste ready):
```xml
<string name="attestation_valid">Valid</string>
<string name="attestation_valid_from">Valid from %1$s</string>
<string name="feed_group_lists">Lists</string>
```
Also check `<string-array>` and `<plurals>` tags using the same approach if the project uses them.
**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?"
### 5. Adding translations (if approved)
When adding translated strings to locale files:
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
## Common Mistakes
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Not checking string-arrays/plurals** — only checking `<string>` misses other resource types
- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
@@ -47,7 +47,7 @@
### :quartz (KMP Nostr Library)
**Type:** Kotlin Multiplatform Library
**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64)
**Targets:** JVM, Android, iOS (iosArm64, iosSimulatorArm64)
**Dependencies:**
- External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain
@@ -127,7 +127,6 @@ commonMain (base)
│ ├─ androidMain (Android platform)
│ └─ jvmMain (Desktop platform)
└─ iosMain (iOS platform)
├─ iosX64Main
├─ iosArm64Main
└─ iosSimulatorArm64Main
```
+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):**
+4 -4
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.05.1` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.3` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.05.1"
quartz = "1.06.3"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.05.1")
implementation("com.vitorpamplona.quartz:quartz:1.06.3")
}
```
@@ -609,7 +609,7 @@ SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-
import com.vitorpamplona.quartz.nip01Core.store.EventStore
import android.content.Context
val store = EventStore(context)
val store = EventStore()
// Insert
store.insert(event)
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.05.1
com.vitorpamplona.quartz:quartz:1.06.3
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.05.1"
quartz = "1.06.3"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.05.1")
implementation("com.vitorpamplona.quartz:quartz:1.06.3")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.05.1")
implementation("com.vitorpamplona.quartz:quartz:1.06.3")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
+1 -1
View File
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
## Current artifact
```
com.vitorpamplona.quartz:quartz:1.05.1
com.vitorpamplona.quartz:quartz:1.06.3
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
+23 -19
View File
@@ -6,22 +6,26 @@ on:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
@@ -46,16 +50,16 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
@@ -72,7 +76,7 @@ jobs:
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
- name: Upload Test Results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
with:
name: Test Reports
@@ -84,16 +88,16 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
@@ -106,13 +110,13 @@ jobs:
run: ./gradlew assembleDebug
- name: Upload Play APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Play Debug APK
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
- name: Upload FDroid APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: FDroid Debug APK
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
@@ -121,19 +125,19 @@ jobs:
run: ./gradlew assembleBenchmark
- name: Upload Play APK Benchmark
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Play Benchmark APK
path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk
- name: Upload FDroid APK Benchmark
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: FDroid Benchmark APK
path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk
- name: Upload Compose Reports
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Compose Reports
path: amethyst/build/compose_compiler
@@ -163,16 +167,16 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
@@ -185,7 +189,7 @@ jobs:
run: ./gradlew :desktopApp:${{ matrix.task }}
- name: Upload Desktop Distribution
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-path }}
+6 -6
View File
@@ -27,16 +27,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
@@ -263,16 +263,16 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
+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
+9
View File
@@ -33,6 +33,10 @@
/captures
.cxx
# superpowers skill
.superpowers
docs/brainstorms
docs/superpowers
# Built application files
*.apk
@@ -153,3 +157,8 @@ TASKS.md
# Claude Code local settings
.claude/settings.local.json
# Downloaded VLC binaries (vlc-setup plugin)
desktopApp/src/jvmMain/appResources/linux/
desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/
+2 -1
View File
@@ -7,6 +7,7 @@
<option name="jvmTarget" value="21" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.3.10" />
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.3.20" />
</component>
</project>
+108 -38
View File
@@ -1,12 +1,19 @@
Adds support for creating and rendering NIP-85 Polls
<a id="v1.06.0"></a>
# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2025-03-21
Adds support for Relay Feeds
Polls:
- Adds support for creating and rendering NIP-85
- Redesign of the poll and zap poll cards
- Adds special notification card while the poll is running
Relay Feeds
- Adds support for rendering relay feeds
- Adds support for NIP-51 favorite relay feeds
- Shows favorite relays in the top navigation filter
- Clicking wss:// links shows the global feed for that relay.
- New user account adds nostr.wine to favorite relay feeds
Redesigns Media Player
Media Player
- Redesigned player controls for videos, audios, and picture-in-picture.
- Adds our own buttons and indicators for the video playback
- Adds Music support with waveform animations
@@ -15,44 +22,78 @@ Redesigns Media Player
- Turn video controller creation into a flow to fix playback lifecycle issues
- Adds support for uploading audio
Adds support for NIP-47 Wallets
NWC Wallets:
- Adds support for NIP-47 Wallets and compete NWC spec
- Adds views for Balance and Transactions
- Add transaction filtering and pagination to wallet screen
- Added several test cases from other repos to guarantee interoperability
Adds support for NIP-52 Calendar appointments
Calendar:
- Adds support for NIP-52 Calendar appointments
- Adds proper display of calendar time slot and date slot events in the note feed
- Refactored the early implementation on Quartz for easier use
Adds support for NIP-39 External Identities with kind 10011
Code Snippets:
- Adds support for NIP-C0 Code Snippets
- Replies using NIP-22
Adds support for NIP-C0 Code Snippets
NIPs on Nostr
- Adds support for event kind 30817
- Replies using NIP-22
Adds support for NIPs on Nostr (event kind 30817)
PayTo:
- Adds support for NIP-A3 Payment targets by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
Adds support for NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
Blossom BUD-10:
- Adds support for "Blossom:" URIs on the post
- Supports automatic discovery of blossom servers
- Renders/Previews images, audios, videos, and documents
- Includes support for encryption when using it in NIP-17 DMs.
Adds support for BUD-10 "Blossom:" URIs in images, audios, videos, and documents.
Expirations
- Adds enhanced support for custom expirations in any new post.
- Displays expirations on posts and DMs
Adds support for NIP-40 Expirations in any new post.
Relay Monitors:
- Adds support for NIP-66 Relay monitor and discovery support to Quartz
Adds support for NIP-66 Relay Monitor and discovery support to Quartz
Attestations:
- Adds support for rendering Attestations (https://attestr.xyz/)
- Recommendations, Requests and Attestor Declarations are also included.
Adds support for Namecoin .bit urls to NIP-05
- Adds choice of ElectrumX server to resolve namecoins.
Chess:
- Adds basic support for Chess with Jester protocol
- Full chess game implemented
- Supports for game challenges and view external games
- Running on debug only for now
Adds basic support for Chess with Jester protocol
DMs:
- Removes NIP-04 DMs
- Blocks DM sending if the receiver doesn't have NIP-17 relay lists.
- Removed incognito icon from the new post field.
Adds NIP-46 Bunker support to Quartz and Amethyst Desktop
Push Notifications:
- Adds support for inline reply
- Adds support for notification grouping
- Adds support for Async image Loading
- Removed NIP-04 notifications
Adds a Broadcasting feedback pop-up in the Complete UI mode
Long Form:
- Adds support for writing Long Form/Markdown content
- Includes support for automatic Draft saving and editing
- Includes support for editing
Adds support for rendering Zap events when quoted inside of posts.
Uploads:
- Adds support to upload Documents to all new post screens.
- Adds toggle to stip file metadata regardless of compression by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Adds encrypted file upload fallback option for NIP-17 chats
- Removes support for NIP-96 and updates Blossom recommendations
Removes support for NIP-96 and updates Blossom recommendations
Adds support to upload Documents to all new post screens.
Content warning improvements:
- Adds optional description field for sensitive content warnings in new posts.
Content Warning:
- Adds an optional description field for sensitive content warnings in new posts.
- Displays additional information on warning composables
Redesigns and reorganizes Setting pages
Settings redesign:
- Consolidate drawer settings into a single Settings hub screen
- Redesigns Zap Amount and NWC setup screens
- Redesigns Custom zap amount screens
@@ -61,13 +102,21 @@ Redesigns and reorganizes Setting pages
- Adds reactions row settings (enable/disable, order, show/hide counters) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
- Tapping on Zap without any pre-configured amount opens the custom dialog
URL/URI parser rewrite in Kotlin multiplatform (KMP)
Content parsers:
- URL/URI parser rewrite in Kotlin multiplatform (KMP)
- Fixes characters attached to URLs or nostr URLs without a space
- Massively increases parsing performance
- Treat multibyte characters as URL terminators in RichTextParser by @npub1k0jrarx8um0lyw3nmysn50539ky4k8p7gfgzgrsvn8d7lccx3d0s38dczd
- Adds a parser for blossom: uris
Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
UI Improvements:
- Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
- New Material 3 UI for DropDowns by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- New Material 3 UI for feed filters by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Draft Screen requests confirmation before deleting drafts on swipe
- Swipe to switch tabs. Main screen and messages by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Adds support for rendering Zap events when quoted inside of posts.
- Adds a Broadcasting feedback pop-up in the Complete UI mode
Relay Management:
- Adds relay search tooltip when adding relays
@@ -75,9 +124,11 @@ Relay Management:
- Adds active subscriptions and outbox event in the queue to relay information
- Adds a complete list of event kind names to the subscription card to relay information
- Tracks and displays connection success rate on relay settings
- Add relay settings export functionality
- Adds relay settings export functionality
- Adds NIP-45 count queries to show how many events each relay has.
- Adds Relay sync utility to help users move posts between relays.
Search fixes
Search:
- Breaks the search filter into two subscriptions to prioritize Metadata without punishing content.
- Fixes the need to start user searches with @ in user fields
- Fixes the stability of the search feed when the user navigates away and back.
@@ -87,17 +138,21 @@ Search fixes
- Removes outdated versions of addressables from the search results
Profiles:
- Adds support for NIP-39 External Identities with kind 10011
- Adds a profile picture upload button when the user has no picture
- Adds last seen to the user profile
- Adds nprofile and npub copy options to the profile
- Groups received zap amounts by sending the user in the profile tab
- Increases the limit of Zap downloads for profiles to 1000
- Simplifies profile edit screen layout by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
- Migrates profile galleries to display a thumbnail for videos
- Fixes profile galleries' aspect ratios
- Adds support for Namecoin .bit urls to NIP-05 and choice of ElectrumX server to resolve namecoins.
Bulk Follow onboarding
- Adds screens to search for a user and to copy his/her follow list
Onboarding
- Adds bulk follow screens to search for a user and to copy his/her follow list
Voice message support by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
Voice message by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Adds voice anonymization
- Change from "hold to record" to "click to start, click to stop"
- Display kind 1 voice replies as an audio waveform
@@ -126,10 +181,8 @@ Fixes:
- Fixes bug on Show More calculations for very long texts without spaces
- Fixing IO Dispatchers and coroutine scopes of choice
- Fixes anySync parallel operation that was returning the first result, not the first positive "any".
AI:
- Add SKILL.md for AI agent customization
- Add settings and hooks to setup Android Development for the agent
- Fixes Req onCannotConnect listeners to the relays that actually sent the req
- Fixes hanging subscriptions when exceptions happen during NostrClient utility methods
Defaults:
- Switches wss://nostr.band to wss://antiprimal.net, wss://relay.ditto.pub on app defaults
@@ -137,6 +190,15 @@ Defaults:
- Adds wss://directory.yabu.me and wss://profiles.nostr1.com as index relays
- Adds electrumx.testls.space, nmc2.bitcoins.sk, 46.229.238.187 and i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion as ElectrumX servers
Quartz:
- Adds Relay Server implementation with NIP-45 COUNT and NIP-42 AUTH support
- Adds support for dynamic auth policies to the relay implementation.
- Migrates Quartz EventStore from Android-only to KMP
- Adds a reqUntilEoseAsFlow extension to the Nostr Client
- Adds a reqBypassingRelayLimits extension to the Nostr Client
- Adds comprehensive NIP-46 Bunker support
- Adds comprehensive support for NIP-47 non-payment methods.
Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
- Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge.
- Provide implementation for LargeCache, using a CacheMap
@@ -149,7 +211,6 @@ Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53
- Provide implementation for AESGCM
- Provide implementation for DigestInstance
- Provide implementation for LibSodium
- Adds a reqUntilEoseAsFlow extension to the Nostr Client
Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
- Adds NIP-46 Bunker Login
@@ -159,6 +220,8 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7
- Adds encrypted DMs (NIP-04/NIP-17)
- Adds proper empty states with EOSE tracking
- Adds multi-column deck layout
- Adds Full media parity — images, video, audio, encrypted DMs, upload, lightbox
- Adds advanced search with NIP-50, collapsible sections, and nav state preservation
- Clear stored credentials on logout
- Adds bunker heartbeat indicator
- Adds QR-based signer pairing
@@ -176,6 +239,7 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7
Code Quality
- Migrates to AGP 9.0
- Adds Amethyst Desktop to CI/CD and Release builds
- Removes the in-app memory counter methods
- Refactors the old NIP-05 code on Quartz
- Migrates contact list management to addressable notes
@@ -190,6 +254,10 @@ Code Quality
- Removes support for feed definitions
- AccountState refactoring
AI:
- Add SKILL.md for AI agent customization
- Add settings and hooks to setup Android Development for the agent
Updated translations:
- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
@@ -199,6 +267,8 @@ Updated translations:
- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t
- Chinese by hypnotichemionus4
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
- Russian by Anton Zhao
<a id="v1.05.1"></a>
# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08
@@ -7008,4 +7078,4 @@ First public version with:
[v0.4]: https://github.com/vitorpamplona/amethyst/compare/v0.3...v0.4
[v0.3]: https://github.com/vitorpamplona/amethyst/compare/v0.2...v0.3
[v0.2]: https://github.com/vitorpamplona/amethyst/compare/v0.1...v0.2
[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1
[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1
+17 -6
View File
@@ -5,7 +5,6 @@ plugins {
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.stability.analyzer)
}
def getCurrentBranch() {
@@ -36,6 +35,17 @@ def generateVersionName(String baseVersion) {
}
}
// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
afterEvaluate {
def stabilityNames = tasks.names.findAll { it.contains("StabilityCheck") }
def compileNames = tasks.names.findAll { it.matches("compile.*UnitTestKotlin") }
stabilityNames.each { scName ->
compileNames.each { ctName ->
tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
}
}
}
android {
namespace = 'com.vitorpamplona.amethyst'
compileSdk = libs.versions.android.compileSdk.get().toInteger()
@@ -44,9 +54,9 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 432
versionName = generateVersionName("1.05.1")
buildConfigField "String", "RELEASE_NOTES_ID", "\"b457a20195ffcf501389fcb708f0ef73f4ee263e3bba63f1b893a896129e4c79\""
versionCode = 435
versionName = generateVersionName("1.06.3")
buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -336,9 +346,7 @@ dependencies {
fdroidImplementation libs.unifiedpush
// Charts
implementation libs.vico.charts.core
implementation libs.vico.charts.compose
implementation libs.vico.charts.views
implementation libs.vico.charts.m3
// GeoHash
@@ -352,6 +360,9 @@ dependencies {
// Image compression lib
implementation libs.zelory.image.compressor
// EXIF metadata stripping
implementation libs.androidx.exifinterface
// Voice anonymization DSP
implementation libs.tarsosdsp
@@ -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),
@@ -139,6 +139,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.quartz.nip01Core.tags.references.references
@@ -171,8 +172,8 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
@@ -1292,6 +1293,30 @@ class Account(
return event
}
suspend fun <T : Event> signAnonymouslyAndBroadcast(
template: EventTemplate<T>,
broadcast: List<Event> = emptyList(),
): T {
val anonymousSigner = NostrSignerInternal(KeyPair())
val event = anonymousSigner.sign(template)
cache.justConsumeMyOwnEvent(event)
val note =
if (event is AddressableEvent) {
cache.getOrCreateAddressableNote(event.address())
} else {
cache.getOrCreateNote(event.id)
}
val relayList = computeRelayListToBroadcast(note)
client.send(event, relayList)
broadcast.forEach { client.send(it, relayList) }
return event
}
/**
* Creates a post event without sending it.
* Returns the event, target relays, and extra events to broadcast.
@@ -1609,7 +1634,7 @@ class Account(
client.send(newEvent, outboxRelays.flow.value + destinationRelays)
}
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
if (!isWriteable()) return
val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer)
@@ -2016,6 +2041,7 @@ class Account(
}
scope.launch(Dispatchers.IO) {
@OptIn(kotlinx.coroutines.FlowPreview::class)
settings.saveable.debounce(1000).collect {
if (it.accountSettings != null) {
LocalPreferences.saveToEncryptedStorage(it.accountSettings)
@@ -160,6 +160,7 @@ class AccountSettings(
var externalSignerPackageName: String? = null,
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
var stripLocationOnUpload: Boolean = true,
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -265,6 +266,13 @@ class AccountSettings(
}
}
fun changeStripLocationOnUpload(strip: Boolean) {
if (stripLocationOnUpload != strip) {
stripLocationOnUpload = strip
saveAccountSettings()
}
}
// ---
// list names
// ---
@@ -39,6 +39,10 @@ import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
@@ -58,7 +62,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -135,8 +139,8 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -325,7 +329,7 @@ object LocalCache : ILocalCache, ICacheProvider {
newFilter.init()
observables.put(newFilter, newFilter)
observables[newFilter] = newFilter
awaitClose {
observables.remove(newFilter)
@@ -358,19 +362,19 @@ object LocalCache : ILocalCache, ICacheProvider {
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
override fun getOrCreateUser(key: HexKey): User {
require(isValidHex(key = key)) { "$key is not a valid hex" }
override fun getOrCreateUser(pubkey: HexKey): User {
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
return users.getOrCreate(key) {
val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(key))
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(key))
return users.getOrCreate(pubkey) {
val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(pubkey))
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(pubkey))
User(it, nip65RelayListNote, dmRelayListNote)
}
}
override fun getUserIfExists(key: String): User? {
if (key.isEmpty()) return null
return users.get(key)
override fun getUserIfExists(pubkey: String): User? {
if (pubkey.isEmpty()) return null
return users.get(pubkey)
}
override fun countUsers(predicate: (String, User) -> Boolean): Int {
@@ -394,7 +398,7 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address)
override fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) }
override fun getNoteIfExists(hexKey: String): Note? = if (hexKey.length == 64) notes.get(hexKey) else Address.parse(hexKey)?.let { addressables.get(it) }
fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId)
@@ -619,6 +623,30 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestationRequestEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestorRecommendationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestorProficiencyEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consumeRegularEvent(
event: Event,
relay: NormalizedRelayUrl?,
@@ -961,7 +989,7 @@ object LocalCache : ILocalCache, ICacheProvider {
@Suppress("DEPRECATION")
fun computeReplyTo(event: Event): List<Note> =
when (event) {
is PollNoteEvent -> {
is ZapPollEvent -> {
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
}
@@ -1059,7 +1087,7 @@ object LocalCache : ILocalCache, ICacheProvider {
}
fun consume(
event: PollNoteEvent,
event: ZapPollEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
@@ -2250,6 +2278,7 @@ object LocalCache : ILocalCache, ICacheProvider {
requestNote?.let { request -> zappedNote?.addZapPayment(request, note) }
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
responseCallback(event)
}
@@ -2344,8 +2373,21 @@ object LocalCache : ILocalCache, ICacheProvider {
if (key != null) {
val note = getNoteIfExists(key)
if ((note != null) && !excludeNoteEventFromSearchResults(note)) {
return listOfNotNull(note)
val noteEvent = note?.event
val newNote =
if (noteEvent is AddressableEvent) {
val addressableNote = getAddressableNoteIfExists(noteEvent.address())
if (addressableNote?.event?.id == note.idHex) {
addressableNote
} else {
note
}
} else {
note
}
if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) {
return listOfNotNull(newNote)
}
}
@@ -3050,6 +3092,10 @@ object LocalCache : ILocalCache, ICacheProvider {
is AppDefinitionEvent -> consume(event, relay, wasVerified)
is AppRecommendationEvent -> consume(event, relay, wasVerified)
is AppSpecificDataEvent -> consume(event, relay, wasVerified)
is AttestationEvent -> consume(event, relay, wasVerified)
is AttestationRequestEvent -> consume(event, relay, wasVerified)
is AttestorRecommendationEvent -> consume(event, relay, wasVerified)
is AttestorProficiencyEvent -> consume(event, relay, wasVerified)
is AudioHeaderEvent -> consume(event, relay, wasVerified)
is AudioTrackEvent -> consume(event, relay, wasVerified)
is BadgeAwardEvent -> consume(event, relay, wasVerified)
@@ -3140,7 +3186,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is PublicMessageEvent -> consume(event, relay, wasVerified)
is PeopleListEvent -> consume(event, relay, wasVerified)
is CodeSnippetEvent -> consume(event, relay, wasVerified)
is PollNoteEvent -> consume(event, relay, wasVerified)
is ZapPollEvent -> consume(event, relay, wasVerified)
is PollEvent -> consume(event, relay, wasVerified)
is PollResponseEvent -> consume(event, relay, wasVerified)
is ReactionEvent -> consume(event, relay, wasVerified)
@@ -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.
@@ -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
@@ -47,7 +47,7 @@ class ConnectivityManager(
val isMobileOrNull: StateFlow<Boolean?> =
status
.map {
(status.value as? ConnectivityStatus.Active)?.isMobile
(it as? ConnectivityStatus.Active)?.isMobile
}.stateIn(
scope,
SharingStarted.WhileSubscribed(2000),
@@ -57,7 +57,7 @@ class ConnectivityManager(
val isMobileOrFalse: StateFlow<Boolean> =
status
.map {
(status.value as? ConnectivityStatus.Active)?.isMobile ?: false
(it as? ConnectivityStatus.Active)?.isMobile ?: false
}.stateIn(
scope,
SharingStarted.WhileSubscribed(2000),
@@ -33,6 +33,7 @@ import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient
import coil3.request.Options
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.startsWithIgnoreCase
import okhttp3.Call
import kotlin.coroutines.cancellation.CancellationException
@@ -43,18 +44,14 @@ class BlossomFetcher(
private val blossomServerResolver: BlossomServerResolver,
private val networkFetcher: (url: String) -> Fetcher,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
println("BlossomFetcher: starting $data")
return try {
override suspend fun fetch(): FetchResult? =
try {
val urlResult = blossomServerResolver.findServers(data.toString())
println("BlossomFetcher: finished $data to ${urlResult?.serverUrl}")
networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch()
} catch (e: Exception) {
if (e is CancellationException) throw e
println("BlossomFetcher: cancelled or error: $e $data")
null
}
}
@OptIn(ExperimentalCoilApi::class)
class Factory(
@@ -68,11 +65,7 @@ class BlossomFetcher(
options: Options,
imageLoader: ImageLoader,
): Fetcher? {
println("BlossomFetcher: PreFactory $data")
if (!isApplicable(data)) return null
println("BlossomFetcher: Factory $data")
return BlossomFetcher(options, data, blossomServerResolver) { url ->
NetworkFetcher(
url = url,
@@ -86,6 +79,6 @@ class BlossomFetcher(
}
}
private fun isApplicable(data: Uri): Boolean = data.scheme?.lowercase() == "blossom"
private fun isApplicable(data: Uri): Boolean = data.scheme?.startsWithIgnoreCase("blossom", "BLOSSOM") == true
}
}
@@ -24,8 +24,6 @@ import android.os.Build
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
import com.vitorpamplona.amethyst.BuildConfig
class Logging {
companion object {
@@ -60,9 +58,6 @@ class Logging {
)
// Looper.getMainLooper().setMessageLogging(LogMonitor())
// ChoreographerHelper.start()
// Enable recomposition tracking ONLY in debug builds
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
}
}
}
@@ -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. */
@@ -47,7 +47,7 @@ fun VideoViewInner(
authorName: String? = null,
nostrUriCallback: String? = null,
automaticallyStartPlayback: Boolean,
controllerVisible: MutableState<Boolean> = mutableStateOf(true),
controllerVisible: MutableState<Boolean> = mutableStateOf(false),
onZoom: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
) {
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -30,7 +31,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -103,21 +106,43 @@ private fun HorizontalLinearProgressIndicator(
scrubberColor: Color = playedColor,
rectHeightDp: Dp = 4.dp,
) {
var isDragging by remember { mutableStateOf(false) }
var dragProgress by remember { mutableFloatStateOf(0f) }
Canvas(
Modifier
.fillMaxWidth()
.padding(horizontal = rectHeightDp * 2.5f)
.pointerInput(Unit) {
detectTapGestures { offset ->
// Capture the exact position
onSeek(offset.x / this.size.width.toFloat())
}
}.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
isDragging = true
dragProgress = (offset.x / this.size.width.toFloat()).coerceIn(0f, 1f)
},
onDrag = { change, _ ->
change.consume()
dragProgress = (change.position.x / this.size.width.toFloat()).coerceIn(0f, 1f)
},
onDragEnd = {
onSeek(dragProgress)
isDragging = false
},
onDragCancel = {
isDragging = false
},
)
}.padding(vertical = rectHeightDp * 2)
.height(rectHeightDp)
.onSizeChanged { (w, _) -> onLayoutWidthChanged(w) },
) {
val positionX = (currentPositionProgress() * size.width).coerceAtLeast(0f)
val displayProgress = if (isDragging) dragProgress else currentPositionProgress()
val positionX = (displayProgress * size.width).coerceAtLeast(0f)
val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f)
val scrubberRadius = if (isDragging) size.height * 3f else size.height * 2f
drawRect(unplayedColor, size = Size(size.width, size.height))
drawRect(bufferedColor, size = Size(bufferX, size.height))
@@ -125,7 +150,7 @@ private fun HorizontalLinearProgressIndicator(
drawCircle(
color = scrubberColor,
radius = size.height * 2f,
radius = scrubberRadius,
center = Offset(x = positionX, y = size.height / 2.0f),
)
}
@@ -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
@@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
@@ -426,41 +425,6 @@ fun observeUserIsFollowingChannel(
return flow.collectAsStateWithLifecycle(channel.roomId in account.ephemeralChatList.liveEphemeralChatList.value)
}
@Composable
fun observeUserReports(
user: User,
accountViewModel: AccountViewModel,
onUpdate: () -> Unit,
) {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user, onUpdate) {
user
.reports()
.receivedReportsByAuthor
.onEach { onUpdate() }
.onStart { onUpdate() }
}.collectAsStateWithLifecycle(emptyMap())
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserReportCount(
user: User,
accountViewModel: AccountViewModel,
): State<Int> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(user) { user.reports().countFlow() }
return flow.collectAsStateWithLifecycle(0)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserContactCardsScore(
@@ -502,9 +466,12 @@ fun observeUserStatuses(
val flow =
remember(user) {
user.statusState().statuses.onStart {
user.statusState().removeExpired()
}
user
.statusState()
.statuses
.onStart {
user.statusState().removeExpired()
}.flowOn(Dispatchers.IO)
}
@SuppressLint("StateFlowValueCalledInComposition")
@@ -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)
@@ -193,11 +193,15 @@ fun uriToRoute(
}
if (isWalletConnectRoute(uri)) {
val url = UriParser(uri)
val nip47Uri = url.getQueryParameter("value")
if (nip47Uri != null) {
Nip47WalletConnect.parse(nip47Uri)
return Route.Nip47NWCSetup(nip47Uri)
try {
val url = UriParser(uri)
val nip47Uri = url.getQueryParameter("value")
if (nip47Uri != null) {
Nip47WalletConnect.parse(nip47Uri)
return Route.Nip47NWCSetup(nip47Uri)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
@@ -125,6 +125,8 @@ fun EditPostView(
postViewModel.load(edit, versionLookingAt)
}
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
Dialog(
onDismissRequest = { onClose() },
properties =
@@ -265,8 +267,8 @@ fun EditPostView(
it,
accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context)
onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context, stripMetadata)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
@@ -90,6 +91,9 @@ open class EditPostViewModel : ViewModel() {
// Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
// Stripping failure dialog
val strippingFailureConfirmation = SuspendableConfirmation()
// Codec selection: false = H264, true = H265
var useH265Codec by mutableStateOf(false)
@@ -161,8 +165,9 @@ open class EditPostViewModel : ViewModel() {
server: ServerName,
onError: (String, String) -> Unit,
context: Context,
stripMetadata: Boolean = true,
) = try {
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context)
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context, stripMetadata)
} catch (e: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
@@ -178,6 +183,7 @@ open class EditPostViewModel : ViewModel() {
server: ServerName,
onError: (String, String) -> Unit,
context: Context,
stripMetadata: Boolean = true,
) {
viewModelScope.launch(Dispatchers.IO) {
val myAccount = account
@@ -194,6 +200,8 @@ open class EditPostViewModel : ViewModel() {
myAccount,
context,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (results.allGood) {
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
@@ -59,12 +60,18 @@ open class NewMediaModel : ViewModel() {
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
var onceUploaded: () -> Unit = {}
// Stripping failure dialog
val strippingFailureConfirmation = SuspendableConfirmation()
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1)
// Codec selection: false = H264, true = H265
var useH265Codec by mutableStateOf(false)
// Strip location and sensitive metadata from files before upload
var stripMetadata by mutableStateOf(true)
open fun load(
account: Account,
uris: ImmutableList<SelectedMedia>,
@@ -73,6 +80,7 @@ open class NewMediaModel : ViewModel() {
this.account = account
this.multiOrchestrator = MultiOrchestrator(uris)
this.selectedServer = defaultServer()
this.stripMetadata = account.settings.stripLocationOnUpload
}
fun isImage(
@@ -115,6 +123,8 @@ open class NewMediaModel : ViewModel() {
myAccount,
context,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (results.allGood) {
@@ -89,6 +89,8 @@ fun NewMediaView(
postViewModel.load(account, uris)
}
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
Dialog(
onDismissRequest = { onClose() },
properties =
@@ -112,6 +114,7 @@ fun NewMediaView(
postViewModel.selectedServer?.let {
account.settings.changeDefaultFileServer(it)
}
account.settings.changeStripLocationOnUpload(postViewModel.stripMetadata)
},
)
},
@@ -269,4 +272,15 @@ fun ImageVideoPost(
onCheckedChange = { postViewModel.useH265Codec = it },
)
}
SettingSwitchItem(
title = R.string.strip_metadata_label,
description = R.string.strip_metadata_description,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp),
checked = postViewModel.stripMetadata,
onCheckedChange = { postViewModel.stripMetadata = it },
)
}
@@ -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(
@@ -50,7 +50,7 @@ fun RecordAudioBox(
modifier: Modifier,
onRecordTaken: (RecordingResult) -> Unit,
maxDurationSeconds: Int? = null,
content: @Composable (Boolean, Int) -> Unit,
content: @Composable (Boolean, Int, () -> Unit) -> Unit,
) {
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
val context = LocalContext.current
@@ -79,7 +79,8 @@ fun RecordAudioBox(
}
fun stopRecording() {
val result = mediaRecorder.value?.stop()
val recorder = mediaRecorder.value ?: return
val result = recorder.stop()
mediaRecorder.value = null
if (result != null) {
onRecordTaken(result)
@@ -136,6 +137,10 @@ fun RecordAudioBox(
}
}
},
content = { active -> content(active, elapsedSeconds) },
content = { active ->
content(active, elapsedSeconds) {
stopRecording()
}
},
)
}
@@ -50,15 +50,17 @@ fun RecordVoiceButton(
) {
var isRecording by remember { mutableStateOf(false) }
var elapsedSeconds by remember { mutableIntStateOf(0) }
var onStopRecording: (() -> Unit)? by remember { mutableStateOf(null) }
Column(
verticalArrangement = Arrangement.Center,
) {
// Floating recording indicator at the top
// Floating recording indicator at the top (outside ToggleableBox to avoid scale/circle)
FloatingRecordingIndicator(
modifier = Modifier.height(50.dp),
isRecording = isRecording,
elapsedSeconds = elapsedSeconds,
onClick = onStopRecording,
)
RecordAudioBox(
@@ -69,15 +71,11 @@ fun RecordVoiceButton(
onVoiceTaken(recording)
},
maxDurationSeconds = maxDurationSeconds,
) { recordingState, elapsed ->
// Update parent state after composition completes
) { recordingState, elapsed, onStop ->
SideEffect {
if (isRecording != recordingState) {
isRecording = recordingState
}
if (elapsedSeconds != elapsed) {
elapsedSeconds = elapsed
}
isRecording = recordingState
elapsedSeconds = elapsed
onStopRecording = onStop
}
Box(
@@ -27,6 +27,7 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -176,6 +177,7 @@ fun FloatingRecordingIndicator(
isRecording: Boolean,
elapsedSeconds: Int,
isCompact: Boolean = false,
onClick: (() -> Unit)? = null,
) {
if (!isRecording) return
@@ -199,6 +201,12 @@ fun FloatingRecordingIndicator(
.background(
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(12.dp),
).then(
if (onClick != null) {
Modifier.clickable(onClick = onClick)
} else {
Modifier
},
),
contentAlignment = Alignment.Center,
) {
@@ -215,7 +215,7 @@ private fun ReRecordButton(
modifier = Modifier,
onRecordTaken = onRecordTaken,
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
) { isRecording, elapsedSeconds ->
) { isRecording, elapsedSeconds, _ ->
val contentColor =
if (isRecording) {
MaterialTheme.colorScheme.onPrimary
@@ -0,0 +1,140 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
@Composable
fun M3ActionDialog(
title: String,
onDismiss: () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(
modifier =
Modifier
.padding(vertical = 20.dp)
.verticalScroll(rememberScrollState()),
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp),
)
content()
}
}
}
}
@Composable
fun M3ActionSection(content: @Composable ColumnScope.() -> Unit) {
Surface(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
) {
Column {
content()
}
}
}
@Composable
fun M3ActionRow(
icon: ImageVector,
text: String,
isDestructive: Boolean = false,
enabled: Boolean = true,
onClick: () -> Unit,
) {
val tint =
if (isDestructive) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val textColor =
if (isDestructive) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
val alpha = if (enabled) 1f else 0.38f
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.alpha(alpha)
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Size20Modifier,
tint = tint,
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = text,
fontSize = Font14SP,
color = textColor,
)
}
}
@@ -119,11 +119,12 @@ object ShareHelper {
bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header)
// MP4/MOV alternative: moov, mdat, or free at offset 4
bytesRead >= 8 && (
matchesMagicNumbers(header, 4, MOV_MOOV) ||
matchesMagicNumbers(header, 4, MOV_MDAT) ||
matchesMagicNumbers(header, 4, MOV_FREE)
) -> "mp4"
bytesRead >= 8 &&
(
matchesMagicNumbers(header, 4, MOV_MOOV) ||
matchesMagicNumbers(header, 4, MOV_MDAT) ||
matchesMagicNumbers(header, 4, MOV_FREE)
) -> "mp4"
else -> defaultExtension
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -34,7 +33,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
@@ -61,7 +59,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import kotlinx.collections.immutable.ImmutableList
@@ -159,13 +156,11 @@ private fun BaseTextSpinner(
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it].title
optionsShowing = false
onSelect(it)
}
if (optionsShowing && options.isNotEmpty()) {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it].title
optionsShowing = false
onSelect(it)
}
}
}
@@ -211,14 +206,14 @@ fun <T> SpinnerSelectionDialog(
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
shape = RoundedCornerShape(5.dp),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
LazyColumn {
title?.let {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp, 16.dp),
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalArrangement = Arrangement.Center,
) {
Text(
@@ -227,7 +222,6 @@ fun <T> SpinnerSelectionDialog(
fontWeight = FontWeight.Bold,
)
}
HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
}
}
itemsIndexed(options) { index, item ->
@@ -237,7 +231,7 @@ fun <T> SpinnerSelectionDialog(
Modifier
.fillMaxWidth()
.clickable { onSelect(index) }
.padding(16.dp, 16.dp)
.padding(horizontal = 16.dp, vertical = 12.dp)
.semantics {
role = Role.Button
contentDescription = optionsOfLabel
@@ -245,9 +239,6 @@ fun <T> SpinnerSelectionDialog(
) {
Column { onRenderItem(item) }
}
if (index < options.lastIndex) {
HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
}
}
}
}
@@ -24,12 +24,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
@@ -47,27 +46,6 @@ import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier
@Composable
private fun CopyToClipboard(
popupExpanded: MutableState<Boolean>,
content: String,
onDismiss: () -> Unit,
) {
DropdownMenu(
expanded = popupExpanded.value,
onDismissRequest = onDismiss,
) {
val clipboardManager = LocalClipboardManager.current
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_url_to_clipboard)) },
onClick = {
clipboardManager.setText(AnnotatedString(content))
onDismiss()
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UrlPreviewCard(
@@ -81,11 +59,20 @@ fun UrlPreviewCard(
}
if (popupExpanded.value) {
CopyToClipboard(
popupExpanded = popupExpanded,
content = url,
val clipboardManager = LocalClipboardManager.current
M3ActionDialog(
title = stringRes(R.string.link_actions_dialog_title),
onDismiss = { popupExpanded.value = false },
) {
popupExpanded.value = false
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.ContentCopy,
text = stringRes(R.string.copy_url_to_clipboard),
) {
clipboardManager.setText(AnnotatedString(url))
popupExpanded.value = false
}
}
}
}
@@ -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
@@ -123,7 +124,6 @@ import okhttp3.coroutines.executeAsync
import okio.sink
import java.io.File
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
// Delay before cleaning up shared video temp files.
// Allows time for receiving app to copy the file after user confirms share.
@@ -223,12 +223,7 @@ fun TwoSecondController(
content: BaseMediaContent,
inner: @Composable (controllerVisible: MutableState<Boolean>) -> Unit,
) {
val controllerVisible = remember(content) { mutableStateOf(true) }
LaunchedEffect(content) {
delay(2.seconds)
controllerVisible.value = false
}
val controllerVisible = remember(content) { mutableStateOf(false) }
inner(controllerVisible)
}
@@ -771,119 +766,101 @@ fun ShareMediaAction(
// Track if video is downloading - hoisted here to block menu dismiss during download
val isDownloadingVideo = remember { mutableStateOf(false) }
DropdownMenu(
expanded = popupExpanded.value,
onDismissRequest = { if (!isDownloadingVideo.value) onDismiss() },
) {
val clipboardManager = LocalClipboardManager.current
if (popupExpanded.value) {
M3ActionDialog(
title = stringRes(R.string.media_actions_dialog_title),
onDismiss = { if (!isDownloadingVideo.value) onDismiss() },
) {
val clipboardManager = LocalClipboardManager.current
if (videoUri != null && !videoUri.startsWith("file")) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_url_to_clipboard)) },
onClick = {
clipboardManager.setText(AnnotatedString(videoUri))
onDismiss()
},
)
}
postNostrUri?.let {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_the_note_id_to_the_clipboard)) },
onClick = {
clipboardManager.setText(AnnotatedString(it))
onDismiss()
},
)
}
postNostrUri?.let {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_media_to_gallery)) },
onClick = {
if (videoUri != null) {
val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent
if (n19 != null) {
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType) // TODO Whole list or first?
accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery)
// Copy & Gallery section
if ((videoUri != null && !videoUri.startsWith("file")) || postNostrUri != null) {
M3ActionSection {
if (videoUri != null && !videoUri.startsWith("file")) {
M3ActionRow(icon = Icons.Outlined.Link, text = stringRes(R.string.copy_url_to_clipboard)) {
clipboardManager.setText(AnnotatedString(videoUri))
onDismiss()
}
}
onDismiss()
},
)
}
content?.let {
val context = LocalContext.current
when (content) {
is MediaUrlImage -> {
videoUri?.let {
if (videoUri.isNotEmpty()) {
DropdownMenuItem(
text = { Text(stringRes(R.string.share_image)) },
onClick = {
scope.launch { shareImageFile(context, videoUri, mimeType) }
onDismiss()
},
)
postNostrUri?.let {
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_the_note_id_to_the_clipboard)) {
clipboardManager.setText(AnnotatedString(it))
onDismiss()
}
M3ActionRow(icon = Icons.Outlined.Collections, text = stringRes(R.string.add_media_to_gallery)) {
if (videoUri != null) {
val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent
if (n19 != null) {
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType)
accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery)
}
}
onDismiss()
}
}
}
}
is MediaUrlVideo -> {
videoUri?.let {
if (videoUri.isNotEmpty()) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringRes(R.string.share_video))
if (isDownloadingVideo.value) {
Spacer(modifier = Modifier.width(8.dp))
LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp)
// Share section
content?.let {
val context = LocalContext.current
M3ActionSection {
when (content) {
is MediaUrlImage -> {
videoUri?.let {
if (videoUri.isNotEmpty()) {
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_image)) {
scope.launch { shareImageFile(context, videoUri, mimeType) }
onDismiss()
}
}
}
}
is MediaUrlVideo -> {
videoUri?.let {
if (videoUri.isNotEmpty()) {
M3ActionRow(
icon = Icons.Outlined.Share,
text = stringRes(R.string.share_video),
enabled = !isDownloadingVideo.value,
) {
isDownloadingVideo.value = true
scope.launch {
shareVideoFile(
context = context,
videoUrl = videoUri,
mimeType = mimeType,
okHttpClient = { url ->
accountViewModel.httpClientBuilder.okHttpClientForVideo(url)
},
onComplete = {
isDownloadingVideo.value = false
onDismiss()
},
onError = {
isDownloadingVideo.value = false
},
)
}
}
},
enabled = !isDownloadingVideo.value,
onClick = {
isDownloadingVideo.value = true
scope.launch {
shareVideoFile(
context = context,
videoUrl = videoUri,
mimeType = mimeType,
okHttpClient = { url ->
accountViewModel.httpClientBuilder.okHttpClientForVideo(url)
},
onComplete = {
isDownloadingVideo.value = false
onDismiss()
},
onError = {
isDownloadingVideo.value = false
},
)
}
},
)
}
}
}
is MediaLocalVideo -> {
content.localFile?.let { localFile ->
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_video)) {
scope.launch { shareLocalVideoFile(context, localFile, mimeType) }
onDismiss()
}
}
}
else -> { /* No share option for other types */ }
}
}
is MediaLocalVideo -> {
content.localFile?.let { localFile ->
DropdownMenuItem(
text = { Text(stringRes(R.string.share_video)) },
onClick = {
scope.launch { shareLocalVideoFile(context, localFile, mimeType) }
onDismiss()
},
)
}
}
else -> { /* No share option for other types */ }
}
}
}
@@ -53,7 +53,11 @@ fun FeedLoaded(
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
NoteCompose(
item,
@@ -37,8 +37,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.core.util.Consumer
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
@@ -79,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
@@ -108,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
@@ -126,10 +130,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -142,7 +143,17 @@ fun AppNavigation(
) {
val nav = rememberNav()
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
val isTabPagerRoute =
navBackStackEntry?.destination?.let { dest ->
dest.hasRoute<Route.Home>() || dest.hasRoute<Route.Message>()
} ?: false
val drawerGesturesEnabled =
!isTabPagerRoute ||
nav.drawerState.isOpen ||
nav.drawerState.targetValue != nav.drawerState.currentValue
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav, drawerGesturesEnabled) {
NavHost(
navController = nav.controller,
startDestination = Route.Home,
@@ -195,16 +206,13 @@ fun AppNavigation(
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ImportFollowsPickFollows> {
ImportFollowListPickFollowsScreen(
accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(it.userHex)),
accountViewModel,
nav,
)
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
}
composableFromEndArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
composableFromEnd<Route.EventSync> { EventSyncScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
@@ -223,35 +231,28 @@ fun AppNavigation(
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEndArgs<Route.PublicChatChannel> {
PublicChatChannelScreen(
it.id,
it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) },
it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) },
accountViewModel,
nav,
)
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
}
composableFromEndArgs<Route.LiveActivityChannel> {
LiveActivityChannelScreen(
Address(it.kind, it.pubKeyHex, it.dTag),
draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyTo = it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) },
draftId = it.draftId,
replyToId = it.replyTo,
accountViewModel,
nav,
)
}
composableFromEndArgs<Route.EphemeralChat> {
RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.let { relay ->
EphemeralChatScreen(
channelId = RoomId(it.id, relay),
draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyTo = it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) },
accountViewModel = accountViewModel,
nav = nav,
)
}
EphemeralChatScreen(
id = it.id,
relayUrl = it.relayUrl,
draftId = it.draftId,
replyToId = it.replyTo,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
@@ -265,9 +266,9 @@ fun AppNavigation(
geohash = it.geohash,
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyId = it.replyTo,
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
nav,
)
@@ -276,8 +277,8 @@ fun AppNavigation(
composableFromBottomArgs<Route.NewPublicMessage> {
NewPublicMessageScreen(
to = it.toKey(),
reply = it.replyId?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyId = it.replyId,
draftId = it.draftId,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -288,9 +289,9 @@ fun AppNavigation(
hashtag = it.hashtag,
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyId = it.replyTo,
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
nav,
)
@@ -298,11 +299,11 @@ fun AppNavigation(
composableFromBottomArgs<Route.GenericCommentPost> {
ReplyCommentPostScreen(
reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
replyId = it.replyTo,
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
nav,
)
@@ -312,22 +313,31 @@ fun AppNavigation(
NewProductScreen(
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
nav,
)
}
composableFromBottomArgs<Route.NewLongFormPost> {
LongFormPostScreen(
draftId = it.draft,
versionId = it.version,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromBottomArgs<Route.NewShortNote> {
ShortNotePostScreen(
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
baseReplyTo = it.baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
fork = it.fork?.let { hex -> accountViewModel.getNoteIfExists(hex) },
version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
baseReplyToId = it.baseReplyTo,
quoteId = it.quote,
forkId = it.fork,
versionId = it.version,
draftId = it.draft,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -414,7 +424,9 @@ private fun NavigateIfIntentRequested(
actionableNextPage?.let { nextRoute ->
val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
accountSessionManager.checkAndSwitchUserSync(npub, nextRoute)
accountSessionManager.checkAndSwitchUserSync(npub) { account ->
uriToRoute(intentNextPage, account)
}
} else {
val currentRoute = getRouteWithArguments(nextRoute::class, nav.controller)
if (!isSameRoute(currentRoute, nextRoute)) {
@@ -470,7 +482,9 @@ private fun NavigateIfIntentRequested(
scope.launch {
val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
accountSessionManager.checkAndSwitchUserSync(npub, newPage)
accountSessionManager.checkAndSwitchUserSync(npub) { newAccount ->
uriToRoute(uri, newAccount)
}
} else {
val currentRoute = getRouteWithArguments(newPage::class, nav.controller)
if (!isSameRoute(currentRoute, newPage)) {
@@ -43,6 +43,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Delete
@@ -51,6 +52,7 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -71,7 +73,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
@@ -88,6 +89,7 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -442,7 +444,7 @@ fun ListContent(
NavigationRow(
title = R.string.my_lists,
icon = ImageVector.vectorResource(R.drawable.format_list_bulleted_type),
icon = Icons.AutoMirrored.Filled.FormatListBulleted,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Lists,
@@ -472,13 +474,23 @@ fun ListContent(
route = Route.Wallet,
)
if (isDebug) {
NavigationRow(
title = R.string.route_chess,
icon = R.drawable.ic_chess,
iconReference = 1,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
)
}
NavigationRow(
title = R.string.route_chess,
icon = R.drawable.ic_chess,
iconReference = 1,
title = R.string.event_sync_title,
icon = Icons.Outlined.Sync,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
route = Route.EventSync,
)
NavigationRow(
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
@@ -85,7 +86,11 @@ fun routeForInner(
): Route? =
when (noteEvent) {
is AppDefinitionEvent -> {
Route.ContentDiscovery(noteEvent.id)
if (noteEvent.includeKind(5300)) {
Route.ContentDiscovery(noteEvent.id)
} else {
Route.Note(noteEvent.id)
}
}
is IsInPublicChatChannel -> {
@@ -330,6 +335,10 @@ suspend fun routeEditDraftTo(
Route.NewShortNote(draft = note.idHex)
}
is LongTextNoteEvent -> {
Route.NewLongFormPost(draft = note.idHex)
}
is ClassifiedsEvent -> {
Route.NewProduct(draft = note.idHex)
}
@@ -134,6 +134,8 @@ sealed class Route {
@Serializable object EditRelays : Route()
@Serializable object EventSync : Route()
@Serializable object EditMediaServers : Route()
@Serializable object UpdateReactionType : Route()
@@ -286,6 +288,12 @@ sealed class Route {
val draft: String? = null,
) : Route()
@Serializable
data class NewLongFormPost(
val draft: String? = null,
val version: String? = null,
) : Route()
@Serializable
data class GeoPost(
val geohash: String? = null,
@@ -21,20 +21,36 @@
package com.vitorpamplona.amethyst.ui.navigation.topbars
import android.Manifest
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ViewList
import androidx.compose.material.icons.automirrored.outlined.VolumeOff
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.SensorDoor
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -43,14 +59,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
@@ -62,7 +81,6 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.CommunityName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
@@ -74,6 +92,8 @@ import com.vitorpamplona.amethyst.ui.screen.RelayName
import com.vitorpamplona.amethyst.ui.screen.ResourceName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -120,6 +140,8 @@ fun FeedFilterSpinner(
stringRes(R.string.feed_filter_select_an_option, selectAnOption)
}
val openDropdownLabel = stringRes(R.string.open_dropdown_menu)
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
@@ -137,7 +159,7 @@ fun FeedFilterSpinner(
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
} else {
@@ -152,7 +174,7 @@ fun FeedFilterSpinner(
Row {
Text(
text = "(${myLocation.geoHash})",
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
Spacer(modifier = StdHorzSpacer)
@@ -162,7 +184,7 @@ fun FeedFilterSpinner(
) { cityName ->
Text(
text = "($cityName)",
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
}
@@ -171,7 +193,7 @@ fun FeedFilterSpinner(
LocationState.LocationResult.LackPermission -> {
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
}
@@ -179,7 +201,7 @@ fun FeedFilterSpinner(
LocationState.LocationResult.Loading -> {
Text(
text = stringRes(R.string.loading_location),
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
}
@@ -207,7 +229,7 @@ fun FeedFilterSpinner(
}.semantics {
role = Role.DropdownList
stateDescription = accessibilityDescription
onClick(label = "Open feed filter menu") {
onClick(label = openDropdownLabel) {
optionsShowing = true
return@onClick true
}
@@ -215,20 +237,18 @@ fun FeedFilterSpinner(
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(
title = explainer,
options = options,
onDismiss = { optionsShowing = false },
onSelect = {
selected = options[it]
optionsShowing = false
onSelect(it)
},
) {
RenderOption(it.name, accountViewModel)
}
if (optionsShowing && options.isNotEmpty()) {
GroupedFeedFilterDialog(
title = explainer,
options = options,
onDismiss = { optionsShowing = false },
onSelect = {
selected = options[it]
optionsShowing = false
onSelect(it)
},
) {
RenderOption(it.name, accountViewModel)
}
}
}
@@ -241,84 +261,258 @@ fun RenderOption(
when (option) {
is GeoHashName -> {
LoadCityName(option.geoHashTag) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
}
Text(text = "/g/$it", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
}
is HashtagName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface)
}
Text(text = option.name(), fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
is ResourceName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringRes(id = option.resourceId),
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringRes(id = option.resourceId),
fontSize = Font14SP,
color = MaterialTheme.colorScheme.onSurface,
)
}
is PeopleListName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
val noteState by observeNote(option.note, accountViewModel)
val noteState by observeNote(option.note, accountViewModel)
val noteEvent = noteState.note.event
val name =
when (noteEvent) {
is PeopleListEvent -> {
noteEvent.titleOrName() ?: option.note.dTag()
}
is FollowListEvent -> {
noteEvent.title() ?: option.note.dTag()
}
else -> {
option.note.dTag()
}
val noteEvent = noteState.note.event
val name =
when (noteEvent) {
is PeopleListEvent -> {
noteEvent.titleOrName() ?: option.note.dTag()
}
Text(text = name, color = MaterialTheme.colorScheme.onSurface)
}
is FollowListEvent -> {
noteEvent.title() ?: option.note.dTag()
}
else -> {
option.note.dTag()
}
}
Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
is CommunityName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
val it by observeNote(option.note, accountViewModel)
val it by observeNote(option.note, accountViewModel)
Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", color = MaterialTheme.colorScheme.onSurface)
}
Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
is RelayName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
Text(
text = option.name(),
fontSize = Font14SP,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Immutable
private data class IndexedFeedDefinition(
val originalIndex: Int,
val item: FeedDefinition,
)
private enum class FeedGroup(
@param:androidx.annotation.StringRes val labelRes: Int,
) {
FEEDS(R.string.feed_group_feeds),
HASHTAGS(R.string.feed_group_hashtags),
COMMUNITIES(R.string.feed_group_communities),
LISTS(R.string.feed_group_lists),
}
private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<FeedGroup, List<IndexedFeedDefinition>> {
val indexed = options.mapIndexed { index, item -> IndexedFeedDefinition(index, item) }
return indexed.groupBy { entry ->
when (entry.item.name) {
is HashtagName -> FeedGroup.HASHTAGS
is CommunityName -> FeedGroup.COMMUNITIES
is PeopleListName -> FeedGroup.LISTS
else -> FeedGroup.FEEDS
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GroupedFeedFilterDialog(
title: String,
options: ImmutableList<FeedDefinition>,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
onRenderItem: @Composable (FeedDefinition) -> Unit,
) {
val grouped = remember(options) { groupFeedDefinitions(options) }
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
LazyColumn(
modifier = Modifier.padding(vertical = 20.dp),
) {
Text(
text = option.name(),
color = MaterialTheme.colorScheme.onSurface,
)
item {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp),
)
}
FeedGroup.entries.forEach { group ->
val items = grouped[group]
if (!items.isNullOrEmpty()) {
item {
GroupSection(
label = stringRes(group.labelRes),
items = items,
isChipLayout = group == FeedGroup.HASHTAGS,
onSelect = onSelect,
onRenderItem = onRenderItem,
)
}
}
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GroupSection(
label: String,
items: List<IndexedFeedDefinition>,
isChipLayout: Boolean,
onSelect: (Int) -> Unit,
onRenderItem: @Composable (FeedDefinition) -> Unit,
) {
Surface(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
) {
Column {
Text(
text = label.uppercase(),
fontSize = Font12SP,
letterSpacing = 0.8.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 6.dp),
)
if (isChipLayout) {
FlowRow(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items.forEach { entry ->
Surface(
modifier = Modifier.clickable { onSelect(entry.originalIndex) },
shape = RoundedCornerShape(18.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
color = Color.Transparent,
) {
Text(
text = entry.item.name.name(),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
} else {
items.forEach { entry ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelect(entry.originalIndex) }
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
FeedIcon(
item = entry.item,
modifier = Size20Modifier,
)
Spacer(modifier = Modifier.padding(start = 12.dp))
Column(modifier = Modifier.weight(1f)) { onRenderItem(entry.item) }
}
}
}
}
}
}
@Composable
private fun FeedIcon(
item: FeedDefinition,
modifier: Modifier = Modifier,
) {
val icon =
when (item.code) {
is TopFilter.Global -> {
Icons.Outlined.Public
}
is TopFilter.AroundMe -> {
Icons.Outlined.LocationOn
}
is TopFilter.AllFollows -> {
Icons.Outlined.Groups
}
is TopFilter.AllUserFollows -> {
Icons.Outlined.Person
}
is TopFilter.DefaultFollows -> {
Icons.Outlined.Groups
}
is TopFilter.MuteList -> {
Icons.AutoMirrored.Outlined.VolumeOff
}
is TopFilter.Chess -> {
Icons.Outlined.Groups
}
is TopFilter.PeopleList -> {
Icons.AutoMirrored.Outlined.ViewList
}
else -> {
when (item.name) {
is GeoHashName -> Icons.Outlined.LocationOn
is RelayName -> Icons.Outlined.SensorDoor
is CommunityName -> Icons.Outlined.Groups
is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList
else -> Icons.Outlined.Person
}
}
}
Icon(
imageVector = icon,
contentDescription = null,
modifier = modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -62,7 +62,7 @@ fun BadgeCompose(
nav: INav,
) {
val noteState by observeNote(likeSetCard.note, accountViewModel)
val note = noteState?.note
val note = noteState.note
val context = LocalContext.current.applicationContext
@@ -102,6 +102,10 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
@@ -174,6 +178,10 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
@@ -184,7 +192,7 @@ import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -767,6 +775,22 @@ private fun RenderNoteRow(
RenderAppDefinition(baseNote, accountViewModel, nav)
}
is AttestationEvent -> {
RenderAttestation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AttestationRequestEvent -> {
RenderAttestationRequest(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AttestorRecommendationEvent -> {
RenderAttestorRecommendation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AttestorProficiencyEvent -> {
RenderAttestorProficiency(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AudioTrackEvent -> {
RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav)
}
@@ -1039,7 +1063,7 @@ private fun RenderNoteRow(
)
}
is PollNoteEvent -> {
is ZapPollEvent -> {
RenderZapPoll(
baseNote,
makeItShort,
@@ -672,7 +672,7 @@ fun ReplyViaVoiceReaction(
}
},
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
) { isRecording, elapsedSeconds ->
) { isRecording, elapsedSeconds, onStop ->
if (voiceRecordingState != null) {
SideEffect {
if (voiceRecordingState.value != isRecording) {
@@ -689,6 +689,7 @@ fun ReplyViaVoiceReaction(
isRecording = true,
elapsedSeconds = elapsedSeconds,
isCompact = true,
onClick = onStop,
)
} else {
VoiceReplyIcon(iconSizeModifier, grayTint)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -59,11 +60,12 @@ fun RelayCompose(
accountViewModel: AccountViewModel,
onAddRelay: () -> Unit,
onRemoveRelay: () -> Unit,
onClick: (() -> Unit)? = null,
) {
val context = LocalContext.current
Row(
modifier = StdPadding,
modifier = StdPadding.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
@@ -32,16 +32,27 @@ import kotlin.math.round
private const val YEAR_DATE_FORMAT = "MMM dd, yyyy"
private const val MONTH_DATE_FORMAT = "MMM dd"
private const val YEAR_NO_DAY_DATE_FORMAT = "MMM yyyy"
private const val MONTH_NO_DAY_DATE_FORMAT = "MMM dd"
var locale: Locale = Locale.getDefault()
var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
var yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
fun timeAgo(
time: Long?,
context: Context,
prefix: String = "",
seconds: Int = R.string.now,
minutes: Int = R.string.m,
hours: Int = R.string.h,
days: Int = R.string.d,
): String {
if (time == null) return " "
if (time == 0L) return "${stringRes(context, R.string.never)}"
if (time == 0L) return prefix + stringRes(context, R.string.never)
val timeDifference = TimeUtils.now() - time
@@ -54,7 +65,7 @@ fun timeAgo(
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
"" + yearFormatter.format(time * 1000)
prefix + yearFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_MONTH) {
// Dec 12
if (locale != Locale.getDefault()) {
@@ -63,16 +74,16 @@ fun timeAgo(
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
}
"" + monthFormatter.format(time * 1000)
prefix + monthFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_DAY) {
// 2 days
"" + (timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, R.string.d)
prefix + (timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, days)
} else if (timeDifference > TimeUtils.ONE_HOUR) {
"" + (timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, R.string.h)
prefix + (timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, hours)
} else if (timeDifference > TimeUtils.ONE_MINUTE) {
"" + (timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, R.string.m)
prefix + (timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, minutes)
} else {
"" + stringRes(context, R.string.now)
prefix + stringRes(context, seconds)
}
}
@@ -116,6 +127,46 @@ fun timeAgoNoDot(
}
}
fun timeAgoNoDotNoDay(
time: Long?,
context: Context,
): String {
if (time == null) return " "
if (time == 0L) return " ${stringRes(context, R.string.never)}"
val timeDifference = TimeUtils.now() - time
return if (timeDifference > TimeUtils.ONE_YEAR) {
// Dec 12, 2022
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
}
yearNoDayFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_MONTH) {
// Dec 12
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
}
monthNoDayFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_DAY) {
// 2 days
(timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, R.string.d)
} else if (timeDifference > TimeUtils.ONE_HOUR) {
(timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, R.string.h)
} else if (timeDifference > TimeUtils.ONE_MINUTE) {
(timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, R.string.m)
} else {
stringRes(context, R.string.now)
}
}
fun timeAheadNoDot(
time: Long?,
context: Context,
@@ -30,10 +30,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -49,10 +49,10 @@ fun showAmountInteger(amount: BigDecimal?): String {
if (amount.abs() < BigDecimal(0.01)) return ""
return when {
amount >= OneGiga -> dfG.get().format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP))
amount >= OneMega -> dfM.get().format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP))
amount >= TenKilo -> dfK.get().format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP))
else -> dfN.get().format(amount)
amount >= OneGiga -> dfG.get()?.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) ?: ""
amount >= OneMega -> dfM.get()?.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) ?: ""
amount >= TenKilo -> dfK.get()?.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) ?: ""
else -> dfN.get()?.format(amount) ?: ""
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -32,7 +34,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -58,8 +59,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
@@ -93,13 +97,15 @@ import com.vitorpamplona.amethyst.ui.theme.BigPadding
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
@@ -115,7 +121,7 @@ import kotlin.uuid.Uuid
@Composable
fun ZapZapPollNotePreview() {
val event =
PollNoteEvent(
ZapPollEvent(
id = "6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72",
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
createdAt = 1729186078,
@@ -188,7 +194,7 @@ fun ZapZapPollNotePreview() {
@Composable
fun ZapZapPollNotePreview2() {
val event =
PollNoteEvent(
ZapPollEvent(
id = "3064bf97800a4b04b612fc0fd498936eae75fffbdca5bbd09d19a6dc598530ab",
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
createdAt = 1729191389,
@@ -311,13 +317,6 @@ private fun OptionNote(
modifier = Modifier.padding(vertical = 3.dp),
) {
if (!pollViewModel.canZap.value) {
val color =
if (poolOption.consensusThreadhold.value) {
Color.Green.copy(alpha = 0.32f)
} else {
MaterialTheme.colorScheme.mediumImportanceLink
}
ZapVote(
baseNote,
poolOption,
@@ -326,7 +325,7 @@ private fun OptionNote(
RenderOptionAfterVote(
baseNote,
poolOption,
color,
poolOption.consensusThreadhold.value,
canPreview,
tags,
backgroundColor,
@@ -366,7 +365,7 @@ private fun OptionNote(
private fun RenderOptionAfterVote(
baseNote: Note,
poolOption: PollOption,
color: Color,
isWinning: Boolean,
canPreview: Boolean,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
@@ -376,14 +375,25 @@ private fun RenderOptionAfterVote(
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = QuoteBorder)
.clip(SmallishBorder)
.border(
2.dp,
color,
QuoteBorder,
width = 1.dp,
color =
if (isWinning) {
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colorScheme.grayText
},
shape = SmallishBorder,
).background(
if (isWinning) {
MaterialTheme.colorScheme.allGoodColor.copy(0.2f)
} else {
MaterialTheme.colorScheme.subtleBorder
},
),
) {
DisplayProgress(poolOption, color, modifier = Modifier.matchParentSize())
DisplayProgress(poolOption, isWinning, modifier = Modifier.matchParentSize())
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -428,22 +438,29 @@ private fun RenderOptionAfterVote(
@Composable
private fun DisplayProgress(
poolOption: PollOption,
color: Color,
isWinning: Boolean,
modifier: Modifier,
) {
val progress by poolOption.tally
// Animate the progress bar when a vote is cast
val animatedProgress by animateFloatAsState(
targetValue = poolOption.tally.value,
animationSpec = tween(durationMillis = 800),
)
// The LinearProgressIndicator has some weird update issues and renders inaccurate percentages.
Box(modifier = modifier) {
Box(
modifier =
Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(color = color),
) {
}
}
val progressBarColor = if (isWinning) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.primary
Box(
modifier =
modifier
.alpha(0.32f)
.drawWithContent {
// Clip the drawing area to show only the progress amount
clipRect(right = size.width * animatedProgress) {
drawRect(progressBarColor)
}
drawContent()
},
)
}
@Composable
@@ -475,11 +492,11 @@ private fun RenderOptionBeforeVote(
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = QuoteBorder)
.clip(SmallishBorder)
.border(
2.dp,
MaterialTheme.colorScheme.primary,
QuoteBorder,
width = 1.dp,
color = MaterialTheme.colorScheme.placeholderText,
shape = SmallishBorder,
),
) {
Column(BigPadding) {
@@ -568,7 +585,19 @@ fun ZapVote(
showErrorMessageDialog = StringToastMsg(title, message)
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
onPayViaIntent = {},
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
showErrorMessageDialog = StringToastMsg(stringRes(context, R.string.error_dialog_zap_error), error)
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
)
} else {
wantsToZap = true
@@ -29,7 +29,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -53,7 +53,7 @@ class PollNoteViewModel : ViewModel() {
private lateinit var account: Account
private var pollNote: Note? = null
private var pollEvent: PollNoteEvent? = null
private var pollEvent: ZapPollEvent? = null
private var pollOptions: Map<Int, String>? = null
private var valueMaximum: Long? = null
private var valueMinimum: Long? = null
@@ -76,7 +76,7 @@ class PollNoteViewModel : ViewModel() {
fun load(note: Note?) {
if (pollNote != note) {
pollNote = note
pollEvent = pollNote?.event as PollNoteEvent
pollEvent = pollNote?.event as ZapPollEvent
pollOptions = pollEvent?.pollOptions()
valueMaximum = pollEvent?.maxAmount()
valueMinimum = pollEvent?.minAmount()
@@ -118,13 +118,13 @@ class PollNoteViewModel : ViewModel() {
it.zappedValue.value = zappedValue
it.tally.value = tallyValue.toFloat()
it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!!
it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false
it.zappedByLoggedIn.value = account.userProfile().let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) }
}
}
}
fun checkIfCanZap(): Boolean {
val account = account ?: return false
val account = account
val note = pollNote ?: return false
return account.userProfile() != note.author && !wasZappedByLoggedInAccount
}
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.anonymous
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
@Composable
fun AnonymousPostButton(
isActive: Boolean,
onClick: () -> Unit,
) {
IconButton(
onClick = { onClick() },
) {
if (!isActive) {
Icon(
imageVector = Icons.Default.PersonOff,
contentDescription = stringRes(R.string.post_anonymously),
modifier = Size19Modifier,
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Default.PersonOff,
contentDescription = stringRes(R.string.post_anonymously),
modifier = Size19Modifier,
tint = Color.Red,
)
}
}
}
@@ -59,7 +59,7 @@ fun ContentSensitivityExplainer(
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
.padding(bottom = 5.dp),
) {
Box(
Modifier
@@ -99,7 +99,7 @@ fun ExpirationDatePicker(model: IExpiration) {
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
.padding(bottom = 5.dp),
) {
Icon(
imageVector = Icons.Outlined.Timer,
@@ -107,7 +107,7 @@ fun InvoiceRequest(
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp),
) {
Icon(
imageVector = CustomHashTagIcons.Lightning,
@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
@@ -76,6 +77,8 @@ fun DisplayLocationInTitle(geohash: String) {
text = cityName,
fontSize = 20.sp,
fontWeight = FontWeight.W500,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.padding(start = Size5dp),
)
}
@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.creators.polls
import android.annotation.SuppressLint
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -32,6 +34,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -47,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.utils.RandomInstance
@Composable
@@ -55,6 +59,13 @@ fun PollOptionsField(postViewModel: ShortNotePostViewModel) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
PollTypeSelector(
selectedType = postViewModel.pollType,
onTypeSelected = { postViewModel.pollType = it },
)
Spacer(Modifier.height(4.dp))
optionsList.forEach { option ->
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
@@ -118,6 +129,27 @@ fun PollOptionsField(postViewModel: ShortNotePostViewModel) {
}
}
@Composable
fun PollTypeSelector(
selectedType: PollType,
onTypeSelected: (PollType) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = selectedType == PollType.SINGLE_CHOICE,
onClick = { onTypeSelected(PollType.SINGLE_CHOICE) },
label = { Text(stringRes(R.string.poll_single_choice)) },
)
FilterChip(
selected = selectedType == PollType.MULTI_CHOICE,
onClick = { onTypeSelected(PollType.MULTI_CHOICE) },
label = { Text(stringRes(R.string.poll_multiple_choice)) },
)
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview
@Composable
@@ -59,13 +59,13 @@ fun SecretEmojiRequest(onSuccess: (String) -> Unit) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp),
) {
Icon(
imageVector = Icons.Outlined.Assistant,
null,
modifier = Size20Modifier,
tint = Color.Unspecified,
tint = MaterialTheme.colorScheme.onBackground,
)
Text(
@@ -79,7 +79,7 @@ fun ImageVideoDescription(
uris: MultiOrchestrator,
defaultServer: ServerName,
isUploading: Boolean,
onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit,
onAdd: (String, ServerName, Boolean, Int, Boolean, Boolean) -> Unit,
onDelete: (SelectedMediaProcessing) -> Unit,
onCancel: () -> Unit,
accountViewModel: AccountViewModel,
@@ -108,6 +108,8 @@ fun ImageVideoDescription(
// Codec selection: false = H264, true = H265
var useH265Codec by remember { mutableStateOf(false) }
var stripMetadata by remember { mutableStateOf(accountViewModel.account.settings.stripLocationOnUpload) }
Column(
modifier =
Modifier
@@ -248,36 +250,52 @@ fun ImageVideoDescription(
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.padding(vertical = 8.dp),
) {
Column(
modifier = Modifier.weight(1.0f),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.media_compression_quality_label),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.media_compression_quality_explainer),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
// Hide privacy toggle when video compression is selected (compression already strips metadata)
val isVideoWithCompression =
uris.first().media.isVideo() == true && mediaQualitySlider != 3
if (!isVideoWithCompression) {
SettingSwitchItem(
title = R.string.strip_metadata_label,
description = R.string.strip_metadata_description,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp),
checked = stripMetadata,
onCheckedChange = { stripMetadata = it },
)
}
val firstMedia = uris.first().media
if (firstMedia.isVideo() == true || firstMedia.isImage() == true || firstMedia.isAudio() == true) {
if (firstMedia.isVideo() == true || firstMedia.isImage() == true) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.padding(vertical = 8.dp),
) {
Column(
modifier = Modifier.weight(1.0f),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.media_compression_quality_label),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.media_compression_quality_explainer),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
@@ -302,7 +320,7 @@ fun ImageVideoDescription(
}
}
if (uris.first().media.isVideo() == true) {
if (uris.first().media.isVideo() == true && mediaQualitySlider != 3) {
SettingSwitchItem(
title = R.string.video_codec_h265_label,
description = R.string.video_codec_h265_description,
@@ -321,7 +339,10 @@ fun ImageVideoDescription(
.fillMaxWidth()
.padding(vertical = 10.dp),
enabled = !isUploading,
onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) },
onClick = {
val effectiveStripMetadata = if (isVideoWithCompression) false else stripMetadata
onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec, effectiveStripMetadata)
},
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(
@@ -30,23 +30,33 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.WatchAndDisplayNip05Row
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.nip05
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -145,3 +155,51 @@ fun UserLine(
},
)
}
@Composable
private fun WatchAndDisplayNip05Row(
user: User,
accountViewModel: AccountViewModel,
) {
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
when (val nip05State = nip05StateMetadata) {
is Nip05State.Exists -> {
NonClickableObserveAndDisplayNIP05(nip05State, accountViewModel)
}
else -> {
Text(
text = user.pubkeyDisplayHex(),
fontSize = Font14SP,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun NonClickableObserveAndDisplayNIP05(
nip05State: Nip05State.Exists,
accountViewModel: AccountViewModel,
) {
if (nip05State.nip05.name != "_") {
Text(
text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) },
fontSize = Font14SP,
color = MaterialTheme.colorScheme.nip05,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ObserveAndRenderNIP05VerifiedSymbol(nip05State, 1, NIP05IconSize, accountViewModel)
Text(
text = nip05State.nip05.domain,
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP),
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
@@ -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())
}
}
@@ -54,7 +54,7 @@ fun ZapRaiserRequest(
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp),
) {
Icon(
imageVector = CustomHashTagIcons.Lightning,

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