Compare commits

..
88 Commits
Author SHA1 Message Date
Vitor Pamplona 1f2e24bc1f v1.06.2 2026-03-24 19:36:30 -04:00
Vitor Pamplona cfd6577029 no message 2026-03-24 19:36:19 -04:00
Vitor Pamplona 976de28ece Fixes routes for AppDefinition events. 2026-03-24 19:25:01 -04:00
Vitor Pamplona bc9b6dc912 fixes client tags 2026-03-24 19:24:33 -04:00
Vitor PamplonaandGitHub c6fd65dbac Merge pull request #1935 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-24 18:35:47 -04:00
Crowdin Bot b3f190410f New Crowdin translations by GitHub Action 2026-03-24 22:33:27 +00:00
Vitor Pamplona 6f7c550034 Clears some inconsistencies in translations 2026-03-24 18:31:10 -04:00
Vitor Pamplona fb976d168a Simplifies report feed in the user profile 2026-03-24 18:30:52 -04:00
Vitor PamplonaandGitHub be4a6d4fc4 Merge pull request #1932 from vitorpamplona/claude/add-anonymous-reply-dkdSJ
feat: activate anonymous reply by tapping user picture
2026-03-24 17:55:46 -04:00
Claude d90c868f32 feat: activate anonymous reply by tapping user picture instead of bottom button
Replace the bottom bar AnonymousPostButton with a tap-on-avatar activation scheme.
Tapping the user picture enables anonymous mode and shows the incognito icon in its
place. Tapping the incognito icon toggles back to normal mode.

https://claude.ai/code/session_013MaeT2T9Bg4HaVe2ACNADy
2026-03-24 21:49:15 +00:00
Vitor Pamplona e4f55c11f1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  perf: move cache lookups from NavHost route lambdas into screen composables
2026-03-24 17:45:46 -04:00
Vitor PamplonaandGitHub 71242e50d2 Merge pull request #1933 from vitorpamplona/claude/improve-navhost-performance-AE9WZ
perf: move cache lookups from NavHost route lambdas into screen composables
2026-03-24 17:42:34 -04:00
Vitor Pamplona 5d0f9ac866 Cleaning up residue from removing the need for rfc 3986 and smpForKmp 2026-03-24 17:41:20 -04:00
Vitor Pamplona ad9596ab92 Fixes flow value and improves tor binding lifecycle. 2026-03-24 17:32:24 -04:00
Vitor Pamplona d847c06979 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-03-24 17:23:31 -04:00
Vitor Pamplona 0d8b4e8baa merge 2026-03-24 17:21:52 -04:00
Vitor PamplonaandGitHub 70d034e50c Merge pull request #1930 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-24 17:20:31 -04:00
Vitor Pamplona f11172216d Improvements to the status of attestations. Validity first, then Processing status. 2026-03-24 17:20:11 -04:00
Claude 2ef8be8fdc perf: move cache lookups from NavHost route lambdas into screen composables
Route lambdas in NavHost run during navigation composition and should be
kept lightweight. Cache lookups (getNoteIfExists, checkGetOrCreateNote,
getOrCreateAddressableNote) and object construction (RoomId,
RelayUrlNormalizer) are now deferred to the screen composables themselves,
either via remember {} or inside LaunchedEffect blocks.

Affected screens: PublicChatChannelScreen, LiveActivityChannelScreen,
EphemeralChatScreen, GeoHashPostScreen, HashtagPostScreen,
ReplyCommentPostScreen, NewProductScreen, LongFormPostScreen,
ShortNotePostScreen, NewPublicMessageScreen,
ImportFollowListPickFollowsScreen.

https://claude.ai/code/session_01NrVHL4zdCghQvqE8xmi4Gf
2026-03-24 21:18:40 +00:00
Crowdin Bot cc850c5b7f New Crowdin translations by GitHub Action 2026-03-24 21:17:34 +00:00
Vitor PamplonaandGitHub 728ff9a4cd Merge pull request #1926 from vitorpamplona/claude/reply-click-navigation-7lKuO
feat: scroll to replied message when clicking reply preview in chat
2026-03-24 17:16:16 -04:00
Vitor PamplonaandGitHub 6f9fc23a64 Merge pull request #1929 from vitorpamplona/claude/relay-info-dialog-uuE8W
feat: make profile relay rows open relay info dialog
2026-03-24 17:16:01 -04:00
Claude bcdaf8630c feat: make relay rows clickable to open relay info dialog on profile page
Adds onClick parameter to RelayCompose and navigates to RelayInformationScreen
when a relay row is tapped in the profile relay tab.

https://claude.ai/code/session_01PBFQz53xzXoFsEHD5Ghyqr
2026-03-24 20:34:01 +00:00
Vitor Pamplona 2d25bb2592 Sorts followers into a set to avoid LazyColumn key conflicts. 2026-03-24 14:23:21 -04:00
Vitor PamplonaandGitHub 405d1d0519 Merge pull request #1928 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-24 13:42:23 -04:00
Crowdin Bot 88042f06d4 New Crowdin translations by GitHub Action 2026-03-24 17:40:05 +00:00
Vitor PamplonaandGitHub 8437a819f8 Merge pull request #1909 from KotlinGeekDev/kmp-completeness
Kmp completeness, part 3 - adios, Swift bridge.
2026-03-24 13:38:44 -04:00
Vitor Pamplona cef07a2460 Fixes single label domains with non ascii chars around them 2026-03-24 13:33:14 -04:00
Vitor Pamplona 2a2c61973e Avoids crashing the app in weird urls. 2026-03-24 13:30:36 -04:00
KotlinGeekDev 349781d9be Use uri-reference-kmp in commonMain. Remove platform-specific implementations. 2026-03-24 16:34:13 +01:00
KotlinGeekDev 334aabbf02 Merge branch 'upstream-main' into kmp-completeness 2026-03-24 16:12:14 +01:00
KotlinGeekDev 2933454fb6 Merge branch 'main' of https://github.com/vitorpamplona/amethyst into upstream-main
# Conflicts:
#	quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt
2026-03-24 16:11:04 +01:00
Vitor Pamplona 09054d6ac5 Fixes pool rendering when one label is large and the other is small 2026-03-24 10:58:59 -04:00
Claude 4d8be5e272 feat: scroll to replied message when clicking reply preview in chat
When a user clicks on the reply preview (inner quote) in a chat message,
the chat feed now scrolls to the original replied-to message in the same
screen instead of doing nothing.

https://claude.ai/code/session_01UF4VxoWGvvvwtLYBLBYw9w
2026-03-24 14:51:39 +00:00
Claude 0ad3180fc5 feat: add anonymous reply with throwaway keypair
Allow users to reply to notes using a fresh, randomly generated keypair
that is not linked to their account. When the "Anonymous" toggle is
activated in the compose screen, the reply is signed with a new
throwaway identity and broadcast to the user's outbox relays without
being cached as the user's own event.

- Add AnonymousPostButton composable (PersonOff icon toggle)
- Add wantsAnonymousPost state to ShortNotePostViewModel
- Add signAnonymouslyAndBroadcast to Account (creates temp KeyPair)
- Show warning banner and hide user avatar when anonymous mode is on
- Add string resources for anonymous post UI

https://claude.ai/code/session_013MaeT2T9Bg4HaVe2ACNADy
2026-03-24 14:25:25 +00:00
Vitor PamplonaandGitHub c913c0e1ab Merge pull request #1921 from vitorpamplona/claude/hide-video-controls-k09Ga
Hide video controls on playback start
2026-03-24 10:20:55 -04:00
Vitor PamplonaandGitHub 30ff9ab09b Merge pull request #1918 from vitorpamplona/claude/draggable-video-progress-CQODi
feat: add drag-to-seek on video progress indicator
2026-03-24 10:12:31 -04:00
Vitor PamplonaandGitHub 159f1a121b Merge pull request #1916 from vitorpamplona/claude/add-multiple-choice-polls-CQUAv
feat: add multiple choice poll type selector
2026-03-24 10:07:33 -04:00
Vitor Pamplona 9b5b50f237 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  code review: - Removed redundant equality guards in SideEffect — MutableState already suppresses no-op writes for value types - Changed mutableStateOf({}) to mutableStateOf(null) with explicit nullable type — clearer intent, no accidental no-op invocation -  stopRecording() now early-returns with ?: return when not recording, so the Toast only shows for genuinely failed recordings (too short)
  entire solid recording indicator bar stops recording when tapped, not just the small stop icon.
  New Crowdin translations by GitHub Action
  update skill
  update translations: CZ, DE, PT, SE
2026-03-24 10:00:10 -04:00
Vitor Pamplona 898e6ac677 Added credits 2026-03-24 09:41:23 -04:00
Vitor Pamplona d6d4bdc5e3 avoids duplicating quote tags by key + value 2026-03-24 09:40:02 -04:00
Vitor Pamplona c39eca5172 Add new utility methods 2026-03-24 09:39:09 -04:00
Vitor Pamplona 3d9efa9938 Tries to solve a duplicate user in a lazy column bug 2026-03-24 09:33:50 -04:00
Vitor Pamplona 11dabd46ba Checks if p tags are user pubkeys when loading drafts. 2026-03-24 09:33:37 -04:00
Vitor Pamplona 94e708b5fc Reduces the need to compute naddr to index articles in bookmarks 2026-03-24 09:32:38 -04:00
Vitor Pamplona 8d81d6d57c Removes the clickable NIP-05 url in the @ tagging of users 2026-03-24 09:30:40 -04:00
Vitor Pamplona 8c4b2fe62b Makes sure quotes only happen once in events. 2026-03-24 09:30:07 -04:00
Vitor Pamplona 514e90ff17 Fixes non http uris in the references tag 2026-03-24 09:13:48 -04:00
KotlinGeekDevandGitHub 4c14554263 Merge branch 'vitorpamplona:main' into kmp-completeness 2026-03-24 13:27:40 +01:00
David KasparandGitHub 22142d17f9 Merge pull request #1924 from davotoula/larger-stop-area-for-voice-recording
Larger stop area for voice recording
2026-03-24 10:59:41 +01:00
davotoula e976bf9e2e code review:
- Removed redundant equality guards in SideEffect — MutableState already suppresses no-op writes for value types
- Changed mutableStateOf({}) to mutableStateOf(null) with explicit nullable type — clearer intent, no accidental no-op invocation
-  stopRecording() now early-returns with ?: return when not recording, so the Toast only shows for genuinely failed recordings (too short)
2026-03-24 09:25:07 +01:00
davotoula 4e43b14b09 entire solid recording indicator bar stops recording when tapped, not just the small stop icon. 2026-03-24 09:25:07 +01:00
David KasparandGitHub 6f3308e45c Merge pull request #1923 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-24 09:00:06 +01:00
Crowdin Bot 1dbe552821 New Crowdin translations by GitHub Action 2026-03-24 07:35:59 +00:00
davotoula 6436668808 update skill 2026-03-24 08:32:56 +01:00
davotoula 346a7ecac2 update translations: CZ, DE, PT, SE 2026-03-24 08:27:32 +01:00
Claude c67d66981c feat: hide video controls on playback start
Video controls are now hidden by default when playback begins.
Users can tap to reveal them. Previously, controls were shown for
2 seconds before auto-hiding (TwoSecondController) or shown by
default (VideoViewInner).

https://claude.ai/code/session_01WpAqbodB5w8oDJpN6MLBs6
2026-03-24 06:15:11 +00:00
Claude 6e0e8cf00f feat: add drag-to-seek support on video progress indicator
The progress scrubber can now be dragged to seek through the video,
in addition to the existing tap-to-seek. During drag, the scrubber
enlarges for visual feedback and tracks the finger position in
real-time. Seeking is applied on drag end.

https://claude.ai/code/session_01QvYAvNdC35zGgkdpnujFn2
2026-03-24 04:01:27 +00:00
Claude 8525f2b930 feat: add poll type selector for single and multiple choice polls
The poll creation UI now shows filter chips to choose between
single choice and multiple choice poll types. Previously it was
hardcoded to single choice only.

https://claude.ai/code/session_01PEsEyGiNvNcNPSjREN4WzW
2026-03-24 02:50:46 +00:00
Vitor Pamplona 7874893938 v1.06.1 2026-03-23 18:19:42 -04:00
Vitor Pamplona 51fa28f22a no message 2026-03-23 18:16:24 -04:00
Vitor Pamplona ec4f949782 Leaves some space to click when their is no space or new lines on the poll. 2026-03-23 18:15:36 -04:00
Vitor Pamplona 4c9f2de6f7 - Improves rendering of the completed polls
- Doesn't render quoted polls
2026-03-23 18:01:41 -04:00
Vitor Pamplona e5aeb49a4b Solves some of the crashes of concurrent modification exception 2026-03-23 17:59:14 -04:00
Vitor PamplonaandGitHub da84848df7 Merge pull request #1911 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-23 17:46:06 -04:00
Crowdin Bot 3d488165c3 New Crowdin translations by GitHub Action 2026-03-23 21:40:20 +00:00
Vitor PamplonaandGitHub f99543dc3b Merge pull request #1913 from greenart7c3/claude/add-url-crash-tests-J8Omf
Claude/add url crash tests j8 omf
2026-03-23 17:38:51 -04:00
Claude 7ea5557828 test: fix UrlTest to use 今北産業 as the crash input (PR #1907)
https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2
2026-03-23 21:02:41 +00:00
Claude 3a93c4b075 test: add UrlTest for getPart() boundary conditions fixed in PR #1907
Tests the StringIndexOutOfBoundsException fix in Url.getPart() by directly
constructing Url objects with UrlMarker indices that exceed the trimmed
originalUrl length — the exact scenario that occurs when readEnd() strips
a trailing character (e.g. ':') while the PORT/QUERY marker still points
to the original buffer position.

https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2
2026-03-23 20:57:49 +00:00
Claude 9ebc4a1e67 style: remove trailing blank line in UrlMarkerTest
https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2
2026-03-23 20:56:58 +00:00
Claude 9202b60dcf test: add regression tests for 今北産業 URL crash (PR #1907)
Verifies that the Japanese phrase "今北産業" does not cause a
StringIndexOutOfBoundsException in Url.getPart() and is not
detected as a URL.

https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2
2026-03-23 20:46:59 +00:00
Claude e31e3829a6 test: add regression tests for PR #1907 URL crash fix
Tests cover the StringIndexOutOfBoundsException in Url.getPart() that
occurred when readEnd() trimmed trailing characters (e.g. ':') from a
detected URL but urlMarker indices remained pointing past the trimmed
string's end.

- UrlMarkerTest: three cases testing PORT/QUERY index at or beyond
  string length (the boundary conditions fixed by minOf clamping and
  the startIndex >= length guard)
- UrlsDetectorTest: regression for "今北産業" (no crash, 0 URLs) and
  for a bare "example.com:" host
- UrlParserTest: end-to-end regression for "今北産業" and "example.com:"

https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2
2026-03-23 20:31:28 +00:00
Vitor Pamplona f553489ddf Improves the wording of the Last Seen 2026-03-23 15:51:35 -04:00
Vitor PamplonaandGitHub 2b920fc47a Merge pull request #1907 from greenart7c3/claude/fix-android-runtime-exception-auFbS
Fix URL parsing bounds checking in Url.kt
2026-03-23 15:51:07 -04:00
KotlinGeekDev ac38a73b5e spotless apply. 2026-03-23 20:27:04 +01:00
KotlinGeekDev 4672e6b587 Merge remote-tracking branch 'origin/kmp-completeness' into kmp-completeness 2026-03-23 19:55:49 +01:00
KotlinGeekDev bddff36e6b androidLibrary {} -> android {} 2026-03-23 19:55:35 +01:00
KotlinGeekDevandGitHub 03c00b9fba Merge branch 'vitorpamplona:main' into kmp-completeness 2026-03-23 19:52:22 +01:00
KotlinGeekDev b495f3f409 NIP19 tests for iOS. 2026-03-23 19:50:26 +01:00
KotlinGeekDev cfecfe2130 Move uri-reference-kmp to version catalog. Remove swift bridge(was a nice run). 2026-03-23 19:49:24 +01:00
KotlinGeekDev f2bdb24ee5 Removal of references to APIs using the Swift bridge. 2026-03-23 19:38:10 +01:00
KotlinGeekDev a9c41e1d55 Introduce uri-reference-kmp and replace uriBridge usages with it. 2026-03-23 19:31:58 +01:00
Vitor Pamplona 306d76f793 Tries to fix
IllegalArgumentException: 1.06.0-PLAY

| Prop | Value |
|------|-------|
| Manuf |Google |
| Model |Pixel 8a |
| Prod |akita |
| Android |16 |
| SDK Int |36 |
| Brand |google |
| Hardware |akita |
| Device | akita |
| Host | r-0123456789abcdef-0123 |
| User | android-user |

```
java.lang.IllegalArgumentException: Cannot disable reuse from root if it was caused by other groups
    androidx.compose.runtime.PreconditionsKt.throwIllegalArgumentException(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:3)
    androidx.compose.runtime.ComposerImpl.endReuseFromRoot(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:19)
    androidx.compose.runtime.PausedCompositionImpl.resume(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:369)
    androidx.compose.ui.layout.LayoutNodeSubcompositionsState$precomposePaused$2.resume(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:50)
    androidx.compose.foundation.lazy.layout.PrefetchHandleProvider$HandleAndRequestImpl.performPausableComposition(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:49)
    androidx.compose.foundation.lazy.layout.PrefetchHandleProvider$HandleAndRequestImpl.executeRequest(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:128)
    androidx.compose.foundation.lazy.layout.PrefetchHandleProvider$HandleAndRequestImpl.execute(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:33)
    androidx.compose.foundation.lazy.layout.AndroidPrefetchScheduler.runRequest(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:36)
    androidx.compose.foundation.lazy.layout.AndroidPrefetchScheduler.run(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:113)
    android.os.Handler.handleCallback(Handler.java:1070)
    android.os.Handler.dispatchMessage(Handler.java:125)
    android.os.Looper.dispatchMessage(Looper.java:333)
    android.os.Looper.loopOnce(Looper.java:263)
    android.os.Looper.loop(Looper.java:367)
    android.app.ActivityThread.main(ActivityThread.java:9331)
    java.lang.reflect.Method.invoke(Native Method)
    com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:566)
    com.android.internal.os.ZygoteInit.main(ZygoteInit.java:837)
```
2026-03-23 13:47:00 -04:00
Vitor Pamplona eeca86e542 Fixes comparator to avoid Comparison method violates its general contract! 2026-03-23 13:16:32 -04:00
Claude 951610d9ca fix: prevent StringIndexOutOfBoundsException in Url.getPart()
When UrlDetector.readEnd() trims the last character from URLs ending
with characters like '.', ',', etc., the urlMarker indices remain
based on the original buffer length. This caused getPart() to call
substring() with an end index beyond the trimmed URL's length.

Fix by clamping the end index to originalUrl.length and guarding
against an out-of-bounds start index.

https://claude.ai/code/session_014Q2SvTE5vKgUm1w8dPVjYo
2026-03-23 17:10:56 +00:00
Vitor Pamplona 590bd21f62 UriParser might crash, wraps in a try catch 2026-03-23 13:03:43 -04:00
Vitor Pamplona 81380a32ae Fixes POCO phone's removal of the + sign in NWC 2026-03-23 13:03:18 -04:00
Vitor Pamplona 943f1256a7 Improves zap-store settings 2026-03-23 12:41:30 -04:00
98 changed files with 1319 additions and 700 deletions
+2 -2
View File
@@ -745,8 +745,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
versionCode = 433
versionName = "1.06.0"
versionCode = 434
versionName = "1.06.2"
vectorDrawables {
useSupportLibrary = true
@@ -7,7 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans
## Overview
Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs a table ready for translation.
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
@@ -15,6 +15,17 @@ Extract string resource keys from the default `values/strings.xml` that are abse
- 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
@@ -24,11 +35,9 @@ Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml
```
Default locale: `cs-rCZ` if none specified. User may override (e.g., `pt-rBR`, `ja`).
### 2. Find missing keys using cs-rCZ as reference
### 2. Extract and diff keys
Use a single bash pipeline to extract translatable keys from both files and diff them:
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")
@@ -36,11 +45,11 @@ comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-<LOCALE>/strings.xml \
<(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.
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
@@ -54,13 +63,13 @@ done < <(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-<LOCALE>/strings.xml \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
```
### 4. Present results
### 4. Present results and ask to translate
Output the missing entries as raw XML resource lines (copy-paste ready for the locale file):
Output the missing entries as raw XML resource lines (copy-paste ready):
```xml
<string name="attestation_valid">Valid</string>
@@ -70,8 +79,18 @@ Output the missing entries as raw XML resource lines (copy-paste ready for the l
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
- **Modifying files**this is a read-only research task unless the user asks to add entries
- **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
+3 -3
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.06.0` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.2` (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.06.0"
quartz = "1.06.2"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.0")
implementation("com.vitorpamplona.quartz:quartz:1.06.2")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.06.0
com.vitorpamplona.quartz:quartz:1.06.2
```
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.06.0"
quartz = "1.06.2"
[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.06.0")
implementation("com.vitorpamplona.quartz:quartz:1.06.2")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.0")
implementation("com.vitorpamplona.quartz:quartz:1.06.2")
// 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.06.0
com.vitorpamplona.quartz:quartz:1.06.2
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
+1
View File
@@ -268,6 +268,7 @@ Updated translations:
- 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
+2 -2
View File
@@ -54,8 +54,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 433
versionName = generateVersionName("1.06.0")
versionCode = 434
versionName = generateVersionName("1.06.2")
buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -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
@@ -1292,6 +1293,21 @@ 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)
val relayList = nip65RelayList.outboxFlow.value.toSet()
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.
@@ -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),
@@ -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),
)
}
@@ -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(
@@ -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
}
}
@@ -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
@@ -124,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.
@@ -224,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)
}
@@ -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,
@@ -130,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
@@ -209,11 +206,7 @@ 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) }
@@ -238,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) }
@@ -280,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,
)
@@ -291,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,
)
@@ -303,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,
)
@@ -313,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,
)
@@ -327,8 +313,8 @@ 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,
)
@@ -336,8 +322,8 @@ fun AppNavigation(
composableFromBottomArgs<Route.NewLongFormPost> {
LongFormPostScreen(
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draftId = it.draft,
versionId = it.version,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -347,11 +333,11 @@ fun AppNavigation(
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,
)
@@ -86,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 -> {
@@ -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(
@@ -45,9 +45,14 @@ 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
@@ -60,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()) {
@@ -69,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)
}
}
@@ -18,19 +18,41 @@
* 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.profile.reports
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 com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReports
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
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 WatchReportsAndUpdateFeed(
baseUser: User,
feedViewModel: UserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
fun AnonymousPostButton(
isActive: Boolean,
onClick: () -> Unit,
) {
observeUserReports(baseUser, accountViewModel) { feedViewModel.invalidateData() }
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,
)
}
}
}
@@ -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
@@ -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,
)
}
@@ -47,7 +47,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
@@ -87,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -94,11 +94,11 @@ import kotlinx.coroutines.withContext
@Composable
fun ReplyCommentPostScreen(
reply: Note? = null,
replyId: HexKey? = null,
message: String? = null,
attachment: Uri? = null,
quote: Note? = null,
draft: Note? = null,
quoteId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -108,13 +108,13 @@ fun ReplyCommentPostScreen(
val context = LocalContext.current
LaunchedEffect(postViewModel, accountViewModel) {
reply?.let {
replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it)
}
draft?.let {
draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it)
}
quote?.let {
quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
@@ -34,13 +34,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Approval
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.RemoveDone
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -591,13 +595,13 @@ private fun attestationColor(
validity: Validity?,
): Color =
when {
status == AttestationStatus.REVOKED -> Color(0xFFB71C1C)
status == AttestationStatus.REJECTED -> Color(0xFFB71C1C)
validity == Validity.INVALID -> Color(0xFFB71C1C)
status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32)
validity == Validity.VALID -> Color(0xFF2E7D32)
status == AttestationStatus.VERIFYING -> Color(0xFFF57F17)
status == AttestationStatus.ACCEPTED -> Color(0xFF1565C0)
status == AttestationStatus.REVOKED -> Color(0xFFB21CB7)
status == AttestationStatus.REJECTED -> Color(0xFFB71C1C)
status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32)
status == AttestationStatus.VERIFYING -> Color(0xFF173CF5)
status == AttestationStatus.ACCEPTED -> Color(0xFF15C0C0)
else -> Color(0xFF757575)
}
@@ -606,14 +610,14 @@ private fun attestationIcon(
validity: Validity?,
): ImageVector =
when {
status == AttestationStatus.REVOKED -> Icons.Default.Close
status == AttestationStatus.REJECTED -> Icons.Default.Close
validity == Validity.INVALID -> Icons.Default.Close
status == AttestationStatus.VERIFIED -> Icons.Default.VerifiedUser
validity == Validity.VALID -> Icons.Default.CheckCircle
status == AttestationStatus.REVOKED -> Icons.Default.RemoveDone
status == AttestationStatus.REJECTED -> Icons.Default.Delete
status == AttestationStatus.VERIFIED -> Icons.Default.Approval
status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop
status == AttestationStatus.ACCEPTED -> Icons.Default.CheckCircle
else -> Icons.Default.VerifiedUser
status == AttestationStatus.ACCEPTED -> Icons.Default.HourglassEmpty
else -> Icons.Default.ErrorOutline
}
@Composable
@@ -622,11 +626,11 @@ private fun attestationStatusLabel(
validity: Validity?,
): String =
when {
validity == Validity.INVALID -> stringRes(R.string.attestation_invalid)
validity == Validity.VALID -> stringRes(R.string.attestation_valid)
status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked)
status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected)
validity == Validity.INVALID -> stringRes(R.string.attestation_invalid)
status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified)
validity == Validity.VALID -> stringRes(R.string.attestation_valid)
status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying)
status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted)
else -> stringRes(R.string.attestation)
@@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
@@ -56,7 +57,13 @@ 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.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -82,11 +89,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BigPadding
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
@@ -178,7 +184,7 @@ fun InnerRenderPoll(
TranslatableRichTextViewer(
content = label,
canPreview = canPreview,
quotesLeft = 1,
quotesLeft = if (quotesLeft > 0) 1 else 0,
modifier = Modifier.fillMaxWidth(),
tags = tags,
backgroundColor = backgroundColor,
@@ -328,10 +334,13 @@ private fun ColumnScope.RenderSingleChoiceOptions(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val hasSpaceToClick =
remember {
it.label.contains(' ') || it.label.contains('\n')
}
Column(
modifier =
Modifier
.fillMaxWidth(),
modifier = if (hasSpaceToClick) Modifier.fillMaxWidth() else Modifier.fillMaxWidth(0.9f),
) {
labelContent(it.code, it.label)
}
@@ -403,8 +412,15 @@ private fun RenderResults(
resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit),
) {
val showGallery =
remember {
card.options.all {
it.label.length < 50
}
}
card.options.forEach { pollItem ->
RenderClosedItem(pollItem, resultContent) {
RenderClosedItem(pollItem, showGallery, resultContent) {
labelContent(pollItem.code, pollItem.label)
}
}
@@ -413,17 +429,20 @@ private fun RenderResults(
@Composable
private fun RenderClosedItem(
item: PollItemCard,
showGallery: Boolean,
resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable ColumnScope.() -> Unit,
) {
val tally by item.results.collectAsStateWithLifecycle(item.currentResults())
RenderClosedItem(tally, resultContent, labelContent)
RenderClosedItem(tally, item.label, showGallery, resultContent, labelContent)
}
@Composable
private fun RenderClosedItem(
tally: TallyResults,
label: String,
showGallery: Boolean,
resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable ColumnScope.() -> Unit,
) {
@@ -481,18 +500,21 @@ private fun RenderClosedItem(
content = labelContent,
)
Spacer(StdHorzSpacer)
Spacer(DoubleHorzSpacer)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy10dp,
) {
UserGallery(tally, resultContent)
if (showGallery) {
UserGallery(tally, resultContent)
}
Text(
text = "${(tally.percent * 100).toInt()}%",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.End,
modifier = measure100PercentWidthModifier(MaterialTheme.typography.bodyMedium),
maxLines = 1,
)
}
@@ -500,6 +522,24 @@ private fun RenderClosedItem(
}
}
@Composable
fun measure100PercentWidthModifier(textStyle: TextStyle): Modifier {
val fontFamilyResolver = LocalFontFamilyResolver.current
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
return remember(fontFamilyResolver, density, textStyle) {
val widthPx =
TextMeasurer(fontFamilyResolver, density, layoutDirection, 1)
.measure("100%", style = textStyle.copy(fontWeight = FontWeight.Bold))
.size
.width
with(density) {
Modifier.width(widthPx.toDp())
}
}
}
@Composable
fun UserGallery(
tally: TallyResults,
@@ -510,13 +550,13 @@ fun UserGallery(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy((-10).dp),
) {
tally.users.take(6).forEach {
tally.users.take(4).forEach {
key(it.pubkeyHex) {
galleryUser(it)
}
}
if (tally.users.size > 6) {
if (tally.users.size > 4) {
Box(
contentAlignment = Alignment.Center,
modifier =
@@ -526,7 +566,7 @@ fun UserGallery(
.background(MaterialTheme.colorScheme.secondaryContainer),
) {
Text(
text = "+" + showCount(tally.users.size - 6),
text = "+" + showCount(tally.users.size - 4),
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -584,6 +624,54 @@ fun RenderPollManualPreview() {
}
}
@Preview
@Composable
fun RenderPollManualLongPreview() {
val poll =
PollCard(
options =
listOf(
PollItemCard(
code = "1",
label = "Yes".repeat(300),
results = flow {},
currentResults = {
TallyResults(
percent = 1.0f,
isWinning = true,
)
},
),
PollItemCard(
code = "2",
label = "No".repeat(300),
results = flow {},
currentResults = {
TallyResults(
percent = 0.0f,
isWinning = false,
)
},
),
),
type = PollType.SINGLE_CHOICE,
endsAt = null,
isMyPoll = true,
haveIVotedFlow = flow {},
haveIVoted = { true },
)
ThemeComparisonColumn {
Column(Modifier.padding(10.dp)) {
RenderPollCard(poll, {}, {}) { _, label ->
Text(
text = label,
)
}
}
}
}
@SuppressLint("StateFlowValueCalledInComposition")
@Preview
@Composable
@@ -106,7 +106,7 @@ fun ArticleList(
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(articles, key = { _, item -> item.toNAddr() }) { _, item ->
itemsIndexed(articles, key = { _, item -> item.address }) { _, item ->
NoteCompose(
baseNote = item,
modifier = Modifier.animateContentSize(),
@@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
@@ -43,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import kotlinx.coroutines.launch
@Composable
fun RefreshingChatroomFeedView(
@@ -131,13 +133,23 @@ fun ChatFeedLoaded(
}
}
val scope = rememberCoroutineScope()
val onScrollToNote: (Note) -> Unit = { note ->
val index = items.list.indexOfFirst { it.idHex == note.idHex }
if (index >= 0) {
scope.launch {
listState.animateScrollToItem(index)
}
}
}
LazyColumn(
contentPadding = FeedPadding,
modifier = Modifier.fillMaxSize(),
reverseLayout = true,
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { index, item ->
itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { index, item ->
val noteEvent = item.event
if (avoidDraft == null || noteEvent !is DraftWrapEvent || noteEvent.dTag() !in avoidDraft.usedDraftTags) {
ChatroomMessageCompose(
@@ -147,6 +159,7 @@ fun ChatFeedLoaded(
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
)
NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item)
@@ -95,6 +95,7 @@ fun ChatroomMessageCompose(
nav: INav,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) {
WatchBlockAndReport(
@@ -114,6 +115,7 @@ fun ChatroomMessageCompose(
nav,
onWantsToReply,
onWantsToEditDraft,
onScrollToNote,
)
}
}
@@ -130,6 +132,7 @@ fun NormalChatNote(
nav: INav,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
) {
val isLoggedInUser =
remember(note.author) {
@@ -171,6 +174,9 @@ fun NormalChatNote(
if (note.event is ChannelCreateEvent) {
nav.nav(Route.PublicChatChannel(note.idHex))
true
} else if (innerQuote && onScrollToNote != null) {
onScrollToNote(note)
true
} else {
false
}
@@ -253,6 +259,7 @@ fun NormalChatNote(
canPreview,
accountViewModel,
nav,
onScrollToNote,
)
}
}
@@ -288,6 +295,7 @@ private fun MessageBubbleLines(
canPreview: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
onScrollToNote: ((Note) -> Unit)? = null,
) {
if (baseNote.event !is DraftWrapEvent) {
RenderReplyRow(
@@ -298,6 +306,7 @@ private fun MessageBubbleLines(
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
)
}
@@ -334,9 +343,10 @@ fun RenderReplyRow(
nav: INav,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
) {
if (!innerQuote && note.replyTo?.lastOrNull() != null) {
RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft)
RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft, onScrollToNote)
}
}
@@ -348,6 +358,7 @@ private fun RenderReply(
nav: INav,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
@Suppress("ProduceStateDoesNotAssignValue")
@@ -368,6 +379,7 @@ private fun RenderReply(
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
)
}
}
@@ -23,22 +23,30 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephem
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@Composable
fun EphemeralChatScreen(
channelId: RoomId,
draft: Note? = null,
replyTo: Note? = null,
id: HexKey,
relayUrl: String,
draftId: HexKey? = null,
replyToId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val channelId = remember(id, relay) { RoomId(id, relay) }
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
@@ -35,13 +35,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Composable
fun PublicChatChannelScreen(
channelId: HexKey?,
draft: Note?,
replyTo: Note? = null,
draftId: HexKey? = null,
replyToId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
@@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -33,17 +33,21 @@ import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Composable
fun LiveActivityChannelScreen(
channelId: Address?,
draft: Note? = null,
replyTo: Note? = null,
draftId: HexKey? = null,
replyToId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
@@ -61,7 +61,6 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
@@ -98,12 +97,13 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongFormPostScreen(
draft: Note? = null,
version: Note? = null,
draftId: HexKey? = null,
versionId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -111,6 +111,8 @@ fun LongFormPostScreen(
postViewModel.init(accountViewModel)
LaunchedEffect(postViewModel, accountViewModel) {
val draft = draftId?.let { accountViewModel.getNoteIfExists(it) }
val version = versionId?.let { accountViewModel.getNoteIfExists(it) }
postViewModel.load(draft, version)
}
@@ -46,7 +46,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
@@ -83,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -93,8 +93,8 @@ import kotlinx.coroutines.withContext
fun NewProductScreen(
message: String? = null,
attachment: Uri? = null,
quote: Note? = null,
draft: Note? = null,
quoteId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -105,10 +105,10 @@ fun NewProductScreen(
LaunchedEffect(postViewModel, accountViewModel) {
postViewModel.reloadRelaySet()
draft?.let {
draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it)
}
quote?.let {
quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
@@ -26,12 +26,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel
import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -42,9 +42,9 @@ fun GeoHashPostScreen(
geohash: String? = null,
message: String? = null,
attachment: Uri? = null,
reply: Note? = null,
quote: Note? = null,
draft: Note? = null,
replyId: HexKey? = null,
quoteId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -57,13 +57,13 @@ fun GeoHashPostScreen(
geohash?.let {
postViewModel.newPostFor(GeohashId(it))
}
reply?.let {
replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it)
}
draft?.let {
draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it)
}
quote?.let {
quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
@@ -26,12 +26,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel
import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -42,9 +42,9 @@ fun HashtagPostScreen(
hashtag: String? = null,
message: String? = null,
attachment: Uri? = null,
reply: Note? = null,
quote: Note? = null,
draft: Note? = null,
replyId: HexKey? = null,
quoteId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -57,13 +57,13 @@ fun HashtagPostScreen(
hashtag?.let {
postViewModel.newPostFor(HashtagId(it))
}
reply?.let {
replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it)
}
draft?.let {
draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it)
}
quote?.let {
quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
@@ -313,7 +313,7 @@ fun FeedLoaded(
Spacer(StdVertSpacer)
}
}
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()
@@ -25,7 +25,10 @@ import android.content.Intent
import android.net.Uri
import android.os.Parcelable
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -46,6 +49,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -59,7 +63,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.util.Consumer
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
@@ -107,11 +110,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -123,11 +128,11 @@ import kotlinx.coroutines.withContext
fun ShortNotePostScreen(
message: String? = null,
attachment: Uri? = null,
baseReplyTo: Note? = null,
quote: Note? = null,
fork: Note? = null,
version: Note? = null,
draft: Note? = null,
baseReplyToId: HexKey? = null,
quoteId: HexKey? = null,
forkId: HexKey? = null,
versionId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -138,6 +143,11 @@ fun ShortNotePostScreen(
val activity = context.getActivity()
LaunchedEffect(postViewModel, accountViewModel) {
val baseReplyTo = baseReplyToId?.let { accountViewModel.getNoteIfExists(it) }
val quote = quoteId?.let { accountViewModel.getNoteIfExists(it) }
val fork = forkId?.let { accountViewModel.getNoteIfExists(it) }
val version = versionId?.let { accountViewModel.getNoteIfExists(it) }
val draft = draftId?.let { accountViewModel.getNoteIfExists(it) }
postViewModel.load(baseReplyTo, quote, fork, version, draft)
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
@@ -278,16 +288,53 @@ private fun NewPostScreenBody(
}
}
if (postViewModel.wantsAnonymousPost) {
Row(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = Size10dp, vertical = 8.dp),
verticalAlignment = CenterVertically,
) {
Text(
text = stringRes(R.string.anonymous_reply_warning),
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
)
}
}
// Only show text input if no voice message is being posted
if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) {
Row(
modifier = Modifier.padding(vertical = Size10dp),
) {
BaseUserPicture(
accountViewModel.userProfile(),
Size35dp,
accountViewModel = accountViewModel,
)
if (postViewModel.wantsAnonymousPost) {
IconButton(
onClick = { postViewModel.wantsAnonymousPost = false },
) {
Icon(
painter = painterRes(resourceId = R.drawable.incognito, 1),
contentDescription = stringRes(R.string.post_anonymously),
modifier = Size35Modifier,
tint = MaterialTheme.colorScheme.onBackground,
)
}
} else {
Box(
modifier =
Modifier.clickable {
postViewModel.wantsAnonymousPost = true
},
) {
BaseUserPicture(
accountViewModel.userProfile(),
Size35dp,
accountViewModel = accountViewModel,
)
}
}
MessageField(
R.string.what_s_on_your_mind,
postViewModel,
@@ -117,6 +117,7 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.alt
@@ -238,6 +239,7 @@ open class ShortNotePostViewModel :
var canUsePoll by mutableStateOf(false)
var wantsPoll by mutableStateOf(false)
var pollOptions: SnapshotStateMap<Int, OptionTag> = newStateMapPollOptions()
var pollType by mutableStateOf(PollType.SINGLE_CHOICE)
var closedAt by mutableLongStateOf(TimeUtils.oneDayAhead())
// ZapPolls
@@ -283,6 +285,9 @@ open class ShortNotePostViewModel :
var wantsZapRaiser by mutableStateOf(false)
override val zapRaiserAmount = mutableStateOf<Long?>(null)
// Anonymous Reply
var wantsAnonymousPost by mutableStateOf(false)
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
@@ -336,6 +341,7 @@ open class ShortNotePostViewModel :
val currentMentions =
(replyNote.event as? TextNoteEvent)
?.mentions()
?.toSet()
?.map { LocalCache.getOrCreateUser(it.pubKey) }
?: emptyList()
@@ -490,8 +496,8 @@ open class ShortNotePostViewModel :
}
pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map {
LocalCache.getOrCreateUser(it[1])
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.checkGetOrCreateUser(it[1])
}
draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "fork" }.forEach {
@@ -576,8 +582,8 @@ open class ShortNotePostViewModel :
}
pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map {
LocalCache.getOrCreateUser(it[1])
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.checkGetOrCreateUser(it[1])
}
canUsePoll = originalNote == null
@@ -596,6 +602,7 @@ open class ShortNotePostViewModel :
pollOptions[index] = tag
}
pollType = draftEvent.pollType() ?: PollType.SINGLE_CHOICE
closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead()
message = TextFieldValue(draftEvent.content)
@@ -647,8 +654,8 @@ open class ShortNotePostViewModel :
}
pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map {
LocalCache.getOrCreateUser(it[1])
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.checkGetOrCreateUser(it[1])
}
canUsePoll = originalNote == null
@@ -712,9 +719,12 @@ open class ShortNotePostViewModel :
}
val version = draftTag.current
val anonymous = wantsAnonymousPost
cancel()
if (accountViewModel.settings.isCompleteUIMode()) {
if (anonymous) {
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
} else if (accountViewModel.settings.isCompleteUIMode()) {
// Tracked broadcasting with progress feedback (non-blocking)
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
@@ -828,7 +838,7 @@ open class ShortNotePostViewModel :
accountViewModel.account.nip65RelayList.outboxFlow.value
.toList()
PollEvent.build(tagger.message, options, closedAt, relays) {
PollEvent.build(tagger.message, options, closedAt, relays, pollType) {
pTags(tagger.directMentionsUsers.map { it.toPTag() })
quotes(quotes)
hashtags(findHashtags(tagger.message))
@@ -1054,6 +1064,7 @@ open class ShortNotePostViewModel :
wantsPoll = false
pollOptions = newStateMapPollOptions()
pollType = PollType.SINGLE_CHOICE
closedAt = TimeUtils.oneDayAhead()
wantsZapPoll = false
@@ -1073,6 +1084,7 @@ open class ShortNotePostViewModel :
wantsToAddGeoHash = false
wantsExclusiveGeoPost = false
wantsSecretEmoji = false
wantsAnonymousPost = false
forwardZapTo.value = SplitBuilder()
forwardZapToEditting.value = TextFieldValue("")
@@ -68,16 +68,22 @@ import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine
import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@Composable
fun ImportFollowListPickFollowsScreen(
contactListNote: AddressableNote,
userHex: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
val contactListNote =
remember(userHex) {
accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(userHex))
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -125,6 +131,7 @@ fun DisplayFollowList(
val contactsState by observeNoteEventAndMap<ContactListEvent, ImmutableList<User>?>(contactListNote, accountViewModel) { contactList ->
contactList
?.unverifiedFollowKeySet()
?.toSet()
?.mapNotNull {
accountViewModel.checkGetOrCreateUser(it)
}?.toPersistentList()
@@ -55,7 +55,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
@@ -108,8 +107,8 @@ import kotlinx.coroutines.withContext
@Composable
fun NewPublicMessageScreen(
to: Set<HexKey>? = null,
reply: Note? = null,
draft: Note? = null,
replyId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -121,10 +120,10 @@ fun NewPublicMessageScreen(
to?.let {
postViewModel.load(it)
}
reply?.let {
replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it)
}
draft?.let {
draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it)
}
}
@@ -229,6 +229,7 @@ fun PrepareViewModels(
factory =
UserProfileReportFeedViewModel.Factory(
baseUser,
accountViewModel.account,
),
)
@@ -273,7 +274,6 @@ fun ProfileScreen(
WatchLifecycleAndUpdateModel(appRecommendations)
WatchLifecycleAndUpdateModel(bookmarksFeedViewModel)
WatchLifecycleAndUpdateModel(galleryFeedViewModel)
WatchLifecycleAndUpdateModel(reportsFeedViewModel)
UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile)
@@ -521,7 +521,7 @@ private fun CreateAndRenderTabs(
{ ZapTabHeader(zapFeedViewModel, accountViewModel) },
{ BookmarkTabHeader(baseUser, accountViewModel) },
{ FollowedTagsTabHeader(baseUser, accountViewModel) },
{ ReportsTabHeader(baseUser, accountViewModel) },
{ ReportsTabHeader(baseUser, reportsFeedViewModel, accountViewModel) },
{ RelaysTabHeader(baseUser, accountViewModel) },
)
@@ -54,8 +54,8 @@ class UserProfileFollowersUserFeedViewModel(
{ it.pubkeyHex },
)
fun List<Event>.toNonHiddenOwners(): List<User> =
mapNotNull { event ->
fun List<Event>.toNonHiddenOwners(): Set<User> =
mapNotNullTo(mutableSetOf()) { event ->
if (!account.isHidden(event.pubKey)) {
account.cache.getOrCreateUser(event.pubKey)
} else {
@@ -309,7 +309,7 @@ fun DisplayLastSeen(
lastSeen?.let { timestamp ->
val context = LocalContext.current
Text(
text = stringRes(R.string.last_seen, timeAgo(timestamp, context)),
text = stringRes(R.string.last_seen, timeAgo(timestamp, context, prefix = "", seconds = R.string.seconds)),
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -105,6 +105,9 @@ private fun RenderRelayRow(
onRemoveRelay = {
nav.nav(Route.EditRelays)
},
onClick = {
nav.nav(Route.RelayInfo(relay.url.url))
},
)
HorizontalDivider(
thickness = DividerThickness,
@@ -23,18 +23,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReportCount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun ReportsTabHeader(
baseUser: User,
reportsFeedViewModel: UserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
) {
val reportCount by observeUserReportCount(baseUser, accountViewModel)
val reportCount by reportsFeedViewModel.followerCount.collectAsStateWithLifecycle()
if (reportCount > 0) {
Text(text = stringRes(R.string.number_reports, reportCount))
@@ -21,14 +21,25 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@Composable
fun TabReports(
@@ -37,15 +48,36 @@ fun TabReports(
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchReportsAndUpdateFeed(baseUser, feedViewModel, accountViewModel)
Column(Modifier.fillMaxHeight()) {
RefresheableFeedView(
feedViewModel,
null,
enablePullRefresh = false,
accountViewModel = accountViewModel,
nav = nav,
)
val items by feedViewModel.followersFlow.collectAsStateWithLifecycle()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
state = rememberLazyListState(),
) {
itemsIndexed(
items,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
NoteCompose(
item,
modifier = Modifier.fillMaxWidth(),
routeForLastRead = null,
isBoostedNote = false,
isHiddenFeed = false,
quotesLeft = 3,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
@@ -23,17 +23,59 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
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.amethyst.ui.screen.AndroidFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
@Stable
class UserProfileReportFeedViewModel(
val user: User,
) : AndroidFeedViewModel(UserProfileReportsFeedFilter(user)) {
val account: Account,
) : ViewModel() {
val sortingModel: Comparator<Note> =
compareBy(
{ it.author?.let { !account.isFollowing(it) } },
{ it.idHex },
)
@OptIn(kotlinx.coroutines.FlowPreview::class)
val followersFlow: StateFlow<List<Note>> =
user
.reports()
.receivedReportsByAuthor
.map {
it.values.flatten().sortedWith(sortingModel)
}.sample(500)
.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
initialValue = emptyList(),
started = SharingStarted.Lazily,
)
val followerCount =
followersFlow
.map { it.size }
.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
initialValue = 0,
started = SharingStarted.Lazily,
)
class Factory(
val user: User,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = UserProfileReportFeedViewModel(user) as T
override fun <T : ViewModel> create(modelClass: Class<T>): T = UserProfileReportFeedViewModel(user, account) as T
}
}
@@ -1,48 +0,0 @@
/*
* 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.profile.reports.dal
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
class UserProfileReportsFeedFilter(
val user: User,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = user.pubkeyHex
override fun feed(): List<Note> = sort(innerApplyFilter(user.reportsOrNull()?.all() ?: emptyList()))
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> =
collection
.filterTo(mutableSetOf()) {
it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
override fun limit() = 400
}
@@ -155,11 +155,21 @@ class EventSync(
val inboxTargets: Map<NormalizedRelayUrl, DestinationRelayInfo> = emptyMap(),
val dmTargets: Map<NormalizedRelayUrl, DestinationRelayInfo> = emptyMap(),
) {
companion object {
val DefaultOrder = compareByDescending<SourceRelayInfo> { it.eventsFound.value }.thenByDescending { it.status.value == ConnectionStatus.Completed }
}
val sortedCompletedRelays =
completedRelays.values.let {
// precompute to avoid: Comparison method violates its general contract
val sortedCompletedRelays = completedRelays.values.sortedWith(DefaultOrder)
val orderCacheEventsFound = it.associateWith { it.eventsFound.value }
val orderCacheCompleted = it.associateWith { it.status.value == ConnectionStatus.Completed }
val orderComparator =
compareByDescending<SourceRelayInfo> {
orderCacheEventsFound[it]
}.thenByDescending {
orderCacheCompleted[it]
}
it.sortedWith(orderComparator)
}
constructor(
runningRelays: List<SourceRelayInfo>,
@@ -330,7 +330,11 @@ fun RenderThreadFeed(
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { index, item ->
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { index, _ -> if (index == 0) "master" else "reply" },
) { index, item ->
val level = viewModel.levelFlowForItem(item).collectAsStateWithLifecycle(0)
val modifier =
@@ -47,7 +47,7 @@ val FollowsFollow = Color.Yellow
val NIP05Verified = Color.Blue
val Nip05EmailColor = Color(0xFFb198ec)
val Nip05EmailColorDark = Color(0xFF6e5490)
val Nip05EmailColorDark = Color(0xFF765AA2)
val Nip05EmailColorLight = Color(0xFFa770f3)
val DarkerGreen = Color.Green.copy(alpha = 0.32f)
@@ -81,7 +81,7 @@ class TorManager(
val activePortOrNull: StateFlow<Int?> =
status
.map {
(status.value as? TorServiceStatus.Active)?.port
(it as? TorServiceStatus.Active)?.port
}.stateIn(
scope,
SharingStarted.WhileSubscribed(2000),
@@ -31,12 +31,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.torproject.jni.TorService
import org.torproject.jni.TorService.LocalBinder
private const val SOCKS_PORT_POLL_INTERVAL_MS = 100L
class TorService(
val context: Context,
) {
@@ -46,9 +47,7 @@ class TorService(
trySend(TorServiceStatus.Connecting)
val currentIntent = Intent(context, TorService::class.java)
context.bindService(
currentIntent,
val serviceConnection: ServiceConnection =
object : ServiceConnection {
override fun onServiceConnected(
name: ComponentName,
@@ -59,7 +58,7 @@ class TorService(
val torService = (service as LocalBinder).service
while (torService.socksPort < 0) {
delay(100)
delay(SOCKS_PORT_POLL_INTERVAL_MS)
}
val active = TorServiceStatus.Active(torService.socksPort)
@@ -74,16 +73,25 @@ class TorService(
Log.d("TorService", "Tor Service Disconnected")
trySend(TorServiceStatus.Off)
}
},
}
context.bindService(
currentIntent,
serviceConnection,
BIND_AUTO_CREATE,
)
awaitClose {
Log.d("TorService", "Stopping Tor Service")
try {
context.unbindService(serviceConnection)
} catch (e: Exception) {
Log.d("TorService", "Failed to unbind Tor Service: ${e.message}")
}
launch {
context.stopService(currentIntent)
}
trySend(TorServiceStatus.Off)
}
}.distinctUntilChanged().flowOn(Dispatchers.IO)
}.flowOn(Dispatchers.IO)
}
@@ -290,6 +290,7 @@
<string name="nip_05">Nostr Adresa</string>
<string name="never">nikdy</string>
<string name="now">nyní</string>
<string name="seconds">sekundy</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
@@ -536,7 +537,7 @@
<string name="follow_set_create_btn_label">Nový</string>
<string name="follow_set_add_author_from_note_action">Přidat autora do seznamu sledování</string>
<string name="follow_set_profile_actions_menu_description">Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem.</string>
<string name="follow_set_icon_description">Ikona pro seznam %1$s</string>
<string name="follow_set_icon_description">Ikona pro seznam</string>
<string name="follow_set_public_presence_indicator">%1$s je veřejný člen</string>
<string name="follow_set_private_presence_indicator">%1$s je soukromý člen</string>
<string name="follow_set_public_member_add_label">Přidat jako veřejného člena</string>
+1 -1
View File
@@ -961,7 +961,7 @@
<string name="follow_set_empty_feed_msg">Zdá se, že zatím nemáte žádné sady sledování.\nKlepněte níže pro obnovení nebo použijte tlačítko přidat k vytvoření nové.</string>
<string name="follow_set_add_author_from_note_action">Přidat autora do sady sledování</string>
<string name="follow_set_profile_actions_menu_description">Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem.</string>
<string name="follow_set_icon_description">Ikona pro seznam %1$s</string>
<string name="follow_set_icon_description">Ikona pro seznam</string>
<string name="follow_set_absence_indicator">%1$s není v tomto seznamu</string>
<string name="follow_set_man_dialog_title">Vaše sady sledování</string>
<string name="follow_set_empty_dialog_msg">Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové.</string>
@@ -294,6 +294,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="nip_05">Nostr-Adresse</string>
<string name="never">nie</string>
<string name="now">jetzt</string>
<string name="seconds">Sekunden</string>
<string name="h">s</string>
<string name="m">m</string>
<string name="d">t</string>
@@ -542,7 +543,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_set_create_btn_label">Neu</string>
<string name="follow_set_add_author_from_note_action">Autor zur Follower-Liste hinzufügen</string>
<string name="follow_set_profile_actions_menu_description">Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen.</string>
<string name="follow_set_icon_description">Symbol für %1$s-Liste</string>
<string name="follow_set_icon_description">Symbol für Liste</string>
<string name="follow_set_public_presence_indicator">%1$s ist ein öffentliches Mitglied</string>
<string name="follow_set_private_presence_indicator">%1$s ist ein privates Mitglied</string>
<string name="follow_set_public_member_add_label">Als öffentliches Mitglied hinzufügen</string>
+1 -1
View File
@@ -1001,7 +1001,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_set_empty_feed_msg">Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen.</string>
<string name="follow_set_add_author_from_note_action">Autor zum Folge-Set hinzufügen</string>
<string name="follow_set_profile_actions_menu_description">Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen.</string>
<string name="follow_set_icon_description">Symbol für %1$s-Liste</string>
<string name="follow_set_icon_description">Symbol für Liste</string>
<string name="follow_set_absence_indicator">%1$s ist nicht in dieser Liste</string>
<string name="follow_set_man_dialog_title">Deine Folge-Sets</string>
<string name="follow_set_empty_dialog_msg">Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen.</string>
@@ -290,6 +290,7 @@
<string name="nip_05">Nostr-cím</string>
<string name="never">soha</string>
<string name="now">most</string>
<string name="seconds">másodperc</string>
<string name="h">ó</string>
<string name="m">p</string>
<string name="d">n</string>
@@ -447,6 +448,8 @@
<string name="poll_zap_value_max">Maximum Zap</string>
<string name="poll_consensus_threshold">Együttműködés</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Egyetlen lehetőség</string>
<string name="poll_multiple_choice">Több lehetőség</string>
<string name="poll_closing_date_time">Szavazás lezárásának dátuma és ideje</string>
<string name="poll_closing_in">A szavazás lezárul %1$s múlva</string>
<string name="poll_closing_time">Szavazás lezárása</string>
@@ -1180,7 +1183,7 @@
<string name="outbox_relays_title">Átjátszók a kimenő üzenetkhez</string>
<string name="outbox_relays_not_found">Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez</string>
<string name="outbox_relays_not_found_description">A tartalom fogadására kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez, és ez az egyetlen módja annak, hogy a követői megtalálják Önt. </string>
<string name="outbox_relays_not_found_editing">Adjon meg 1-3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért</string>
<string name="outbox_relays_not_found_editing">Adjon meg 13 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért</string>
<string name="outbox_relays_not_found_examples">Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Átjátszók a bejövő üzenetkhez</string>
<string name="inbox_relays_not_found">Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához</string>
@@ -115,7 +115,7 @@
<string name="yes"></string>
<string name="no"></string>
<string name="follow_list_selection">Sekot saraksts</string>
<string name="follow_set_icon_description">Ikona %1$s sarakstam</string>
<string name="follow_set_icon_description">Ikona sarakstam</string>
<string name="follow_set_creation_menu_title">Izveidot jaunu sarakstu</string>
<string name="follow_set_creation_dialog_title">Jaunais %1$s saraksts</string>
<string name="follow_set_creation_name_label">Kolekcijas nosaukums</string>
+35 -16
View File
@@ -71,7 +71,7 @@
<string name="quote">Zacytuj</string>
<string name="fork">Sklonuj</string>
<string name="propose_an_edit">Zaproponuj zmianę</string>
<string name="new_amount_in_sats">Nowa kwota w Satsach</string>
<string name="new_amount_in_sats">Nowa kwota w satoszach</string>
<string name="add">Dodaj</string>
<string name="replying_to">"odpowiadając do "</string>
<string name="and">" i "</string>
@@ -92,7 +92,7 @@
<string name="lightning_tips">Lightning transfer</string>
<string name="note_to_receiver">Wiadomość dla odbiorcy</string>
<string name="thank_you_so_much">Dziękuję bardzo!</string>
<string name="amount_in_sats">Kwota w Satsach</string>
<string name="amount_in_sats">Kwota w satoszach</string>
<string name="send_sats">Wyślij</string>
<string name="secret_emoji_maker">Kreator tajnych emoji</string>
<string name="secret_emoji_maker_explainer">Dodaj emoji z ukrytą wiadomością do wpisu</string>
@@ -181,7 +181,7 @@
<string name="voice_preset_neutral">Neutralna</string>
<string name="voice_anonymize_title">Anonimowy</string>
<string name="voice_anonymize_description">Zmienia barwę głosu. Uwaga: podstawowe zmiany barwy głosu mogą zostać wykryte przez uważnych słuchaczy.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satsy</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satosze</string>
<string name="reply_here">"odpowiedz tutaj.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopiuje ID wpisu do schowka w celu udostępnienia w Nostr</string>
<string name="copy_channel_id_note_to_the_clipboard">Kopiuj ID kanału (wpisu) do schowka</string>
@@ -287,6 +287,7 @@
<string name="nip_05">Adres Nostr</string>
<string name="never">nigdy</string>
<string name="now">teraz</string>
<string name="seconds">sekund</string>
<string name="h">godz.</string>
<string name="m">m</string>
<string name="d">d</string>
@@ -431,7 +432,7 @@
<string name="zap_type_section_explainer">Kontroluje sposób wyświetlania Twojej tożsamości podczas wysyłania zapa.</string>
<string name="wallet_connect_connect_app">Podłącz portfel</string>
<string name="see_relay_feed">Przejrzyj kanał transmitera</string>
<string name="pledge_amount_in_sats">Kwota zobowiązania w Satach</string>
<string name="pledge_amount_in_sats">Kwota zobowiązania w Satoszach</string>
<string name="post_poll">Wyślij Ankietę</string>
<string name="poll_heading_required">Wymagane pola:</string>
<string name="poll_zap_recipients">Odbiorcy zap</string>
@@ -444,6 +445,8 @@
<string name="poll_zap_value_max">Maksymalny Zap</string>
<string name="poll_consensus_threshold">Konsensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Pojedynczy wybór</string>
<string name="poll_multiple_choice">Wielokrotny wybór</string>
<string name="poll_closing_date_time">Data &amp; godzina zakończenia ankiety</string>
<string name="poll_closing_in">Ankieta zostanie zamknięta za %1$s</string>
<string name="poll_closing_time">Zamknij po</string>
@@ -537,7 +540,7 @@
<string name="follow_set_create_btn_label">Nowa</string>
<string name="follow_set_add_author_from_note_action">Dodaj autora do listy obserwowanych</string>
<string name="follow_set_profile_actions_menu_description">Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem.</string>
<string name="follow_set_icon_description">Ikona dla listy %1$s</string>
<string name="follow_set_icon_description">Ikona dla listy</string>
<string name="follow_set_public_presence_indicator">%1$s jest uczestnikiem publicznym</string>
<string name="follow_set_private_presence_indicator">%1$s jest uczestnikiem prywatnym</string>
<string name="follow_set_public_member_add_label">Dodaj jako uczestnika publicznego</string>
@@ -633,7 +636,7 @@
<string name="app_notification_dms_channel_description">Powiadamia Cię, gdy nadejdzie prywatna wiadomość</string>
<string name="app_notification_zaps_channel_name">Otrzymano Zapy</string>
<string name="app_notification_zaps_channel_description">Powiadamia Cię, gdy ktoś prześle ci zapy</string>
<string name="app_notification_zaps_channel_message">%1$s Satsów</string>
<string name="app_notification_zaps_channel_message">%1$s Satoszy</string>
<string name="app_notification_zaps_channel_message_from">Od %1$s</string>
<string name="app_notification_zaps_channel_message_for">dla %1$s</string>
<string name="app_notification_reply_label">Odpowiedz</string>
@@ -668,9 +671,9 @@
<string name="new_reaction_symbol">Nowy Symbol Odzewu</string>
<string name="no_reaction_type_setup_long_press_to_change">Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">Dodaje docelową liczbę satsów do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn</string>
<string name="zapraiser_target_amount_in_sats">Docelowa kwota w Satach</string>
<string name="sats_to_complete">Zapraiser przy: %1$s. %2$s satach do celu</string>
<string name="zapraiser_explainer">Dodaje docelową liczbę satoszy do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn</string>
<string name="zapraiser_target_amount_in_sats">Docelowa kwota w Satoszach</string>
<string name="sats_to_complete">Zapraiser przy: %1$s. %2$s satoszach do celu</string>
<string name="read_from_relay">Odczytaj z Transmitera</string>
<string name="write_to_relay">Zapisz do Transmitera</string>
<string name="write_to_relay_description">Ilość w bajtach, która została wysłana do tego transmitera, w tym filtry i wydarzenia</string>
@@ -878,7 +881,7 @@
<string name="copy_to_clipboard">Kopiuj do schowka</string>
<string name="copy_nprofile_to_clipboard">Skopiuj nprofile do schowka</string>
<string name="copy_npub_to_clipboard">Kopiuj npub do schowka</string>
<string name="share_or_save">Udostępnij lub Zapisz</string>
<string name="share_or_save">Udostępnij lub zapisz</string>
<string name="copy_url_to_clipboard">Kopiuj adres URL do schowka</string>
<string name="copy_the_note_id_to_the_clipboard">Kopiuj ID wpisu do schowka</string>
<string name="add_media_to_gallery">Dodaj pliki do Galerii</string>
@@ -916,7 +919,7 @@
<string name="zap_split_search_and_add_user">Szukaj i dodaj użytkownika</string>
<string name="zap_split_search_and_add_user_placeholder">Nick lub Login</string>
<string name="missing_lud16">Brakująca konfiguracja LN</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satsy</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satosze</string>
<string name="zap_split_weight">Procentowo</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Podziel zapsy z</string>
@@ -947,7 +950,7 @@
<string name="cashu_failed_redemption_explainer_error_msg">Mint dostarczył następujący komunikat błędu: %1$s</string>
<string name="cashu_failed_redemption_explainer_already_spent">Tokeny Cashu zostały już wydane.</string>
<string name="cashu_successful_redemption">Cashu odebrano</string>
<string name="cashu_successful_redemption_explainer">%1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satsów)</string>
<string name="cashu_successful_redemption_explainer">%1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satoszy)</string>
<string name="cashu_no_wallet_found">W systemie nie znaleziono kompatybilnego portfela Cashu</string>
<string name="error_unable_to_fetch_invoice">Nie można pobrać faktury z serwerów odbiorcy</string>
<string name="wallet_connect_pay_invoice_error_error">Twój dostawca połączenia z portfelem zwrócił następujący błąd: %1$s</string>
@@ -965,7 +968,7 @@
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">Nie znaleziono zwrotnego adresu URL z odpowiedzi %1$s</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup">Wystąpił błąd podczas analizowania JSON z pobierania faktury z Lightning Adresu. Sprawdź konfigurację lightning użytkownika</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika</string>
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">Nieprawidłowa kwota faktury (%1$s satsów) od %2$s. Powinieno być %3$s</string>
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">Nieprawidłowa kwota faktury (%1$s satoszy) od %2$s. Powinieno być %3$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">Nie można utworzyć faktury przed wysłaniem zapa. Portfel odbiorcy wysłał następujący błąd: %1$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Nie można utworzyć faktury. Wiadomość od %1$s: %2$s</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json">Nie można utworzyć faktury przed wysłaniem zapa. Element pr nie został znaleziony w powstałym JSON.</string>
@@ -992,7 +995,7 @@
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Stan</string>
<string name="classifieds_category">Kategoria</string>
<string name="classifieds_price">Cena (w Satach)</string>
<string name="classifieds_price">Cena (w Satoszach)</string>
<string name="classifieds_price_placeholder">1000</string>
<string name="classifieds_location">Lokalizacja</string>
<string name="classifieds_location_placeholder">Miasto, Województwo, Kraj</string>
@@ -1101,6 +1104,13 @@
<string name="new_community_note">Nowy wpis w społeczności</string>
<string name="new_product">Nowy produkt</string>
<string name="new_exclusive_geo_note">Nowy GEO-ekskluzywny Wpis</string>
<string name="new_long_form_post">Nowy artykuł</string>
<string name="article_title">Tytuł</string>
<string name="article_summary">Podsumowanie (opcjonalnie)</string>
<string name="article_cover_image_url">Adres URL miniaturki (opcjonalnie)</string>
<string name="write_your_article_in_markdown">Napisz artykuł w formacie markdown…</string>
<string name="markdown_preview">Podgląd</string>
<string name="markdown_edit">Edytuj</string>
<string name="open_all_reactions_to_this_post">Otwórz wszystkie odzewy na ten post</string>
<string name="close_all_reactions_to_this_post">Zamknij wszystkie odzewy na ten post</string>
<string name="reply_description">Odpowiedź</string>
@@ -1238,6 +1248,7 @@
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Potwierdzenie znacznika czasu</string>
<string name="ots_info_description">Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie.</string>
<string name="edit_article">Redaguj artykuł</string>
<string name="edit_post">Edytuj wpis</string>
<string name="proposal_to_edit">Propozycja ulepszenia wpisu</string>
<string name="message_to_author">Podsumowanie zmian</string>
@@ -1397,7 +1408,7 @@
<string name="share_of">%1$d/%2$d</string>
<string name="broadcasting">Przekaz</string>
<string name="broadcasting_name">Przekaz %1$s</string>
<string name="broadcasting_number_events">Liczba przekazów: %1$d...</string>
<string name="broadcasting_number_events">Liczba przekazów: %1$d</string>
<string name="sent_number_events">Wysłanych przekazów: %1$d</string>
<string name="bradcasting_result_partial">Niektóre akcje nie powiodły się</string>
<string name="bradcasting_result_success">Wszystkie akcje udane</string>
@@ -1560,6 +1571,7 @@
<string name="name_search_npub1_alice_example_com">wyszukaj, npub1…, alicja@domena.pl</string>
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Obsługuje npub, nprofile, NIP-05, hex, i namecoin (.bit, d/, id/)</string>
<string name="look_up_follow_list">Sprawdź listę obserwowanych</string>
<string name="tip">Porada</string>
<string name="accounts_found">Znaleziono %1$d kont(a)</string>
<string name="num_selected">Wybrano: %1$d</string>
<string name="resolved_via_namecoin">Rozwiązane przez Namecoin</string>
@@ -1616,7 +1628,7 @@
<string name="event_sync_log_new">nowa %1$s</string>
<string name="event_sync_no_events">brak wydarzeń</string>
<string name="ots_explorer_settings">Eksplorator Bitcoin (OTS)</string>
<string name="events">wydarzenia</string>
<string name="events">wydarzeń</string>
<string name="dms">DMs</string>
<string name="profiles">profile</string>
<string name="relay_settings_lower">ustawienia transmiterów</string>
@@ -1659,4 +1671,11 @@
<string name="attestor_proficiency_for_kinds">Biegłość w sprawdzaniu typów: %1$s</string>
<string name="attestation_attests_to">Certyfikat dla</string>
<string name="attestation_requests_attestation_to">Żądanie certyfikatu do</string>
<string name="event_sync_date_filter_title">Przedział czasu</string>
<string name="event_sync_date_filter_since">Od</string>
<string name="event_sync_date_filter_until">Do</string>
<string name="event_sync_date_filter_now">Teraz</string>
<string name="event_sync_date_filter_all_time">Cały czas</string>
<string name="event_sync_date_filter_last_sync">Ostatnia synchronizacja %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Od ostatniej synchronizacji</string>
</resources>
@@ -290,6 +290,7 @@
<string name="nip_05">Endereço Nostr</string>
<string name="never">nunca</string>
<string name="now">agora</string>
<string name="seconds">segundos</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
@@ -536,7 +537,7 @@
<string name="follow_set_create_btn_label">Novo</string>
<string name="follow_set_add_author_from_note_action">Adicionar autor à lista de seguidores</string>
<string name="follow_set_profile_actions_menu_description">Adicionar ou remover usuário de listas, ou criar uma nova lista com este usuário.</string>
<string name="follow_set_icon_description">Ícone da lista %1$s</string>
<string name="follow_set_icon_description">Ícone da lista</string>
<string name="follow_set_public_presence_indicator">%1$s é um membro público</string>
<string name="follow_set_private_presence_indicator">%1$s é um membro privado</string>
<string name="follow_set_public_member_add_label">Adicionar como membro público</string>
@@ -1419,7 +1419,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="share_of">%1$d/%2$d</string>
<string name="broadcasting">Oddajam</string>
<string name="broadcasting_name">Oddajam %1$s</string>
<string name="broadcasting_number_events">Oddajam %1$d dogodkov...</string>
<string name="broadcasting_number_events">Oddajam %1$d dogodkov</string>
<string name="sent_number_events">Poslano %1$d dogodkov</string>
<string name="bradcasting_result_partial">Nekaterim dogodkom ni uspelo</string>
<string name="bradcasting_result_success">Vsem dogodkom je uspelo</string>
@@ -290,6 +290,7 @@
<string name="nip_05">Nostr-adress</string>
<string name="never">aldrig</string>
<string name="now">nu</string>
<string name="seconds">sekunder</string>
<string name="h">t</string>
<string name="m">m</string>
<string name="d">d</string>
@@ -536,7 +537,7 @@
<string name="follow_set_create_btn_label">Ny</string>
<string name="follow_set_add_author_from_note_action">Lägg till författare i följelista</string>
<string name="follow_set_profile_actions_menu_description">Lägg till eller ta bort användare från listor, eller skapa en ny lista med denna användare.</string>
<string name="follow_set_icon_description">Ikon för %1$s-lista</string>
<string name="follow_set_icon_description">Ikon för lista</string>
<string name="follow_set_public_presence_indicator">%1$s är en offentlig medlem</string>
<string name="follow_set_private_presence_indicator">%1$s är en privat medlem</string>
<string name="follow_set_public_member_add_label">Lägg till som offentlig medlem</string>
@@ -290,6 +290,7 @@
<string name="nip_05">Nostr 地址</string>
<string name="never">从不</string>
<string name="now">现在</string>
<string name="seconds"></string>
<string name="h"></string>
<string name="m"></string>
<string name="d"></string>
@@ -1104,6 +1105,13 @@
<string name="new_community_note">新社区笔记</string>
<string name="new_product">新产品</string>
<string name="new_exclusive_geo_note">新建地理位置限定帖文</string>
<string name="new_long_form_post">新文章</string>
<string name="article_title">标题</string>
<string name="article_summary">摘要(选填)</string>
<string name="article_cover_image_url">封面图片URL (可选)</string>
<string name="write_your_article_in_markdown">用 markdown 格式撰写文章…</string>
<string name="markdown_preview">预览</string>
<string name="markdown_edit">编辑</string>
<string name="open_all_reactions_to_this_post">展开对此帖子的所有回应</string>
<string name="close_all_reactions_to_this_post">收起对此帖子的所有回应</string>
<string name="reply_description">回复</string>
@@ -1173,7 +1181,7 @@
<string name="outbox_relays_title">发件箱中继</string>
<string name="outbox_relays_not_found">设置您的公共发件箱中继来发布内容</string>
<string name="outbox_relays_not_found_description">创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 </string>
<string name="outbox_relays_not_found_editing">插入 1-3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入</string>
<string name="outbox_relays_not_found_editing">插入 13 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入</string>
<string name="outbox_relays_not_found_examples">好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">收件箱中继</string>
<string name="inbox_relays_not_found">设置您的公共收件箱中继来接收通知</string>
@@ -1241,6 +1249,7 @@
<string name="existed_since">OTS%1$s</string>
<string name="ots_info_title">OpenTimestamps 证明</string>
<string name="ots_info_description">%1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。</string>
<string name="edit_article">编辑文章</string>
<string name="edit_post">编辑帖子</string>
<string name="proposal_to_edit">提议改进帖子</string>
<string name="message_to_author">变动摘要</string>
@@ -1664,4 +1673,11 @@
<string name="attestor_proficiency_for_kinds">熟练验证类型:%1$s</string>
<string name="attestation_attests_to">证明</string>
<string name="attestation_requests_attestation_to">请求证明</string>
<string name="event_sync_date_filter_title">日期范围</string>
<string name="event_sync_date_filter_since"></string>
<string name="event_sync_date_filter_until"></string>
<string name="event_sync_date_filter_now">刚刚</string>
<string name="event_sync_date_filter_all_time">全部时间</string>
<string name="event_sync_date_filter_last_sync">上次同步: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">自上次同步后</string>
</resources>
+7
View File
@@ -309,6 +309,7 @@
<string name="lnurl" translatable="false">LNURL…</string>
<string name="never">never</string>
<string name="now">now</string>
<string name="seconds">seconds</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
@@ -482,6 +483,8 @@
<string name="poll_zap_value_max">Zap maximum</string>
<string name="poll_consensus_threshold">Consensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Single choice</string>
<string name="poll_multiple_choice">Multiple choice</string>
<string name="poll_closing_date_time">Poll Closing Date &amp; Time</string>
<string name="poll_closing_in">Poll closes in %1$s</string>
<string name="poll_closing_time">Close after</string>
@@ -538,6 +541,10 @@
<string name="zap_type_nonzap_explainer">No trace in Nostr, only in Lightning</string>
<string name="post_anonymously">Anonymous</string>
<string name="post_anonymously_explainer">Post as a new throwaway identity. Your account will not be linked to this reply.</string>
<string name="anonymous_reply_warning">This reply will be posted from a new anonymous identity</string>
<string name="file_server">File Server</string>
<string name="file_server_description">Choose a server to upload this file to</string>
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.commons.richtext
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.startsWithAny
import com.vitorpamplona.quartz.utils.urldetector.Url
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
import kotlinx.coroutines.CancellationException
@Stable
class Urls(
@@ -77,30 +79,35 @@ class UrlParser {
val blossom = mutableSetOf<String>()
urls.forEach { url ->
if (url.isValidTopLevelDomain()) {
if (url.wroteWithSchema()) {
if (url.originalUrl.startsWithAny(httpScheme)) {
// quick exit
completeUrls.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(nostrScheme)) {
bech32.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(websocketScheme)) {
relays.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(blossomScheme)) {
blossom.add(url.originalUrl)
} else {
completeUrls.add(url.originalUrl)
}
} else {
// emails are understood as urls from the detector.
if (url.isEmail()) {
Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach {
emails.add(it.value)
try {
if (url.isValidTopLevelDomain()) {
if (url.wroteWithSchema()) {
if (url.originalUrl.startsWithAny(httpScheme)) {
// quick exit
completeUrls.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(nostrScheme)) {
bech32.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(websocketScheme)) {
relays.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(blossomScheme)) {
blossom.add(url.originalUrl)
} else {
completeUrls.add(url.originalUrl)
}
} else {
urlsWithoutScheme.add(url.originalUrl)
// emails are understood as urls from the detector.
if (url.isEmail()) {
Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach {
emails.add(it.value)
}
} else {
urlsWithoutScheme.add(url.originalUrl)
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("UrlParser", "Trying to parse url `${url.originalUrl}` from `$content`", e)
}
}
@@ -281,6 +281,18 @@ class UrlParserTest {
Urls(withScheme = emptySet()),
)
/**
* Regression test for PR #1907: parsing a note whose content is only the Japanese phrase
* "今北産業" (a common internet abbreviation) must not throw a StringIndexOutOfBoundsException
* from Url.getPart() and must produce no detected URLs.
*/
@Test
fun testImakitaSangyo() =
test(
"今北産業",
Urls(),
)
@Test
fun testHour() =
test(
+2 -4
View File
@@ -48,7 +48,6 @@ netUrlencoderLibVersion = "1.6.0"
navigationCompose = "2.9.7"
okhttp = "5.3.2"
runner = "1.7.0"
rfc3986 = "0.1.2"
secp256k1KmpJniAndroid = "0.23.0"
securityCryptoKtx = "1.1.0"
slf4j = "2.0.17"
@@ -58,6 +57,7 @@ torAndroid = "0.4.9.5.1"
translate = "17.0.3"
jetbrainsCompose = "1.10.3"
unifiedpush = "3.0.10"
uriReferenceKmp = "1.0"
vico-charts-compose = "3.0.3"
zelory = "3.0.1"
zoomable = "2.11.1"
@@ -72,7 +72,6 @@ androidxExifinterface = "1.4.2"
kotlinTest = "2.3.0"
core = "1.7.0"
mavenPublish = "0.36.0"
spmForKmpVersion = "1.6.1"
sqlite = "2.6.2"
[libraries]
@@ -128,6 +127,7 @@ coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versio
coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" }
commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" }
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
uri-reference-kmp = { module = "io.github.kotlingeekdev:uri-reference-kmp", version.ref = "uriReferenceKmp" }
vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" }
dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" }
drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" }
@@ -165,7 +165,6 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" }
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
@@ -199,4 +198,3 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref =
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
frankois944-spmForKmp = { id = "io.github.frankois944.spmForKmp", version.ref = "spmForKmpVersion" }
+7 -25
View File
@@ -1,9 +1,5 @@
@file:OptIn(ExperimentalSpmForKmpFeature::class)
import com.vanniktech.maven.publish.KotlinMultiplatform
import com.vanniktech.maven.publish.SourcesJar
import io.github.frankois944.spmForKmp.swiftPackageConfig
import io.github.frankois944.spmForKmp.utils.ExperimentalSpmForKmpFeature
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest
@@ -13,7 +9,6 @@ plugins {
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.serialization)
alias(libs.plugins.vanniktech.mavenPublish)
alias(libs.plugins.frankois944.spmForKmp)
}
kotlin {
@@ -26,7 +21,7 @@ kotlin {
}
}
androidLibrary {
android {
namespace = "com.vitorpamplona.quartz"
compileSdk =
libs.versions.android.compileSdk
@@ -121,21 +116,6 @@ kotlin {
dependsOn(libsodiumDefFileGeneration)
}
}
target.swiftPackageConfig(cinteropName = "swiftbridge") {
minIos = "17"
minMacos = "14"
dependency {
remotePackageVersion(
url = uri("https://github.com/swift-standards/swift-rfc-3986.git"),
packageName = "swift-rfc-3986",
products = {
add("RFC 3986")
},
version = "0.1.0",
)
}
}
}
iosArm64 {
@@ -144,6 +124,7 @@ kotlin {
}
binaries.framework {
baseName = xcfName
isStatic = true
binaryOption("bundleId", "com.vitorpamplona.quartz")
}
}
@@ -154,6 +135,7 @@ kotlin {
}
binaries.framework {
baseName = xcfName
isStatic = true
binaryOption("bundleId", "com.vitorpamplona.quartz")
}
}
@@ -201,6 +183,9 @@ kotlin {
// SQLite KMP driver for event store
api(libs.androidx.sqlite)
implementation(libs.androidx.sqlite.bundled)
// RFC3986 library(normalizes URLs)
api(libs.uri.reference.kmp)
}
}
@@ -221,9 +206,6 @@ kotlin {
dependsOn(commonMain.get())
dependencies {
// Normalizes URLs
api(libs.rfc3986.normalizer)
// Performant Parser of JSONs into Events
api(libs.jackson.module.kotlin)
@@ -368,7 +350,7 @@ mavenPublishing {
coordinates(
groupId = "com.vitorpamplona.quartz",
artifactId = "quartz",
version = "1.06.0",
version = "1.06.2",
)
// Configure publishing to Maven Central
@@ -72,6 +72,15 @@ class TagArrayBuilder<T : IEvent> {
return this
}
fun addUniqueValueIfNew(tag: Array<String>): TagArrayBuilder<T> {
if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this
val list = tagList.getOrPut(tag[0], ::mutableListOf)
if (list.none { it.valueOrNull() == tag[1] }) {
list.add(tag)
}
return this
}
fun addAll(tag: List<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::add)
return this
@@ -82,6 +91,16 @@ class TagArrayBuilder<T : IEvent> {
return this
}
fun addAllUnique(tag: Array<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::addUnique)
return this
}
fun addAllUniqueValueIfNew(tag: List<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::addUniqueValueIfNew)
return this
}
fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray()
fun build() = toTypedArray()
@@ -36,7 +36,7 @@ class RelayOfflineTracker(
const val TAG = "RelayOfflineTracker"
}
val cannotConnectRelays = mutableSetOf<NormalizedRelayUrl>()
var cannotConnectRelays = setOf<NormalizedRelayUrl>()
private val clientListener =
object : IRelayClientListener {
@@ -45,14 +45,14 @@ class RelayOfflineTracker(
pingMillis: Int,
compressed: Boolean,
) {
cannotConnectRelays.remove(relay.url)
cannotConnectRelays -= relay.url
}
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
cannotConnectRelays.add(relay.url)
cannotConnectRelays += relay.url
}
}
@@ -52,6 +52,12 @@ class ReferenceTag {
fun assemble(url: String) = arrayOf(TAG_NAME, HttpUrlFormatter.normalize(url))
fun assemble(urls: List<String>): List<Array<String>> = urls.mapTo(HashSet()) { HttpUrlFormatter.normalize(it) }.map { arrayOf(TAG_NAME, it) }
fun assemble(urls: List<String>): List<Array<String>> =
urls
.mapTo(HashSet()) {
HttpUrlFormatter.normalize(it)
}.map {
arrayOf(TAG_NAME, it)
}
}
}
@@ -20,15 +20,25 @@
*/
package com.vitorpamplona.quartz.nip10Notes.content
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter
import com.vitorpamplona.quartz.utils.fastFindURLs
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.startsWithAny
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
fun findURLs(text: String) = fastFindURLs(text)
val rejectSchemes =
listOf(
DualCase("ftp:"),
DualCase("ftps:"),
DualCase("ws:"),
DualCase("wss:"),
DualCase("nostr:"),
DualCase("blossom:"),
)
fun buildUrlRefs(urls: List<String>): List<Array<String>> =
urls
.mapTo(HashSet()) { url ->
HttpUrlFormatter.normalize(url)
}.map {
arrayOf("r", it)
fun findURLs(text: String) =
UrlDetector(text).detect().mapNotNull {
if (it.originalUrl.startsWithAny(rejectSchemes)) {
null
} else {
it.originalUrl
}
}
@@ -31,18 +31,18 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
fun <T : Event> TagArrayBuilder<T>.quote(tag: QTag) = add(tag.toTagArray())
fun <T : Event> TagArrayBuilder<T>.quote(tag: QTag) = addUniqueValueIfNew(tag.toTagArray())
fun <T : Event> TagArrayBuilder<T>.quotes(tag: List<QTag>) = addAll(tag.map { it.toTagArray() })
fun <T : Event> TagArrayBuilder<T>.quotes(tag: List<QTag>) = addAllUniqueValueIfNew(tag.map { it.toTagArray() })
fun <T : Event> TagArrayBuilder<T>.quote(entity: Entity) =
when (entity) {
is NNote -> add(entity.toQuoteTagArray())
is NEvent -> add(entity.toQuoteTagArray())
is NAddress -> add(entity.toQuoteTagArray())
is NEmbed -> add(entity.toQuoteTagArray())
is NPub -> add(entity.toQuoteTagArray())
is NProfile -> add(entity.toQuoteTagArray())
is NNote -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NEvent -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NAddress -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NEmbed -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NPub -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NProfile -> addUniqueValueIfNew(entity.toQuoteTagArray())
else -> this
}
@@ -32,10 +32,22 @@ import kotlinx.serialization.Serializable
class Nip47WalletConnect {
companion object {
fun fix(uri: String): String {
var newUri = uri
// POCO Phones seem to remove the + sign from the scheme
if (uri.startsWith("nostr walletconnect")) {
newUri = uri.replaceFirst("nostr walletconnect", "nostr+walletconnect")
} else if (uri.startsWith("amethyst walletconnect")) {
newUri = uri.replaceFirst("amethyst walletconnect", "amethyst+walletconnect")
}
return newUri
}
fun parse(uri: String): Nip47URINorm {
// nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=...&lud16=user@example.com
val url = UriParser(uri)
val url = UriParser(fix(uri))
if (url.scheme() != "nostrwalletconnect" && url.scheme() != "nostr+walletconnect" && url.scheme() != "amethyst+walletconnect") {
throw IllegalArgumentException("Not a Wallet Connect QR Code")
@@ -21,6 +21,5 @@
package com.vitorpamplona.quartz.nip89AppHandlers.clientTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
fun Event.client() = tags.zapraiserAmount()
fun Event.client() = tags.client()
@@ -24,9 +24,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
fun TagArrayBuilder<Event>.client(name: String) = addUnique(ClientTag.assemble(name))
fun <T : Event> TagArrayBuilder<T>.client(name: String) = addUnique(ClientTag.assemble(name))
fun TagArrayBuilder<Event>.client(
fun <T : Event> TagArrayBuilder<T>.client(
name: String,
address: AddressHint,
) = addUnique(ClientTag.assemble(name, address.addressId, address.relay))
@@ -20,12 +20,47 @@
*/
package com.vitorpamplona.quartz.utils
expect object Rfc3986 {
fun normalize(uri: String): String
import io.kotlingeekdev.urireference.URIReference
fun isValidUrl(url: String): Boolean
object Rfc3986 {
fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString()
fun normalizeAndRemoveFragment(url: String): String
fun isValidUrl(url: String): Boolean =
runCatching {
URIReference.parse(url)
}.isSuccess
fun host(url: String): String
fun normalizeAndRemoveFragment(url: String): String =
URIReference
.parse(url)
.normalize()!!
.toStringNoFragment()
.internIfPossible()
fun host(url: String): String =
URIReference
.parse(url)
.host
?.value
.toString()
}
fun URIReference.toStringSchemeHost(): String {
val sb = StringBuilder()
if (scheme != null) sb.append(scheme).append(":")
if (authority != null) sb.append("//").append(authority.toString())
return sb.toString()
}
fun URIReference.toStringNoFragment(): String {
val sb = StringBuilder()
if (scheme != null) sb.append(scheme).append(":")
if (host != null) sb.append("//").append(host.toString())
if (path != null) sb.append(path)
if (query != null) sb.append("?").append(query)
return sb.toString()
}
@@ -1,23 +0,0 @@
/*
* 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.quartz.utils
expect fun fastFindURLs(text: String): List<String>
@@ -228,11 +228,17 @@ class Url(
return null
}
val startIndex = urlMarker.indexOf(part)
if (startIndex < 0 || startIndex >= originalUrl.length) {
return null
}
val nextPart = nextExistingPart(part)
return if (nextPart == null) {
originalUrl.substring(urlMarker.indexOf(part))
originalUrl.substring(startIndex)
} else {
originalUrl.substring(urlMarker.indexOf(part), urlMarker.indexOf(nextPart))
val endIndex = urlMarker.indexOf(nextPart)
originalUrl.substring(startIndex, minOf(endIndex, originalUrl.length))
}
}
@@ -298,11 +298,22 @@ class DomainNameReader(
topLevelLength = currentLabelLength
}
var lastWasAscii: Boolean? = null
var lastWasAscii: Boolean? =
if (current.isNullOrEmpty()) {
null
} else {
val last = current.last()
if (isDot(last)) {
null
} else {
last.code < INTERNATIONAL_CHAR_START
}
}
var isAscii = false
while (!done && !reader.eof()) {
val curr: Char = reader.read()
isAscii = curr.code < INTERNATIONAL_CHAR_START
if (lastWasAscii == null) {
lastWasAscii = isAscii
@@ -765,7 +776,7 @@ class DomainNameReader(
* The start of the utf character code table which indicates that this character is an international character.
* Everything below this value is either a-z,A-Z,0-9 or symbols that are not included in domain name.
*/
private const val INTERNATIONAL_CHAR_START = 192
const val INTERNATIONAL_CHAR_START = 192
/**
* The maximum length of each label in the domain name.
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.utils.urldetector.detection
import com.vitorpamplona.quartz.utils.urldetector.Url
import com.vitorpamplona.quartz.utils.urldetector.UrlMarker
import com.vitorpamplona.quartz.utils.urldetector.UrlPart
import com.vitorpamplona.quartz.utils.urldetector.detection.DomainNameReader.Companion.INTERNATIONAL_CHAR_START
import kotlin.math.max
import kotlin.text.deleteRange
@@ -84,6 +85,9 @@ class UrlDetector(
var length = 0
var position = 0
var lastWasAscii: Boolean? = null
var isAscii = false
// until end of string read the contents
while (!reader.eof()) {
// read the next char to process.
@@ -96,6 +100,7 @@ class UrlDetector(
}
readEnd(ReadEndState.InvalidUrl)
length = 0
lastWasAscii = null
}
'%' -> {
@@ -116,6 +121,7 @@ class UrlDetector(
length = 0
}
}
lastWasAscii = null
}
'\u3002', '\uFF0E', '\uFF61', '.' -> {
@@ -125,6 +131,7 @@ class UrlDetector(
readEnd(ReadEndState.InvalidUrl)
}
length = 0
lastWasAscii = null
}
'@' -> {
@@ -136,6 +143,7 @@ class UrlDetector(
}
length = 0
}
lastWasAscii = null
}
'[' -> {
@@ -153,6 +161,7 @@ class UrlDetector(
reader.seek(beginning)
}
length = 0
lastWasAscii = null
}
'/' -> {
@@ -180,15 +189,29 @@ class UrlDetector(
hasScheme = readHtml5Root()
length = buffer.length
}
lastWasAscii = null
}
':' -> {
// add the ":" to the url and check for scheme/username
buffer.append(curr)
length = processColon(length)
lastWasAscii = null
}
else -> {
isAscii = curr.code < INTERNATIONAL_CHAR_START
if (lastWasAscii == null) {
lastWasAscii = isAscii
} else if (isAscii != lastWasAscii) {
// threat changes in char as a space
if (buffer.isNotEmpty() && hasScheme) {
reader.goBack()
readDomainName(buffer.substring(length))
}
length = 0
}
buffer.append(curr)
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip10Notes.urls
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.utils.fastFindURLs
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
@@ -31,7 +30,7 @@ class UrlsDetectorTest {
@Test
fun detectUrlNumber() {
val detectedLinks = fastFindURLs(testSentence)
val detectedLinks = findURLs(testSentence)
assertEquals(2, detectedLinks.size)
}
@@ -41,4 +40,14 @@ class UrlsDetectorTest {
assertContains(detectedLinks, "https://mysite.xyz")
assertContains(detectedLinks, "https://myblog.xyz")
}
/**
* Regression test for PR #1907: the Japanese phrase "今北産業" must not crash the URL
* detector with a StringIndexOutOfBoundsException and must return no URLs.
*/
@Test
fun doesNotCrashOnJapaneseText() {
val detectedLinks = findURLs("今北産業")
assertEquals(0, detectedLinks.size)
}
}
@@ -0,0 +1,64 @@
/*
* 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.quartz.utils.urldetector
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
/**
* Regression tests for PR #1907: StringIndexOutOfBoundsException in [Url.getPart] when
* processing the Japanese text "今北産業".
*/
class UrlTest {
/**
* Regression: detecting URLs in "今北産業" must not throw and must return no URLs.
*/
@Test
fun detectingImakitaSangyoDoesNotThrow() {
val urls = UrlDetector("今北産業").detect()
assertEquals(0, urls.size)
}
/**
* Regression: constructing a Url with "今北産業" as the original string and a HOST
* marker at 0 with PORT at the string length (simulating a trimmed trailing character)
* must not throw StringIndexOutOfBoundsException when accessing any property.
*
* "今北産業" has length 4. PORT at 4 == length triggers the startIndex >= length guard
* added to getPart() in PR #1907.
*/
@Test
fun urlPropertiesDoNotThrowForImakitaSangyoWithOutOfRangeMarker() {
val marker = UrlMarker()
marker.setIndex(UrlPart.HOST, 0)
marker.setIndex(UrlPart.PORT, 4) // == "今北産業".length
val url = marker.createUrl("今北産業")
assertNotNull(url.scheme)
assertNotNull(url.host)
assertNotNull(url.path)
assertNotNull(url.query)
assertNotNull(url.fragment)
assertEquals(443, url.port) // getPart(PORT) returns null → -1
}
}
@@ -739,6 +739,37 @@ class UriDetectionTest {
runTest("blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net", "blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net")
}
@Test
fun testBrokenCaseInProduction() {
runTest("今北産業")
runTest("http://test.com今北産業", "http://test.com")
runTest("ftp://test.com今北産業", "ftp://test.com")
runTest("test.com今北産業", "test.com")
runTest("wss://test.com今北産業", "wss://test.com")
runTest("blossom:test.com今北産業", "blossom:test.com")
runTest("nostr:test.com今北産業", "nostr:test.com")
runTest("nostr:test今北産業", "nostr:test")
runTest("nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m")
runTest("今北産業http://test.com", "http://test.com")
runTest("今北産業ftp://test.com", "ftp://test.com")
runTest("今北産業test.com", "test.com")
runTest("今北産業wss://test.com", "wss://test.com")
runTest("今北産業blossom:test.com", "blossom:test.com")
runTest("今北産業nostr:test.com", "nostr:test.com")
runTest("今北産業nostr:test", "nostr:test")
runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m")
runTest("今北産業http://test.com今北産業", "http://test.com")
runTest("今北産業ftp://test.com今北産業", "ftp://test.com")
runTest("今北産業test.com今北産業", "test.com")
runTest("今北産業wss://test.com今北産業", "wss://test.com")
runTest("今北産業blossom:test.com今北産業", "blossom:test.com")
runTest("今北産業nostr:test.com今北産業", "nostr:test.com")
runTest("今北産業nostr:test今北産業", "nostr:test")
runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m")
}
@Test
fun testFullText() {
val text =
@@ -1,55 +0,0 @@
/*
* 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.quartz.utils
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSURLComponents
import swiftbridge.Rfc3986UriBridge
@OptIn(ExperimentalForeignApi::class)
actual object Rfc3986 {
private val rfc3986UriBridge = Rfc3986UriBridge()
actual fun normalize(uri: String): String =
rfc3986UriBridge
.normalizeUrlWithUrl(uri, null)
?.let { if (it.last() == '/') it else "$it/" } ?: throw Exception("Could not normalize URI: $uri")
actual fun isValidUrl(url: String): Boolean = rfc3986UriBridge.isUrlValidWithUrl(url)
actual fun normalizeAndRemoveFragment(url: String): String =
NSURLComponents(url)
.toStringNoFragment()
.internIfPossible()
actual fun host(url: String): String = rfc3986UriBridge.hostFromUriWithUrl(url, null) ?: throw Exception("Could not retrieve host from URL.")
}
fun NSURLComponents.toStringNoFragment(): String {
val sb = StringBuilder()
if (scheme != null) sb.append(scheme).append(":")
if (host != null) sb.append("//").append(host.toString())
if (path != null) sb.append(path)
if (query != null) sb.append("?").append(query)
return sb.toString()
}
@@ -1,31 +0,0 @@
/*
* 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.quartz.utils
import kotlinx.cinterop.ExperimentalForeignApi
import swiftbridge.UrlDetector
@Suppress("UNCHECKED_CAST")
@OptIn(ExperimentalForeignApi::class)
actual fun fastFindURLs(text: String): List<String> {
val detectorInstance = UrlDetector()
return detectorInstance.findURLsWithText(text) as List<String>
}
@@ -0,0 +1,152 @@
/*
* 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.quartz.nip19Bech32
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class NIP19EmbedTests {
@Test
fun testEmbedKind1Event() =
runTest {
val signer =
NostrSignerInternal(
KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")),
)
val textNote =
signer.sign(
TextNoteEvent.build("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size."),
)
assertNotNull(textNote)
val bech32 = NEmbed.create(textNote)
println(bech32)
val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event
assertTrue(decodedNote.verify())
assertEquals(textNote.toJson(), decodedNote.toJson())
}
@Test
fun testVisionPrescriptionEmbedEvent() =
runTest {
val signer =
NostrSignerInternal(
KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")),
)
val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionFhir))
assertNotNull(eyeglassesPrescriptionEvent)
val bech32 = NEmbed.create(eyeglassesPrescriptionEvent)
println(eyeglassesPrescriptionEvent.toJson())
println(bech32)
val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event
assertTrue(decodedNote.verify())
assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson())
}
@Test
fun testVisionPrescriptionBundleEmbedEvent() =
runTest {
val signer =
NostrSignerInternal(
KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")),
)
val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle))
assertNotNull(eyeglassesPrescriptionEvent)
val bech32 = NEmbed.create(eyeglassesPrescriptionEvent)
println(eyeglassesPrescriptionEvent.toJson())
println(bech32)
val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event
assertTrue(decodedNote.verify())
assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson())
}
@Test
fun testVisionPrescriptionBundle2EmbedEvent() =
runTest {
val signer =
NostrSignerInternal(
KeyPair(decodePrivateKeyAsHexOrNull("nsec1arn3jlxv20y76n8ek8ydecy9ga06rl7aw8evznjylc3ap00hwkvqx4vvy6")!!.hexToByteArray()),
)
val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle2))
assertNotNull(eyeglassesPrescriptionEvent)
val bech32 = NEmbed.create(eyeglassesPrescriptionEvent)
println(eyeglassesPrescriptionEvent.toJson())
println(bech32)
val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event
assertTrue(decodedNote.verify())
assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson())
}
@Test
fun testTimsNembed() {
val uri = "nembed1r79ssq9446hkwqhl642ukmku8qg0c92pu7w3j0jyfte8tc7tvg85vmrys8x3sqgle5vjy7jpjswqhphl0kd6yf4sz0n3peyjq5rp3zkat4w6c6j3f7um0724jmfu5456xxgg2yxkn8dp23j64xsn9npcggzafyh2effyntqrqxzja8dp52kpcvc9zqxlj86e8mx05vevzxkeprjkfs4wmppxm3p96vj6yvu2mqgf5l4v99492r2qsggquxuv93uzx244652h2kkj8xseg9xkq0afpygknjtty9j4ju5v0nm9mezux9wyl6s5wr7lzce7cj397mnu0u04ha7aq3w7exelrhe3zs3l3urwa9sp36u80npllrs0hmsxqdn0fsuyav3nv0azjs5suzuurg2uymncjxez8p9xksc2j6gw992enjflgrdd7n5uq2xrpvfrd3rckw624ey0elvm6grr27tyzlf4vaswgm5vc3hdyczsl983g2j8e67r6z5zt30lat84ma4wclkwwxxrcflvdsuwd7346h7zqav4vdwe3gkt9lr87sfk4aqd2aey03tt4eyspldrqcmkx9pqe2pn63rv7grwwalr86akuldnvjm6m87wrw9sdwns8wq0rnsmj57vqwtc3g7hkwum3vl2dda78dwkycgfzw6qna3ufhpatcvq5a4hm4ehl45an8umwt0clf7rn77ctke475qglwu86hhfwhn7dkca4pkfpyc4y75rll6nvr5qc8nlhf8mk22celn5mecvyuzxd830drhdck9tcdpcafymk8wajwu2w8ha8gatggjfvq0a4jlf2sdamzj0ysqks9dk8me3q7a0qpmf6vykurkrcls4pug3u4pn4u26ezx3h8e482n07x2nsmu80dpufxqc0ttcyzhnppguxma4d8aumdawnlsyy7yzcuxl7lw5y9p4nv5h8fn6u8anpm2tsze3p6mgxy9j9uuqfxg2jvlmtjpakna5m4hln0msmw804hnun96h66fh62270yhhljnmmdl7jln07ll5vft7e870hemcld34a09n943ed6629fgtctsftma9q6tf4jfm2p0ukd2j2n2dpz53fqrkk4ctdcy2j5jar095g5jntf6u807ggkzauzt6uqkwk4tg5w7w55kskspc9663zx5dzzzfwpg3q546g2ve4kukr70n0a46eyce2crsqqq247ql5"
val decodedNote = (Nip19Parser.uriToRoute(uri)?.entity as NEmbed).event
assertTrue(decodedNote.verify())
assertEquals(timsPrescription, decodedNote.toJson())
}
val visionPrescriptionFhir = "{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"Patient/Donald Duck\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"Practitioner/Adam Careful\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}"
val visionPrescriptionBundle = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Careful\",\"given\":[\"Adam\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Duck\",\"given\":[\"Donald\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"#2\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}"
val visionPrescriptionBundle2 = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Smith\",\"given\":[\"Dr. Joe\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Doe\",\"given\":[\"Jane\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}"
val timsPrescription = "{\"id\":\"18d8b22e6455dfc9f4c6d6be8c2cf015e961b8d160dfe5e4b7fc1578f2c4e0be\",\"pubkey\":\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\",\"created_at\":1739566773,\"kind\":82,\"tags\":[[\"p\",\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\"]],\"content\":\"{\\\"resourceType\\\": \\\"VisionPrescription\\\", \\\"id\\\": \\\"eyeglass-prescription-001\\\", \\\"status\\\": \\\"active\\\", \\\"created\\\": \\\"2025-02-14T10:00:00Z\\\", \\\"patient\\\": {\\\"reference\\\": \\\"Patient/12345\\\", \\\"display\\\": \\\"John Doe\\\"}, \\\"encounter\\\": {\\\"reference\\\": \\\"Encounter/67890\\\"}, \\\"dateWritten\\\": \\\"2025-02-10T15:00:00Z\\\", \\\"prescriber\\\": {\\\"reference\\\": \\\"Practitioner/56789\\\", \\\"display\\\": \\\"Dr. Emily Smith\\\"}, \\\"lensSpecification\\\": [{\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"right\\\", \\\"sphere\\\": -2.5, \\\"cylinder\\\": -1.0, \\\"axis\\\": 180, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"up\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Right eye prescription for near-sightedness with astigmatism.\\\"}]}, {\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"left\\\", \\\"sphere\\\": -3.0, \\\"cylinder\\\": -0.75, \\\"axis\\\": 160, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"down\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Left eye prescription for near-sightedness with astigmatism.\\\"}]}]}\",\"sig\":\"d22d3b86aea397094de8b6cdf69decdfd886c90008aeebf95fd43a2770b37d486b313bff5bdb44b78e33bbaa3f336d74ee8b36bc5b16050374054246c72d93c2\"}"
}
@@ -1,65 +0,0 @@
/*
* 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.quartz.utils
import org.czeal.rfc3986.URIReference
actual object Rfc3986 {
actual fun normalize(uri: String) =
URIReference
.parse(uri)
.normalize()
.toString()
actual fun isValidUrl(url: String): Boolean =
runCatching {
URIReference.parse(url)
}.isSuccess
actual fun normalizeAndRemoveFragment(url: String): String =
URIReference
.parse(url)
.normalize()
.toStringNoFragment()
.intern()
actual fun host(url: String): String = URIReference.parse(url).host.value
}
fun URIReference.toStringSchemeHost(): String {
val sb = StringBuilder()
if (scheme != null) sb.append(scheme).append(":")
if (authority != null) sb.append("//").append(authority.toString())
return sb.toString()
}
fun URIReference.toStringNoFragment(): String {
val sb = StringBuilder()
if (scheme != null) sb.append(scheme).append(":")
if (authority != null) sb.append("//").append(authority.toString())
if (path != null) sb.append(path)
if (query != null) sb.append("?").append(query)
return sb.toString()
}
@@ -1,25 +0,0 @@
/*
* 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.quartz.utils
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
actual fun fastFindURLs(text: String): List<String> = UrlDetector(text).detect().map { it.originalUrl }
@@ -1,23 +0,0 @@
//
// Created by NullDev on 31/12/2025.
//
import Foundation
import RFC_3986
@objcMembers public class Rfc3986UriBridge: NSObject {
public func normalizeUrl(url: String) throws -> String {
let uri = try RFC_3986.URI(url)
let normalized = uri.normalized()
return normalized.value
}
public func isUrlValid(url: String) -> Bool {
return RFC_3986.isValidURI(url)
}
public func hostFromUri(url: String) throws -> String {
let actualUri = try RFC_3986.URI(url)
return actualUri.host!
}
}
@@ -1,21 +0,0 @@
//
// Created by NullDev on 20/01/2026.
//
import Foundation
@objcMembers public class UrlDetector: NSObject {
public func findURLs(text: String) -> [String] {
var links = [String]()
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let matches = detector.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
for match in matches {
guard let range = Range(match.range, in: text) else { continue }
let url = text[range]
links.append(String(url))
}
return links
}
}
+46 -9
View File
@@ -1,14 +1,51 @@
name: Amethyst
description: The all-in-one Nostr client
license: MIT
builder: npub142gywvjkq0dv6nupggyn2euhx4nduwc7yz5f24ah9rpmunr2s39se3xrj0
# ═══════════════════════════════════════════════════════════════════
# SOURCE CONFIGURATION
# ═══════════════════════════════════════════════════════════════════
# Source code repository URL or NIP-34 naddr (for display in app store)
repository: https://github.com/vitorpamplona/amethyst
homepage: https://amethyst.social/
assets:
- amethyst-googleplay.*.apk
remote_metadata:
- github
# ═══════════════════════════════════════════════════════════════════
# APP METADATA
# ═══════════════════════════════════════════════════════════════════
# App name (overrides APK label)
name: Amethyst
# Short one-line description
summary: The all-in-one Nostr client
# Full description (supports markdown)
description: |
A privacy-focused Nostr client for Android. Built-in TOR support, the most configurable relay system,
encrypted messaging, zaps, marketplaces, live streams and complete data sovereignty.
# Category tags
tags:
- social-network
- nostr
# SPDX license identifier
license: MIT
# App homepage
website: https://amethyst.social/
# ═══════════════════════════════════════════════════════════════════
# MEDIA
# ═══════════════════════════════════════════════════════════════════
# Screenshots (local paths or URLs)
images:
- ./docs/screenshots/home.png
- ./docs/screenshots/messages.png
- ./docs/screenshots/notifications.png
- ./docs/screenshots/replies.png
# ═══════════════════════════════════════════════════════════════════
# VARIANTS
# ═══════════════════════════════════════════════════════════════════
# APK variant patterns (for apps with multiple builds)
variants:
fdroid: ".*-fdroid-.*\\.apk$"
google: ".*-googleplay-.*\\.apk$"