Compare commits

...
109 Commits
Author SHA1 Message Date
Vitor Pamplona 69099728b2 v1.07.3 2026-03-31 17:32:23 -04:00
Vitor PamplonaandGitHub 190b99d55a Merge pull request #2060 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-31 17:31:30 -04:00
Crowdin Bot 95adb53640 New Crowdin translations by GitHub Action 2026-03-31 21:29:37 +00:00
davotoula 25a4b68958 update translations: CZ, DE, PT, SE 2026-03-31 23:26:48 +02:00
David KasparandGitHub e91c793f73 Merge pull request #2059 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-31 23:21:41 +02:00
David KasparandGitHub 5b477c64cf Merge branch 'main' into l10n_crowdin_translations 2026-03-31 23:21:32 +02:00
Crowdin Bot 11ca2c14be New Crowdin translations by GitHub Action 2026-03-31 21:07:39 +00:00
Vitor Pamplona 1b39bf1d52 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-03-31 17:05:26 -04:00
Vitor Pamplona a4e3625f26 Migrates to the new 10008 profile badges kind 2026-03-31 16:52:24 -04:00
Crowdin Bot e75b5d173b New Crowdin translations by GitHub Action 2026-03-31 20:25:02 +00:00
Vitor PamplonaandGitHub 027cc660e2 Merge pull request #2058 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-31 16:24:28 -04:00
Vitor Pamplona 49850e773f Adds a space after inserting the username to avoid issues in some keyboards. 2026-03-31 16:22:57 -04:00
Vitor Pamplona 44d100f60a Fixes file name 2026-03-31 16:11:45 -04:00
Crowdin Bot f110436853 New Crowdin translations by GitHub Action 2026-03-31 19:47:41 +00:00
Vitor Pamplona aebbe0974a Fixes wrong filter 2026-03-31 15:45:34 -04:00
Vitor Pamplona a79ceb527f Adding filters by community and location to the new side feeds 2026-03-31 15:23:50 -04:00
Vitor Pamplona 29bb0d2682 Dynamic filters for Longs, Shorts, Polls and Pictures 2026-03-31 14:44:56 -04:00
Vitor Pamplona 794af6eb2b Adds Long Video Menu item 2026-03-31 13:24:55 -04:00
Vitor Pamplona 3e83762761 Adds new TopNav Filter settings 2026-03-31 13:24:37 -04:00
Vitor Pamplona c6e1dc9d79 Fixes dal filters for Shorts 2026-03-31 12:58:53 -04:00
Vitor Pamplona 6354b32205 Adds a Shorts Feed 2026-03-31 12:22:33 -04:00
Vitor Pamplona 6618644ba3 No more pages in the video tab 2026-03-31 11:45:08 -04:00
Vitor PamplonaandGitHub 329d133291 Merge pull request #2057 from vitorpamplona/claude/instagram-picture-feed-9sBzo
Add Pictures feed screen with NIP-68 picture event support
2026-03-31 10:45:00 -04:00
Vitor Pamplona 3fec18b9a8 Improvements to the old bookmarks screen 2026-03-31 10:40:24 -04:00
Vitor PamplonaandGitHub de395f5629 Merge pull request #2056 from vitorpamplona/claude/migrate-bookmark-event-86Biv
Add support for legacy NIP-51 bookmark list (kind 30001)
2026-03-31 10:22:24 -04:00
Claude 55a1626159 fix: merge old bookmarks into existing new bookmarks during migration
Instead of replacing the new bookmark list, the migration now checks for
an existing kind 10003 event and merges old bookmarks into it, skipping
duplicates that already exist.

https://claude.ai/code/session_01U9sjQHQMVVHxYiesoXjqop
2026-03-31 13:31:29 +00:00
Vitor Pamplona 581f13eea8 This code is already running on IO 2026-03-31 09:25:41 -04:00
Claude b960a4692a feat: migrate BookmarkListEvent from kind 30001 to 10003
Rename the existing BookmarkListEvent (kind 30001) to OldBookmarkListEvent
and create a new BookmarkListEvent with kind 10003 as per the updated spec.

- OldBookmarkListEvent (kind 30001): Kept as-is for backward compatibility,
  displayed as "Old Bookmarks" in the Bookmark Lists screen
- BookmarkListEvent (kind 10003): New replaceable event, all new bookmark
  operations (save/check/remove) use this kind
- Both kinds are displayed as separate items in the Bookmark Lists screen
- Old Bookmarks screen includes a "Move All to New Bookmarks" button that
  migrates public bookmarks to public and private to private
- Created PrivateReplaceableTagArrayEvent base class for replaceable events
  with encrypted private tags (kind 10003 is in the 10000-19999 range)
- Updated all relay filters, search queries, and profile views to fetch
  both kinds
- Updated Android and Desktop modules

https://claude.ai/code/session_01U9sjQHQMVVHxYiesoXjqop
2026-03-31 13:24:27 +00:00
Vitor PamplonaandGitHub 4256e3f3cf Merge pull request #2055 from vitorpamplona/claude/migrate-quartz-nip58-XQ8bw
Refactor NIP-58 Badge events with improved tag handling
2026-03-31 09:17:19 -04:00
Vitor PamplonaandGitHub f291bb126a Merge branch 'main' into claude/migrate-quartz-nip58-XQ8bw 2026-03-31 09:17:11 -04:00
Vitor PamplonaandGitHub e5c8e6dd7f Merge pull request #2054 from vitorpamplona/claude/edit-field-visual-transform-GDY9G
Refactor user mention parsing to support nprofile and nostr: URIs
2026-03-31 09:09:59 -04:00
Vitor Pamplona bbeb2a66ee Fixes bad merge 2026-03-31 09:06:12 -04:00
Vitor Pamplona 7ec5357653 Lint 2026-03-31 09:04:42 -04:00
Vitor PamplonaandGitHub 96a2645070 Merge pull request #2053 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-31 09:02:17 -04:00
Crowdin Bot bc8b73e458 New Crowdin translations by GitHub Action 2026-03-31 13:01:46 +00:00
Vitor PamplonaandGitHub da96180a10 Merge pull request #2046 from vitorpamplona/claude/add-reaction-notifications-NzyLO
Add push notifications for post reactions
2026-03-31 09:01:43 -04:00
Vitor PamplonaandGitHub 4dbd833c0d Merge pull request #2048 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-31 09:01:32 -04:00
Crowdin Bot d5f27c7994 New Crowdin translations by GitHub Action 2026-03-31 13:01:19 +00:00
Claude 20425a7678 feat: add @Preview composables for picture feed UI components
Adds 6 preview functions covering:
- PictureCardCompose with title, without title, and multi-image
- PictureCardCaption with title, without title, and long content
- Uses ThemeComparisonColumn for light/dark theme comparison
- Uses mock PictureEvent JSON data consumed via LocalCache

https://claude.ai/code/session_01KtuzFmDZXk67tW8QxPqmMy
2026-03-31 13:00:29 +00:00
Vitor Pamplona 26798e2b57 Fixes assertion 2026-03-31 08:59:38 -04:00
Vitor Pamplona 9cb7f9cc21 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-03-31 08:57:49 -04:00
Vitor PamplonaandGitHub 094adcd6f6 Merge branch 'main' into claude/add-reaction-notifications-NzyLO 2026-03-31 08:47:21 -04:00
Vitor PamplonaandGitHub 3dbd79d879 Merge pull request #2052 from vitorpamplona/claude/fix-sorting-order-p23ZY
Improve sorting stability by adding secondary sort keys
2026-03-31 08:42:46 -04:00
Vitor PamplonaandGitHub 126d58e2d1 Merge pull request #2051 from vitorpamplona/claude/fix-quote-cursor-position-QOw9L
Fix quote placement to begin at start of message
2026-03-31 08:41:54 -04:00
Vitor PamplonaandGitHub 87d0d2e768 Merge pull request #2049 from vitorpamplona/claude/tor-exception-handling-2Qy1u
Add error handling to Tor service initialization and control
2026-03-31 08:29:03 -04:00
Vitor PamplonaandGitHub 50470771b0 Merge pull request #2050 from vitorpamplona/claude/simplify-localcache-redirects-9vbb7
Refactor event consumption to use base methods directly
2026-03-31 08:28:30 -04:00
Claude acd0a13abf fix: catch exceptions from Tor library to prevent app crashes
Wrap all Tor library interactions in try-catch blocks so that failures
in the Tor service (JNI, control connection, bind/unbind) are logged
and gracefully degrade to TorServiceStatus.Off instead of crashing
the entire app.

https://claude.ai/code/session_019JDZMTvVdJyc9z2WmoAimv
2026-03-31 12:07:09 +00:00
David KasparandGitHub bb6bd3f0ae Merge pull request #1910 from davotoula/chess-enhancements-and-bug-fixes
Chess enhancements and bug fixes
2026-03-31 09:06:36 +02:00
davotoula 4d1e9cb8d5 Merge branch 'main-upstream' into chess-enhancements-and-bug-fixes 2026-03-31 07:26:39 +02:00
Claude abeccb23a2 feat: support npub and nprofile with nostr: prefix in edit field visual transformation
The visual transformation for message edit fields now processes nostr:npub1...,
nostr:nprofile1..., and @nprofile1... mentions in addition to the existing @npub1...
pattern. Both UrlUserTagOutputTransformation (new API) and UrlUserTagTransformation
(old API) are updated to resolve these to user display names.

https://claude.ai/code/session_012ijrLmft5PjcQ64zDwXJWj
2026-03-31 04:23:50 +00:00
Claude d57c03a01a feat: add Instagram-style picture feed for kind 20 events
Implements a dedicated picture feed screen with a custom card layout:
- Full-width image display with user avatar and name header
- Title and 3-line content preview below the image
- Reaction row (reply, boost, like, zap, share) at the bottom
- Top bar with follow list filter (same pattern as polls feed)
- Complete relay subscription pipeline for picture events
- Accessible from the navigation drawer

https://claude.ai/code/session_01KtuzFmDZXk67tW8QxPqmMy
2026-03-31 04:10:27 +00:00
Claude be1ad9cdb8 refactor: remove ~94 redirect consume functions from LocalCache
Replace typed consume() overloads that simply forwarded to
consumeRegularEvent() or consumeBaseReplaceable() with direct calls
in the when dispatch block. This removes ~600 lines of boilerplate
without changing behavior.

https://claude.ai/code/session_017dM6dt1on3g3yhWSi69Pwq
2026-03-31 04:06:02 +00:00
Claude 3a850ba26c fix: place cursor at beginning of text field when quoting a note
When a user hits Quote, the new post screen opens with the nostr URI
pre-filled but the cursor was at the end (after the URI). This moves
the cursor to the beginning so the user can immediately start typing
their commentary.

https://claude.ai/code/session_01RPseKC9GJ8hs3GGBR85ezw
2026-03-31 04:01:05 +00:00
Claude 96c4092138 fix: ensure secondary sort by id ascending when sorting by createdAt descending
When events share the same createdAt timestamp, the tiebreaker sort by id
must be ascending to produce a stable, deterministic order. Fixed several
locations that either had no secondary sort, used descending id sort, or
used .reversed() which incorrectly flipped both sort directions.

https://claude.ai/code/session_01RvuyPf1x9wLf2DCgsSXLGz
2026-03-31 03:45:24 +00:00
Claude a4bf093cb3 refactor: migrate NIP-58 badges to nip88Polls-style sub-package structure
Restructures the flat nip58Badges package into sub-packages following
the established nip88Polls pattern with proper tag classes, TagArrayExt,
and TagArrayBuilderExt for each event type:

- definition/ - BadgeDefinitionEvent (kind 30009) with NameTag, ImageTag,
  DescriptionTag, ThumbTag supporting dimensions per spec
- award/ - BadgeAwardEvent (kind 8) with build() method
- profiles/ - BadgeProfilesEvent (kind 30008) with AcceptedBadge tag
  for proper paired a+e tag parsing per NIP-58 spec

Adds build() companion methods to all three event types and full
spec compliance including image/thumb dimension support.

https://claude.ai/code/session_019Uvxfa7jJbjeprLxECoJ8s
2026-03-31 03:08:25 +00:00
Vitor Pamplona c5edcab051 Fixes the loading of old labeled bookmarks 2026-03-30 22:03:51 -04:00
Claude a7f60082f0 feat: add push notifications for reactions
Add a new notification channel for reactions (kind 7 events) so users
get notified when someone reacts to their posts. Follows the same
pattern as zap notifications with deduplication, time-window filtering,
and grouped summaries.

https://claude.ai/code/session_01J9rAA4oeFEfNcYD6GoJqqt
2026-03-30 21:51:03 +00:00
Vitor PamplonaandGitHub 91f5dffc6d Merge pull request #2045 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-30 16:52:24 -04:00
Vitor Pamplona 8e4ee7a618 v1.07.2 2026-03-30 16:46:01 -04:00
Crowdin Bot 332bfcd88a New Crowdin translations by GitHub Action 2026-03-30 20:39:46 +00:00
Vitor PamplonaandGitHub 1872a5879e Merge pull request #2042 from greenart7c3/main
Assign `rejected` based on the presence of the "rejected" key in intent extras rather than retrieving its specific boolean value
2026-03-30 16:39:13 -04:00
Vitor PamplonaandGitHub a293976501 Merge pull request #2043 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-30 16:38:44 -04:00
Vitor PamplonaandGitHub a1095b4c17 Merge pull request #2044 from vitorpamplona/claude/add-gif-keyboard-support-ahCJX
Add image paste support to message input field
2026-03-30 16:38:14 -04:00
Claude 95d678f62f feat: Add GIF keyboard support to short note post screen
Enable the message field to receive images/GIFs from Android's keyboard
using Compose Foundation's contentReceiver API. When content is received,
it's routed through the existing selectImage flow for upload.

https://claude.ai/code/session_019GfY3MtCPcYn3wqTHCMSNu
2026-03-30 20:33:55 +00:00
Crowdin Bot b625ac6d2d New Crowdin translations by GitHub Action 2026-03-30 20:26:29 +00:00
Vitor PamplonaandGitHub f848ad20d8 Merge pull request #2041 from greenart7c3/fix_external_signer
fix: support external signers wrapped in NostrSignerWithClientTag
2026-03-30 16:24:56 -04:00
greenart7c3 198e049873 Assign rejected based on the presence of the "rejected" key in intent extras rather than retrieving its specific boolean value 2026-03-30 17:18:02 -03:00
greenart7c3 f2d007f9ba fix: support external signers wrapped in NostrSignerWithClientTag
The external signer registration logic was failing when the signer was
wrapped in a `NostrSignerWithClientTag`. Fix by:

1. Updating `ListenToExternalSignerIfNeeded` to unwrap the signer if it's
   a `NostrSignerWithClientTag` containing an `IActivityLauncher`
2. Using the unwrapped launcher for intent registration and response handling
3. Renaming the intent callback to `intentLauncher` to avoid shadowing the `ActivityResultLauncher` variable
2026-03-30 17:09:59 -03:00
Vitor Pamplona 4b9c3f239c Fixes version mismatch 2026-03-30 16:02:55 -04:00
Vitor Pamplona 3ac04cdc63 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-03-30 15:59:14 -04:00
Vitor PamplonaandGitHub f9d231063d Merge pull request #2040 from vitorpamplona/claude/migrate-textfield-state-6ezEZ
Migrate text fields from TextFieldValue to TextFieldState
2026-03-30 15:58:19 -04:00
Vitor PamplonaandGitHub 4bca74888d Merge pull request #2039 from vitorpamplona/claude/relay-manager-user-search-jivZ2
Replace hex input with user search dialog in relay management
2026-03-30 15:46:03 -04:00
Vitor PamplonaandGitHub 17ff27bac9 Merge pull request #2038 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-30 15:00:48 -04:00
Vitor Pamplona 3913384fcf Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-03-30 15:00:32 -04:00
Vitor Pamplona b02b8c4ed9 new change logs 2026-03-30 14:57:13 -04:00
Crowdin Bot ed31b12e3f New Crowdin translations by GitHub Action 2026-03-30 18:50:03 +00:00
Vitor PamplonaandGitHub 3b63264927 Merge pull request #2036 from Nam0101/contribai/docs/undocumented-identity-based-comparison-i
Docs: Undocumented identity-based comparison in `equalImmutableLists`
2026-03-30 14:48:27 -04:00
Vitor Pamplona 640c47e29f v1.07.1 2026-03-30 13:53:25 -04:00
Vitor PamplonaandGitHub 3dd1b949eb Merge pull request #2037 from vitorpamplona/claude/threadsafe-without-synchronized-Zw8Tx
Replace synchronized queue with Channel and AtomicBoolean
2026-03-30 13:51:20 -04:00
Claude 13bd492e32 refactor: replace synchronized with lock-free CAS in BleChunkAssembler
Use AtomicReference holding an immutable list with a compare-and-set loop,
eliminating the synchronized block while maintaining thread safety.

https://claude.ai/code/session_01KLRz7FJgWRtgCcZe3qD1VX
2026-03-30 17:48:58 +00:00
Claude ef8b9be0e8 refactor: replace synchronized with Channel + AtomicBoolean in BLE relay classes
Use Channel(UNLIMITED) as a lock-free send queue and AtomicBoolean with
atomic exchange for the isSending flag, eliminating all synchronized blocks
while maintaining thread safety via a double-check pattern.

https://claude.ai/code/session_01KLRz7FJgWRtgCcZe3qD1VX
2026-03-30 17:37:23 +00:00
Nguyen Van Nam bc491b154b docs: undocumented identity-based comparison in equalimmutablelists
`equalImmutableLists` is public and uses referential equality (`===`) per element rather than structural equality (`==`). Without documentation, callers may assume normal list equality and get false negatives for value-equal but distinct instances. This is a non-obvious contract and should be documented explicitly to prevent subtle logic bugs.


Affected files: ListUtils.kt

Signed-off-by: Nguyen Van Nam <nam.nv205106@gmail.com>
2026-03-30 22:48:41 +07:00
davotoula a7259b21ee Merge branch 'main-upstream' into chess-enhancements-and-bug-fixes 2026-03-30 17:40:40 +02:00
davotoula f76fc23840 update logging to new style 2026-03-30 16:42:38 +02:00
davotoulaandClaude Opus 4.6 43914d655c chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:52:02 +02:00
davotoula 40ce80786e update logging to new style 2026-03-30 14:50:42 +02:00
davotoula 17f071b0f4 Merge branch 'main' into chess-enhancements-and-bug-fixes
# Conflicts:
#	commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt
2026-03-30 13:14:33 +02:00
Claude 4d8583eed9 feat: replace hex input with user search in relay manager allow/ban dialogs
Users can now search by name, npub, or NIP-05 identifier when adding
users to relay allow/ban lists, using the same UserSuggestionState
mechanism as the My List screens. The hex pubkey input was not
user-friendly since most users don't know their hex representation.

https://claude.ai/code/session_01SWReoziLKJKxrbwDWprQYs
2026-03-30 03:43:51 +00:00
Claude 72bc7e0ed5 style: apply linter formatting to TextFieldState migration
https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ
2026-03-28 19:13:18 +00:00
Claude f997586e4b feat: migrate remaining IMessageField implementors to TextFieldState
Completes the TextFieldState migration for ShortNotePostViewModel,
LongFormPostViewModel, and their associated post screens (GeoHash,
Hashtag, ShortNote, LongForm).

https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ
2026-03-28 19:11:09 +00:00
Claude 1d208edd33 feat: migrate ThinPaddingTextField to BasicTextField(TextFieldState) for GIF keyboard support
Migrates all new post screens from the old BasicTextField(TextFieldValue)
to the new BasicTextField(TextFieldState) API, which properly sets
EditorInfo.contentMimeTypes to enable GIF keyboard input.

Key changes:
- Add TextFieldState-based ThinPaddingTextField overload (keep old for readonly dropdowns)
- Create UrlUserTagOutputTransformation for the new OutputTransformation API
- Create TextFieldState extension functions (insertUrlAtCursor, replaceCurrentWord, currentWord)
- Update IMessageField interface to use TextFieldState + onMessageChanged()
- Migrate all IMessageField implementors: ChannelNewMessageViewModel,
  ChatNewMessageViewModel, NewProductViewModel, NewPublicMessageViewModel,
  CommentPostViewModel, ShortNotePostViewModel, LongFormPostViewModel
- Update PreviewState to accept String instead of TextFieldValue
- Add TextFieldState overload to UserSuggestionState.replaceCurrentWord()
- Update all post screen call sites

https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ
2026-03-28 19:09:38 +00:00
davotoula 7871a7672b Merge branch 'main' into chess-enhancements-and-bug-fixes 2026-03-25 09:10:46 +01:00
davotoulaandClaude Opus 4.6 86fd92e505 feat(chess): add dismiss/clear-all for finished games in lobby
Persist dismissed game IDs locally (SharedPreferences on Android,
java.util.prefs on Desktop) so completed games can be permanently
hidden from the chess lobby. Adds per-game dismiss button and
"Clear all" action when 2+ finished games exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:30:46 +01:00
davotoulaandClaude Opus 4.6 1f34a79676 fix: prevent double-fetch and stop re-polling finished chess games
- Mark recentlyLoadedGames timestamp eagerly in refreshGame (before
  async fetch) to prevent concurrent fetches for the same game
- Clear focused game in removeGameId when the removed game matches,
  so finished games stop being re-polled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 19:41:20 +01:00
davotoula 5bdc684f21 fix: add event-level dedup and handleGameAccepted guard in chess lobby
- Dedup incoming events by ID (bounded LRU set of 500) to prevent
  redundant processing when multiple relays deliver the same event
- Filter non-chess kind 30 events early (isStart=false, isMove=false)
- Guard handleGameAccepted with recentlyLoadedGames check to prevent
  N concurrent relay fetches when N move events arrive for same game
2026-03-24 19:29:06 +01:00
davotoula 944fbcd000 add extensive [chessdebug] logging across all chess layers
Uses Log.d("chessdebug", ...) via quartz's multiplatform Log utility
(android.util.Log on Android, println on Desktop) for proper logcat
integration.

Layers instrumented:
- [Reconstructor] state reconstruction, move replay, desync detection
- [Collector/CollectorMgr] event ingestion, dedup, routing
- [GameLoader] game loading, live state conversion
- [Lobby] challenges, moves, resign, spectating, polling refresh
- [Polling] polling cycles, focused mode
- [Broadcaster] relay connections, event send/confirm
- [LiveGame] move validation, opponent moves, head event tracking
- [AndroidVM] incoming event parsing from subscriptions

Filter with: adb logcat | grep chessdebug
2026-03-24 15:43:17 +01:00
davotoula da53001e65 Merge branch 'main' into chess-enhancements-and-bug-fixes 2026-03-24 13:21:41 +01:00
davotoula 6c8021f219 code review fixes:
Deduplicate chess notify() into shared notifyChessEvent() helper using BaseChessEvent
Remove addCompletedGameDirectly, extend moveToCompleted with optional liveState param
Hoist currentUser lookup to top of ChessLobbyContent, remove 4 duplicate remembers
Add missing imports in desktop ChessScreen, replace all FQN usages with short names
Remove redundant distinctBy in completed games UI (state already prevents duplicates)
2026-03-23 20:39:54 +01:00
davotoulaandClaude Opus 4.6 a6205a29c4 fix(chess): add chess kinds to notification relay subscription
Chess event kinds 30065 (accept) and 30066 (move) were missing from the
notification relay subscription filters, so they were never fetched from
relays for the in-app notification feed. Also bypass the follow-list
filter for chess events since opponents may not be followed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 19:31:11 +01:00
davotoulaandClaude Opus 4.6 c86daa6950 fix(chess): filter own games from live list, dismissable errors, clickable avatars and completed games
- Filter user's own games from public "Live Games" list in lobby
- Fix broken addSpectatingGame filter (playerPubkey is always viewerPubkey)
- Add removeGame() to clean up stale entries when "Game not found"
- Make player avatars clickable to open profile on game screen
- Make completed game cards clickable to view final board state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 19:31:11 +01:00
davotoulaandClaude Opus 4.6 08d5f2ebb5 fix(chess): resolve TimeUtils compilation error in ChessLobbyState
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 19:31:11 +01:00
davotoulaandClaude Opus 4.6 478c4cc356 feat(chess): notifications for challenge accepted and opponent moves
Add Android push notifications for chess events:
- LiveChessGameAcceptEvent (kind 30065): notifies when opponent accepts challenge
- LiveChessMoveEvent (kind 30066): notifies when opponent makes a move
- New Chess notification channel alongside existing DM and Zap channels
- Chess kinds added to NotificationFeedFilter for in-app notification feed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 19:31:11 +01:00
davotoulaandClaude Sonnet 4.6 292c6de08a feat(chess): exit spectator mode with Leave Game button
Adds ability to leave a spectated game on both Android and Desktop:
- Added stopSpectating() to ChessLobbyLogic (removes from state + stops polling)
- Both ViewModels now delegate to logic.stopSpectating() instead of bypassing it
- LiveChessGameScreen accepts onLeaveSpectating callback shown in spectator info area
- Android: Leave Game button pops back stack and stops spectating
- Desktop: Leave Game button clears selectedGameId (returns to lobby) and stops spectating

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:31:11 +01:00
davotoulaandClaude Sonnet 4.6 ed9d942ce0 feat(chess): finished games section below active games in lobby
Adds a "Finished Games" section to the Android chess lobby screen,
showing up to 10 completed games with opponent avatars, result
(Won/Lost/Draw), and move count. The section only appears when
completedGames is non-empty, matching the Desktop implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:31:11 +01:00
davotoulaandClaude Sonnet 4.6 012e97494e feat(chess): add rank numbers (1-8) to board coordinates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:31:10 +01:00
davotoulaandClaude Opus 4.6 006a2229fe feat(chess): avatar vs avatar display on board and game list
Add ChessPlayerChip.kt with three shared composables:
- ChessPlayerChip: single player avatar + name with active border
- ChessPlayerVsHeader: mirrored White vs Black header above the board
- OverlappingAvatars: compact overlapping circular avatars for list cards

Wire avatars into:
- LiveChessGameScreen: vs header with active player green border
- DesktopChessGameLayout: vs header above the chess board
- Android & Desktop lobby cards: overlapping avatars in ActiveGameCard,
  ChallengeCard, and OutgoingChallengeCard avatar slots

Uses existing UserAvatar (coil3 AsyncImage + Robohash fallback) from
commons for KMP compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 19:31:10 +01:00
davotoulaandClaude Sonnet 4.6 8c69105fdb fix(chess): exclude own games from spectator list
Skip adding games to spectatingGames when the user is a participant
(playerPubkey or opponentPubkey matches userPubkey) or when the game
is already finished.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:31:10 +01:00
davotoulaandClaude Opus 4.6 638bdb7b2d fix(chess): finished games transition from active to completed
Wire onGameEndDismiss callback so the "Continue" button on game end
overlay moves the game to the completed list. Add auto-detection of
finished games during polling refresh so games transition even if the
user navigates away without clicking the button. Discovered games that
are already finished now go straight to completed instead of appearing
in the active list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 19:31:10 +01:00
davotoulaandClaude Opus 4.6 2004caa53c fix(chess): board interactive on mobile at game start
The board was non-interactive for participants during pending challenges
on Android because canMakeMoves factored in isPendingChallenge. Desktop
passed isSpectator directly without the pending check, so it worked.

Remove isPendingChallenge from the board interactivity gate to match
Desktop behavior. The header still shows "Challenge Pending" status,
and this also fixes a secondary issue where the isPendingChallenge flag
could get stuck when replaceGameState preserved the old state via its
open-challenge workaround.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 19:31:10 +01:00
210 changed files with 9476 additions and 1858 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 = 435
versionName = "1.07.0"
versionCode = 439
versionName = "1.07.3"
vectorDrawables {
useSupportLibrary = true
+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.07.0` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.3` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.07.0"
quartz = "1.07.3"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.07.0")
implementation("com.vitorpamplona.quartz:quartz:1.07.3")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.07.0
com.vitorpamplona.quartz:quartz:1.07.3
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.07.0"
quartz = "1.07.3"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.07.0")
implementation("com.vitorpamplona.quartz:quartz:1.07.3")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.07.0")
implementation("com.vitorpamplona.quartz:quartz:1.07.3")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
+1 -1
View File
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
## Current artifact
```
com.vitorpamplona.quartz:quartz:1.07.0
com.vitorpamplona.quartz:quartz:1.07.3
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
+3
View File
@@ -163,3 +163,6 @@ TASKS.md
desktopApp/src/jvmMain/appResources/linux/
desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/
# Git worktrees
.worktrees/
+36
View File
@@ -1,3 +1,39 @@
<a id="v1.07.3"></a>
# [Release v1.07.3: GIF Keyboard](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.3) - 2026-03-31
- Migrates Shorts UI out of a paged design
- New edge-to-edge feed for Pictures only
- New edge-to-edge feed for Shorts only
- New edge-to-edge feed for long Videos only
- Migrates Badges to kind 10008
- Migrates Bookmarks to kind 10003
- Fixes AOSP keyboard auto-correction bug
- Fixes Poll's top nav filter
- Fixes loading of Bookmark sets
- Fixes sorting stability by including event ids
- Fixes cursor position in quote post field
- Fixes rendering of nostr: uris when composing notes
- Adds Basic support for reaction notifications
- Refactors NIP-58 Badges on Quartz
- Refactors LocalCache methods
- Improvements to the Chess engine (debug only)
- Add error handling to Tor service initialization and control
<a id="v1.07.2"></a>
# [Release v1.07.2: GIF Keyboard](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.2) - 2026-03-30
- Adds GIF uploads support from Keyboard
- Migrates text fields and @ modifiers to the new Jetpack Compose states
- Fixes Bug that wasn't openning Amber to sign
- Fixes Bug on rejections using old Ambers
- Replaces hex input with user search dialog in relay management (allow/ban user)
<a id="v1.07.1"></a>
# [Release v1.07.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.1) - 2026-03-30
- New translations
- Fixes BLE's module need for synchronized events.
<a id="v1.07.0"></a>
# [Release v1.07.0: Vanish, Relay Management, Pin Notes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.0) - 2026-03-30
+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 = 436
versionName = generateVersionName("1.07.0")
versionCode = 439
versionName = generateVersionName("1.07.3")
buildConfigField "String", "RELEASE_NOTES_ID", "\"12cd4bce977ed53502cf121ecba89a190ab02685333c8f230bac35b04f920eeb\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -97,6 +97,10 @@ private object PrefKeys {
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
const val DEFAULT_LONGS_FOLLOW_LIST = "defaultLongsFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_USER_METADATA = "latestUserMetadata"
const val LATEST_CONTACT_LIST = "latestContactList"
@@ -331,6 +335,11 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNotificationFollowList.value))
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultDiscoveryFollowList.value))
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
putString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLongsFollowList.value))
putOrRemove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, settings.zapPaymentRequest.value?.denormalize())
putOrRemove(PrefKeys.LATEST_CONTACT_LIST, settings.backupContactList)
@@ -470,6 +479,12 @@ object LocalPreferences {
val defaultStoriesFollowListStr = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null)
val defaultNotificationFollowListStr = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null)
val defaultDiscoveryFollowListStr = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null)
val defaultPollsFollowListStr = getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null)
val defaultPicturesFollowListStr = getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null)
val defaultShortsFollowListStr = getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null)
val defaultLongsFollowListStr = getString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, null)
val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)
val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null)
@@ -501,6 +516,12 @@ object LocalPreferences {
val defaultStoriesFollowList = async { parseOrNull<TopFilter>(defaultStoriesFollowListStr) ?: TopFilter.Global }
val defaultNotificationFollowList = async { parseOrNull<TopFilter>(defaultNotificationFollowListStr) ?: TopFilter.Global }
val defaultDiscoveryFollowList = async { parseOrNull<TopFilter>(defaultDiscoveryFollowListStr) ?: TopFilter.Global }
val defaultPollsFollowList = async { parseOrNull<TopFilter>(defaultPollsFollowListStr) ?: TopFilter.Global }
val defaultPicturesFollowList = async { parseOrNull<TopFilter>(defaultPicturesFollowListStr) ?: TopFilter.Global }
val defaultShortsFollowList = async { parseOrNull<TopFilter>(defaultShortsFollowListStr) ?: TopFilter.Global }
val defaultLongsFollowList = async { parseOrNull<TopFilter>(defaultLongsFollowListStr) ?: TopFilter.Global }
val zapPaymentRequestServer = async { parseOrNull<Nip47WalletConnect.Nip47URI>(zapPaymentRequestServerStr) }
val defaultFileServer = async { parseOrNull<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] }
@@ -545,6 +566,10 @@ object LocalPreferences {
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList.await()),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList.await()),
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList.await()),
defaultPollsFollowList = MutableStateFlow(defaultPollsFollowList.await()),
defaultPicturesFollowList = MutableStateFlow(defaultPicturesFollowList.await()),
defaultShortsFollowList = MutableStateFlow(defaultShortsFollowList.await()),
defaultLongsFollowList = MutableStateFlow(defaultLongsFollowList.await()),
zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer.await()?.normalize()),
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
@@ -176,6 +177,7 @@ import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
@@ -224,6 +226,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
@@ -320,6 +323,7 @@ class Account(
val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings)
val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope)
val oldBookmarkState = OldBookmarkListState(signer, cache, scope)
val bookmarkState = BookmarkListState(signer, cache, scope)
val pinState = PinListState(signer, cache, scope)
val emoji = EmojiPackState(signer, cache, scope)
@@ -379,10 +383,9 @@ class Account(
geohashCache = geohashListDecryptionCache,
)
// App-ready Feeds
val liveHomeFollowLists: StateFlow<IFeedTopNavFilter> =
fun topNavFilterFlow(listName: MutableStateFlow<TopFilter>) =
FeedTopNavFilterState(
feedFilterListName = settings.defaultHomeFollowList,
feedFilterListName = listName,
kind3Follows = kind3FollowList.flow,
allFollows = allFollows.flow,
locationFlow = geolocationFlow,
@@ -394,56 +397,31 @@ class Account(
scope = scope,
).flow
// App-ready Feeds
val liveHomeFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultHomeFollowList)
val liveHomeFollowListsPerRelay = OutboxLoaderState(liveHomeFollowLists, cache, scope).flow
val liveStoriesFollowLists: StateFlow<IFeedTopNavFilter> =
FeedTopNavFilterState(
feedFilterListName = settings.defaultStoriesFollowList,
kind3Follows = kind3FollowList.flow,
allFollows = allFollows.flow,
locationFlow = geolocationFlow,
followsRelays = defaultGlobalRelays.flow,
blockedRelays = blockedRelayList.flow,
proxyRelays = proxyRelayList.flow,
caches = feedDecryptionCaches,
signer = signer,
scope = scope,
).flow
val liveStoriesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultStoriesFollowList)
val liveStoriesFollowListsPerRelay = OutboxLoaderState(liveStoriesFollowLists, cache, scope).flow
val liveDiscoveryFollowLists: StateFlow<IFeedTopNavFilter> =
FeedTopNavFilterState(
feedFilterListName = settings.defaultDiscoveryFollowList,
kind3Follows = kind3FollowList.flow,
allFollows = allFollows.flow,
locationFlow = geolocationFlow,
followsRelays = defaultGlobalRelays.flow,
blockedRelays = blockedRelayList.flow,
proxyRelays = proxyRelayList.flow,
caches = feedDecryptionCaches,
signer = signer,
scope = scope,
).flow
val liveDiscoveryFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultDiscoveryFollowList)
val liveDiscoveryFollowListsPerRelay = OutboxLoaderState(liveDiscoveryFollowLists, cache, scope).flow
val liveNotificationFollowLists: StateFlow<IFeedTopNavFilter> =
FeedTopNavFilterState(
feedFilterListName = settings.defaultNotificationFollowList,
kind3Follows = kind3FollowList.flow,
allFollows = allFollows.flow,
locationFlow = geolocationFlow,
followsRelays = defaultGlobalRelays.flow,
blockedRelays = blockedRelayList.flow,
proxyRelays = proxyRelayList.flow,
caches = feedDecryptionCaches,
signer = signer,
scope = scope,
).flow
val liveNotificationFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultNotificationFollowList)
val liveNotificationFollowListsPerRelay = OutboxLoaderState(liveNotificationFollowLists, cache, scope).flow
val livePollsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPollsFollowList)
val livePollsFollowListsPerRelay = OutboxLoaderState(livePollsFollowLists, cache, scope).flow
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
val liveShortsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultShortsFollowList)
val liveShortsFollowListsPerRelay = OutboxLoaderState(liveShortsFollowLists, cache, scope).flow
val liveLongsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultLongsFollowList)
val liveLongsFollowListsPerRelay = OutboxLoaderState(liveLongsFollowLists, cache, scope).flow
override fun isWriteable(): Boolean = settings.isWriteable()
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
@@ -1815,6 +1793,49 @@ class Account(
cache.justConsumeMyOwnEvent(event)
}
suspend fun migrateOldBookmarksToNew() {
if (!isWriteable()) return
val oldList = oldBookmarkState.getBookmarkList() ?: return
val oldPublic = oldList.publicBookmarks()
val oldPrivate = oldList.privateBookmarks(signer) ?: emptyList()
if (oldPublic.isEmpty() && oldPrivate.isEmpty()) return
val existingNewList = bookmarkState.getBookmarkList()
val newEvent =
if (existingNewList != null) {
val existingPublic = existingNewList.publicBookmarks()
val existingPrivate = existingNewList.privateBookmarks(signer) ?: emptyList()
val existingPublicIds = existingPublic.map { it.toTagIdOnly().toList() }.toSet()
val existingPrivateIds = existingPrivate.map { it.toTagIdOnly().toList() }.toSet()
val newPublic = oldPublic.filter { it.toTagIdOnly().toList() !in existingPublicIds }
val newPrivate = oldPrivate.filter { it.toTagIdOnly().toList() !in existingPrivateIds }
if (newPublic.isEmpty() && newPrivate.isEmpty()) return
val mergedPublic = existingPublic + newPublic
val mergedPrivate = existingPrivate + newPrivate
BookmarkListEvent.create(
publicBookmarks = mergedPublic,
privateBookmarks = mergedPrivate,
signer = signer,
)
} else {
BookmarkListEvent.create(
publicBookmarks = oldPublic,
privateBookmarks = oldPrivate,
signer = signer,
)
}
sendMyPublicAndPrivateOutbox(newEvent)
}
suspend fun addPin(note: Note) {
if (!isWriteable() || note.isDraft()) return
@@ -165,6 +165,10 @@ class AccountSettings(
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val zapPaymentRequest: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
@@ -321,6 +325,50 @@ class AccountSettings(
}
}
fun changeDefaultPollsFollowList(name: FeedDefinition) {
changeDefaultPollsFollowList(name.code)
}
fun changeDefaultPollsFollowList(name: TopFilter) {
if (defaultPollsFollowList.value != name) {
defaultPollsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultPicturesFollowList(name: FeedDefinition) {
changeDefaultPicturesFollowList(name.code)
}
fun changeDefaultPicturesFollowList(name: TopFilter) {
if (defaultPicturesFollowList.value != name) {
defaultPicturesFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultShortsFollowList(name: FeedDefinition) {
changeDefaultShortsFollowList(name.code)
}
fun changeDefaultShortsFollowList(name: TopFilter) {
if (defaultShortsFollowList.value != name) {
defaultShortsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultLongsFollowList(name: FeedDefinition) {
changeDefaultLongsFollowList(name.code)
}
fun changeDefaultLongsFollowList(name: TopFilter) {
if (defaultLongsFollowList.value != name) {
defaultLongsFollowList.tryEmit(name)
saveAccountSettings()
}
}
// ---
// language services
// ---
@@ -144,6 +144,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEv
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
@@ -167,9 +168,10 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
@@ -580,84 +582,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: ExternalIdentitiesEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: ContactListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: BookmarkListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: TextNoteEvent,
relay: NormalizedRelayUrl? = null,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: PublicMessageEvent,
relay: NormalizedRelayUrl? = null,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: TorrentEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: InteractiveStoryPrologueEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: InteractiveStorySceneEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: InteractiveStoryReadingStateEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestationRequestEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestorRecommendationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestorProficiencyEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consumeRegularEvent(
event: Event,
relay: NormalizedRelayUrl?,
@@ -694,135 +618,6 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
fun consume(
event: PictureEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: VoiceEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: VoiceReplyEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
@Suppress("DEPRECATION")
fun consume(
event: TorrentCommentEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NIP90ContentDiscoveryResponseEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NIP90ContentDiscoveryRequestEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NIP90StatusEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NIP90UserDiscoveryResponseEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: RequestToVanishEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NIP90UserDiscoveryRequestEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: GoalEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: GitPatchEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: GitIssueEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
@Suppress("DEPRECATION")
fun consume(
event: GitReplyEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
// Chess events (NIP-64 live chess)
fun consume(
event: ChessGameEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: JesterEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: LiveChessGameChallengeEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessGameAcceptEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessMoveEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessGameEndEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessDrawOfferEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: NipTextEvent,
relay: NormalizedRelayUrl?,
@@ -1049,7 +844,12 @@ object LocalCache : ILocalCache, ICacheProvider {
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
}
is BadgeProfilesEvent -> {
is AcceptedBadgeSetEvent -> {
event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) }
}
is ProfileBadgesEvent -> {
event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) }
}
@@ -1103,12 +903,6 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
fun consume(
event: ZapPollEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
private fun consume(
event: LiveActivitiesEvent,
relay: NormalizedRelayUrl?,
@@ -1150,234 +944,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: LabeledBookmarkListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: MuteListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: CommunityListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: GitRepositoryEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: RootSiteEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: NamedSiteEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: ChannelListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: BlossomServersEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: FileServersEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: PeopleListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: EphemeralChatListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: FollowListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: AdvertisedRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: ChatMessageRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: PrivateOutboxRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: HashtagListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: GeohashListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: SearchRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: BlockedRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: TrustedRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: RelayFeedsListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: TrustProviderListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: ProxyRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: IndexerRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: BroadcastRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: CommunityDefinitionEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: EmojiPackSelectionEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: EmojiPackEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: ClassifiedsEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: PinListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: RelaySetEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: AudioTrackEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: VideoVerticalEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: VideoHorizontalEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: VideoNormalEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
private fun consume(
event: VideoShortEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
private fun consume(
event: RelayDiscoveryEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: RelayMonitorEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: StatusEvent,
relay: NormalizedRelayUrl?,
@@ -1438,66 +1004,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: BadgeDefinitionEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: BadgeProfilesEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: BadgeAwardEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
private fun consume(
event: NNSEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AppDefinitionEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: CalendarEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: WebBookmarkEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: CalendarDateSlotEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: CalendarTimeSlotEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: CalendarRSVPEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consumeBaseReplaceable(
event: Event,
relay: NormalizedRelayUrl?,
@@ -1543,24 +1049,6 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
fun consume(
event: AppRecommendationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AppSpecificDataEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: PrivateDmEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: DeletionEvent,
relay: NormalizedRelayUrl?,
@@ -1993,12 +1481,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
fun consume(
event: CommentEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
@Suppress("UNUSED_PARAMETER")
fun consume(
event: ChannelHideMessageEvent,
@@ -2080,36 +1562,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: AudioHeaderEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: FileHeaderEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: ProfileGalleryEntryEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: FileStorageHeaderEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: FhirResourceEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: TextNoteModificationEvent,
relay: NormalizedRelayUrl?,
@@ -2145,30 +1597,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: HighlightEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: CodeSnippetEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: ChatEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: PollEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: PollResponseEvent,
relay: NormalizedRelayUrl?,
@@ -2242,30 +1670,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
private fun consume(
event: ChatMessageEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
private fun consume(
event: ChatMessageEncryptedFileHeaderEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: SealedRumorEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: GiftWrapEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: LnZapPaymentRequestEvent,
relay: NormalizedRelayUrl?,
@@ -3141,134 +2545,136 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean =
try {
when (event) {
is AdvertisedRelayListEvent -> consume(event, relay, wasVerified)
is AppDefinitionEvent -> consume(event, relay, wasVerified)
is AppRecommendationEvent -> consume(event, relay, wasVerified)
is AppSpecificDataEvent -> consume(event, relay, wasVerified)
is AttestationEvent -> consume(event, relay, wasVerified)
is AttestationRequestEvent -> consume(event, relay, wasVerified)
is AttestorRecommendationEvent -> consume(event, relay, wasVerified)
is AttestorProficiencyEvent -> consume(event, relay, wasVerified)
is AudioHeaderEvent -> consume(event, relay, wasVerified)
is AudioTrackEvent -> consume(event, relay, wasVerified)
is BadgeAwardEvent -> consume(event, relay, wasVerified)
is BadgeDefinitionEvent -> consume(event, relay, wasVerified)
is BadgeProfilesEvent -> consume(event, relay, wasVerified)
is BlockedRelayListEvent -> consume(event, relay, wasVerified)
is BlossomServersEvent -> consume(event, relay, wasVerified)
is BroadcastRelayListEvent -> consume(event, relay, wasVerified)
is BookmarkListEvent -> consume(event, relay, wasVerified)
is CalendarEvent -> consume(event, relay, wasVerified)
is CalendarDateSlotEvent -> consume(event, relay, wasVerified)
is CalendarTimeSlotEvent -> consume(event, relay, wasVerified)
is CalendarRSVPEvent -> consume(event, relay, wasVerified)
is AcceptedBadgeSetEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AdvertisedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AppDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AppRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AppSpecificDataEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AttestationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AttestationRequestEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AttestorRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AttestorProficiencyEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AudioHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
is AudioTrackEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BadgeAwardEvent -> consumeRegularEvent(event, relay, wasVerified)
is BadgeDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BlockedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BlossomServersEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BroadcastRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is OldBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CalendarEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CalendarDateSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CalendarTimeSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CalendarRSVPEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ChannelCreateEvent -> consume(event, relay, wasVerified)
is ChannelListEvent -> consume(event, relay, wasVerified)
is ChannelListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ChannelHideMessageEvent -> consume(event, relay, wasVerified)
is ChannelMessageEvent -> consume(event, relay, wasVerified)
is ChannelMetadataEvent -> consume(event, relay, wasVerified)
is ChannelMuteUserEvent -> consume(event, relay, wasVerified)
is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay, wasVerified)
is ChatMessageEvent -> consume(event, relay, wasVerified)
is ChatMessageRelayListEvent -> consume(event, relay, wasVerified)
is ClassifiedsEvent -> consume(event, relay, wasVerified)
is CommentEvent -> consume(event, relay, wasVerified)
is CommunityDefinitionEvent -> consume(event, relay, wasVerified)
is CommunityListEvent -> consume(event, relay, wasVerified)
is ChatMessageEncryptedFileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
is ChatMessageEvent -> consumeRegularEvent(event, relay, wasVerified)
is ChatMessageRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ClassifiedsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CommentEvent -> consumeRegularEvent(event, relay, wasVerified)
is CommunityDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CommunityListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CommunityPostApprovalEvent -> consume(event, relay, wasVerified)
is ContactListEvent -> consume(event, relay, wasVerified)
is ContactListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is DeletionEvent -> consume(event, relay, wasVerified)
is DraftWrapEvent -> consume(event, relay, wasVerified)
is EmojiPackEvent -> consume(event, relay, wasVerified)
is EmojiPackSelectionEvent -> consume(event, relay, wasVerified)
is EmojiPackEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EmojiPackSelectionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EphemeralChatEvent -> consume(event, relay, wasVerified)
is EphemeralChatListEvent -> consume(event, relay, wasVerified)
is ExternalIdentitiesEvent -> consume(event, relay, wasVerified)
is EphemeralChatListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ExternalIdentitiesEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is GenericRepostEvent -> consume(event, relay, wasVerified)
is FhirResourceEvent -> consume(event, relay, wasVerified)
is FileHeaderEvent -> consume(event, relay, wasVerified)
is ProfileGalleryEntryEvent -> consume(event, relay, wasVerified)
is FileServersEvent -> consume(event, relay, wasVerified)
is FhirResourceEvent -> consumeRegularEvent(event, relay, wasVerified)
is FileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
is ProfileGalleryEntryEvent -> consumeRegularEvent(event, relay, wasVerified)
is FileServersEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is FileStorageEvent -> consume(event, relay, wasVerified)
is FileStorageHeaderEvent -> consume(event, relay, wasVerified)
is FollowListEvent -> consume(event, relay, wasVerified)
is GeohashListEvent -> consume(event, relay, wasVerified)
is GoalEvent -> consume(event, relay, wasVerified)
is GiftWrapEvent -> consume(event, relay, wasVerified)
is GitIssueEvent -> consume(event, relay, wasVerified)
is GitReplyEvent -> consume(event, relay, wasVerified)
is GitPatchEvent -> consume(event, relay, wasVerified)
is GitRepositoryEvent -> consume(event, relay, wasVerified)
is RootSiteEvent -> consume(event, relay, wasVerified)
is NamedSiteEvent -> consume(event, relay, wasVerified)
is ChessGameEvent -> consume(event, relay, wasVerified)
is RelayFeedsListEvent -> consume(event, relay, wasVerified)
is JesterEvent -> consume(event, relay, wasVerified)
is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified)
is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified)
is LiveChessMoveEvent -> consume(event, relay, wasVerified)
is LiveChessGameEndEvent -> consume(event, relay, wasVerified)
is LiveChessDrawOfferEvent -> consume(event, relay, wasVerified)
is HashtagListEvent -> consume(event, relay, wasVerified)
is HighlightEvent -> consume(event, relay, wasVerified)
is IndexerRelayListEvent -> consume(event, relay, wasVerified)
is InteractiveStoryPrologueEvent -> consume(event, relay, wasVerified)
is InteractiveStorySceneEvent -> consume(event, relay, wasVerified)
is InteractiveStoryReadingStateEvent -> consume(event, relay, wasVerified)
is LabeledBookmarkListEvent -> consume(event, relay, wasVerified)
is FileStorageHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
is FollowListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is GeohashListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is GoalEvent -> consumeRegularEvent(event, relay, wasVerified)
is GiftWrapEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitIssueEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitPatchEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitRepositoryEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is RootSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is NamedSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ChessGameEvent -> consumeRegularEvent(event, relay, wasVerified)
is RelayFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is JesterEvent -> consumeRegularEvent(event, relay, wasVerified)
is LiveChessGameChallengeEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessGameAcceptEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessMoveEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified)
is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is InteractiveStorySceneEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is InteractiveStoryReadingStateEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified)
is LnZapEvent -> consume(event, relay, wasVerified)
is LnZapRequestEvent -> consume(event, relay, wasVerified)
is NIP90StatusEvent -> consume(event, relay, wasVerified)
is NIP90ContentDiscoveryResponseEvent -> consume(event, relay, wasVerified)
is NIP90ContentDiscoveryRequestEvent -> consume(event, relay, wasVerified)
is NIP90UserDiscoveryResponseEvent -> consume(event, relay, wasVerified)
is NIP90UserDiscoveryRequestEvent -> consume(event, relay, wasVerified)
is NIP90StatusEvent -> consumeRegularEvent(event, relay, wasVerified)
is NIP90ContentDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified)
is NIP90ContentDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified)
is NIP90UserDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified)
is NIP90UserDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified)
is LnZapPaymentRequestEvent -> consume(event, relay, wasVerified)
is LnZapPaymentResponseEvent -> consume(event, relay, wasVerified)
is LongTextNoteEvent -> consume(event, relay, wasVerified)
is MetadataEvent -> consume(event, relay, wasVerified)
is MuteListEvent -> consume(event, relay, wasVerified)
is NNSEvent -> consume(event, relay, wasVerified)
is MuteListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is NNSEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is NipTextEvent -> consume(event, relay, wasVerified)
is OtsEvent -> consume(event, relay, wasVerified)
is PictureEvent -> consume(event, relay, wasVerified)
is PrivateDmEvent -> consume(event, relay, wasVerified)
is PrivateOutboxRelayListEvent -> consume(event, relay, wasVerified)
is ProxyRelayListEvent -> consume(event, relay, wasVerified)
is PinListEvent -> consume(event, relay, wasVerified)
is PublicMessageEvent -> consume(event, relay, wasVerified)
is PeopleListEvent -> consume(event, relay, wasVerified)
is RequestToVanishEvent -> consume(event, relay, wasVerified)
is CodeSnippetEvent -> consume(event, relay, wasVerified)
is ZapPollEvent -> consume(event, relay, wasVerified)
is ChatEvent -> consume(event, relay, wasVerified)
is PollEvent -> consume(event, relay, wasVerified)
is PictureEvent -> consumeRegularEvent(event, relay, wasVerified)
is PrivateDmEvent -> consumeRegularEvent(event, relay, wasVerified)
is PrivateOutboxRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ProfileBadgesEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ProxyRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is PinListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is PublicMessageEvent -> consumeRegularEvent(event, relay, wasVerified)
is PeopleListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is RequestToVanishEvent -> consumeRegularEvent(event, relay, wasVerified)
is CodeSnippetEvent -> consumeRegularEvent(event, relay, wasVerified)
is ZapPollEvent -> consumeRegularEvent(event, relay, wasVerified)
is ChatEvent -> consumeRegularEvent(event, relay, wasVerified)
is PollEvent -> consumeRegularEvent(event, relay, wasVerified)
is PollResponseEvent -> consume(event, relay, wasVerified)
is RelayDiscoveryEvent -> consume(event, relay, wasVerified)
is RelayMonitorEvent -> consume(event, relay, wasVerified)
is RelayDiscoveryEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is RelayMonitorEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ReactionEvent -> consume(event, relay, wasVerified)
is ContactCardEvent -> consume(event, relay, wasVerified)
is RelaySetEvent -> consume(event, relay, wasVerified)
is RelaySetEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ReportEvent -> consume(event, relay, wasVerified)
is RepostEvent -> consume(event, relay, wasVerified)
is SealedRumorEvent -> consume(event, relay, wasVerified)
is SearchRelayListEvent -> consume(event, relay, wasVerified)
is SealedRumorEvent -> consumeRegularEvent(event, relay, wasVerified)
is SearchRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is StatusEvent -> consume(event, relay, wasVerified)
is TextNoteEvent -> consume(event, relay, wasVerified)
is TextNoteEvent -> consumeRegularEvent(event, relay, wasVerified)
is TextNoteModificationEvent -> consume(event, relay, wasVerified)
is TorrentEvent -> consume(event, relay, wasVerified)
is TorrentCommentEvent -> consume(event, relay, wasVerified)
is TrustedRelayListEvent -> consume(event, relay, wasVerified)
is TrustProviderListEvent -> consume(event, relay, wasVerified)
is VideoHorizontalEvent -> consume(event, relay, wasVerified)
is VideoNormalEvent -> consume(event, relay, wasVerified)
is VideoVerticalEvent -> consume(event, relay, wasVerified)
is VideoShortEvent -> consume(event, relay, wasVerified)
is VoiceEvent -> consume(event, relay, wasVerified)
is VoiceReplyEvent -> consume(event, relay, wasVerified)
is WebBookmarkEvent -> consume(event, relay, wasVerified)
is TorrentEvent -> consumeRegularEvent(event, relay, wasVerified)
is TorrentCommentEvent -> consumeRegularEvent(event, relay, wasVerified)
is TrustedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is TrustProviderListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is VideoHorizontalEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is VideoNormalEvent -> consumeRegularEvent(event, relay, wasVerified)
is VideoVerticalEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is VideoShortEvent -> consumeRegularEvent(event, relay, wasVerified)
is VoiceEvent -> consumeRegularEvent(event, relay, wasVerified)
is VoiceReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
is WebBookmarkEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is WikiNoteEvent -> consume(event, relay, wasVerified)
is PaymentTargetsEvent -> consume(event, relay, wasVerified)
else -> Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false }
@@ -21,3 +21,5 @@
package com.vitorpamplona.amethyst.model.nip51Lists
typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState
typealias OldBookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState
@@ -29,7 +29,9 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.stringRes
@@ -43,10 +45,14 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import java.math.BigDecimal
@@ -104,10 +110,33 @@ class EventNotificationConsumer(
Log.d(TAG) { "Unwrapped consume ${innerEvent.javaClass.simpleName}" }
when (innerEvent) {
is PrivateDmEvent -> notify(innerEvent, account)
is LnZapEvent -> notify(innerEvent, account)
is ChatMessageEvent -> notify(innerEvent, account)
is ChatMessageEncryptedFileHeaderEvent -> notify(innerEvent, account)
is PrivateDmEvent -> {
notify(innerEvent, account)
}
is LnZapEvent -> {
notify(innerEvent, account)
}
is ChatMessageEvent -> {
notify(innerEvent, account)
}
is ChatMessageEncryptedFileHeaderEvent -> {
notify(innerEvent, account)
}
is ReactionEvent -> {
notify(innerEvent, account)
}
is LiveChessGameAcceptEvent -> {
notifyChessEvent(innerEvent, account, R.string.app_notification_chess_challenge_accepted)
}
is LiveChessMoveEvent -> {
notifyChessEvent(innerEvent, account, R.string.app_notification_chess_your_turn)
}
}
}
}
@@ -481,6 +510,111 @@ class EventNotificationConsumer(
}
}
private suspend fun notify(
event: ReactionEvent,
account: Account,
) {
Log.d(TAG, "New Reaction to Notify")
// old event being re-broadcast
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
// don't notify for own reactions
if (event.pubKey == account.signer.pubKey) return
// only notify if the reaction is for the current user
if (!event.isTaggedUser(account.signer.pubKey)) return
val reactedPostId = event.originalPost().firstOrNull() ?: return
val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId)
val author = LocalCache.getOrCreateUser(event.pubKey)
val user = author.toBestDisplayName()
val userPicture = author.profilePicture()
val reactionContent = event.content
val reactionSymbol =
when {
reactionContent == ReactionEvent.LIKE || reactionContent.isBlank() -> "\uD83E\uDD19"
reactionContent == ReactionEvent.DISLIKE -> "\uD83D\uDC4E"
else -> reactionContent
}
val title = "$reactionSymbol $user"
val reactedContent =
reactedNote
?.event
?.content
?.split("\n")
?.firstOrNull() ?: ""
val content =
if (reactedContent.isNotBlank()) {
stringRes(
applicationContext,
R.string.app_notification_reactions_channel_message_for,
reactedContent,
)
} else {
stringRes(
applicationContext,
R.string.app_notification_reactions_channel_message,
user,
)
}
val noteUri =
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
.toNpub()
notificationManager()
.sendReactionNotification(
event.id,
content,
title,
event.createdAt,
userPicture,
noteUri,
applicationContext,
)
}
private suspend fun notifyChessEvent(
event: BaseChessEvent,
account: Account,
contentStringRes: Int,
) {
if (
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
event.pubKey != account.signer.pubKey
) {
val author = LocalCache.getOrCreateUser(event.pubKey)
val user = author.toBestDisplayName()
val userPicture = author.profilePicture()
val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name)
val content = stringRes(applicationContext, contentStringRes, user)
val noteUri =
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
.toNpub()
notificationManager()
.sendChessNotification(
event.id,
content,
title,
event.createdAt,
userPicture,
noteUri,
applicationContext,
)
}
}
fun notificationManager(): NotificationManager =
ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
as NotificationManager
@@ -45,8 +45,14 @@ import kotlinx.coroutines.withContext
object NotificationUtils {
private var dmChannel: NotificationChannel? = null
private var zapChannel: NotificationChannel? = null
private var reactionChannel: NotificationChannel? = null
private var chessChannel: NotificationChannel? = null
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION"
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
const val KEY_REPLY_TEXT = "key_reply_text"
@@ -56,6 +62,8 @@ object NotificationUtils {
private const val DM_SUMMARY_ID = 0x10000
private const val ZAP_SUMMARY_ID = 0x20000
private const val REACTION_SUMMARY_ID = 0x40000
private const val CHESS_SUMMARY_ID = 0x30000
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!!
@@ -99,6 +107,104 @@ object NotificationUtils {
return zapChannel!!
}
fun getOrCreateReactionChannel(applicationContext: Context): NotificationChannel {
if (reactionChannel != null) return reactionChannel!!
reactionChannel =
NotificationChannel(
stringRes(applicationContext, R.string.app_notification_reactions_channel_id),
stringRes(applicationContext, R.string.app_notification_reactions_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description =
stringRes(applicationContext, R.string.app_notification_reactions_channel_description)
}
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(reactionChannel!!)
return reactionChannel!!
}
fun getOrCreateChessChannel(applicationContext: Context): NotificationChannel {
if (chessChannel != null) return chessChannel!!
chessChannel =
NotificationChannel(
stringRes(applicationContext, R.string.app_notification_chess_channel_id),
stringRes(applicationContext, R.string.app_notification_chess_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description =
stringRes(applicationContext, R.string.app_notification_chess_channel_description)
}
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(chessChannel!!)
return chessChannel!!
}
suspend fun NotificationManager.sendReactionNotification(
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
applicationContext: Context,
) {
getOrCreateReactionChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_reactions_channel_id)
sendNotification(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
pictureUrl = pictureUrl,
uri = uri,
channelId = channelId,
notificationGroupKey = REACTION_GROUP_KEY,
category = NotificationCompat.CATEGORY_SOCIAL,
summaryId = REACTION_SUMMARY_ID,
summaryText = stringRes(applicationContext, R.string.app_notification_reactions_summary),
applicationContext = applicationContext,
)
}
suspend fun NotificationManager.sendChessNotification(
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
applicationContext: Context,
) {
getOrCreateChessChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_chess_channel_id)
sendNotification(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
pictureUrl = pictureUrl,
uri = uri,
channelId = channelId,
notificationGroupKey = CHESS_GROUP_KEY,
category = NotificationCompat.CATEGORY_SOCIAL,
summaryId = CHESS_SUMMARY_ID,
summaryText = stringRes(applicationContext, R.string.app_notification_chess_summary),
applicationContext = applicationContext,
)
}
suspend fun NotificationManager.sendZapNotification(
id: String,
messageBody: String,
@@ -27,8 +27,6 @@ import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
@SuppressLint("UnsafeOptInUsageError")
@@ -42,20 +40,18 @@ class VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
suspend fun initFileCache(
fun initFileCache(
context: Context,
cachePath: File,
) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
withContext(Dispatchers.IO) {
simpleCache =
SimpleCache(
cachePath,
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider,
)
}
simpleCache =
SimpleCache(
cachePath,
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider,
)
}
// This method should be called when proxy setting changes.
@@ -22,18 +22,15 @@ package com.vitorpamplona.amethyst.service.playback.diskCache
import android.content.Context
import com.vitorpamplona.amethyst.service.safeCacheDir
import kotlinx.coroutines.runBlocking
class VideoCacheFactory {
companion object {
fun new(app: Context): VideoCache {
val newCache = VideoCache()
runBlocking {
newCache.initFileCache(
app,
app.safeCacheDir().resolve("exoplayer"),
)
}
newCache.initFileCache(
app,
app.safeCacheDir().resolve("exoplayer"),
)
return newCache
}
}
@@ -79,16 +79,24 @@ class RelayProxyClientConnector(
client.disconnect()
}
if (it.torStatus is TorServiceStatus.Active) {
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_DORMANT)
Log.d("ManageRelayServices", "Pausing Tor Activity")
try {
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_DORMANT)
Log.d("ManageRelayServices", "Pausing Tor Activity")
} catch (e: Exception) {
Log.e("ManageRelayServices") { "Failed to signal Tor dormant: ${e.message}" }
}
}
} else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) {
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
if (it.torStatus is TorServiceStatus.Active) {
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_ACTIVE)
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM)
Log.d("ManageRelayServices", "Resuming Tor Activity with new nym")
try {
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_ACTIVE)
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM)
Log.d("ManageRelayServices", "Resuming Tor Activity with new nym")
} catch (e: Exception) {
Log.e("ManageRelayServices") { "Failed to signal Tor active: ${e.message}" }
}
}
// only calls this if the client is not active. Otherwise goes to the else below
@@ -37,10 +37,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfoNip66FilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -63,7 +66,6 @@ class RelaySubscriptionsCoordinator(
val chatroomList = ChatroomListFilterAssembler(client)
val video = VideoFilterAssembler(client)
val discovery = DiscoveryFilterAssembler(client)
val polls = PollsFilterAssembler(client)
// loaders of content that is not yet in the device.
// they are active when looking at events, users, channels.
@@ -87,6 +89,11 @@ class RelaySubscriptionsCoordinator(
val followPacks = FollowPackFeedFilterAssembler(client)
val chess = ChessFilterAssembler(client)
val polls = PollsFilterAssembler(client)
val pictures = PicturesFilterAssembler(client)
val shorts = ShortsFilterAssembler(client)
val longs = LongsFilterAssembler(client)
// active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client)
@@ -98,6 +105,9 @@ class RelaySubscriptionsCoordinator(
video,
discovery,
polls,
pictures,
shorts,
longs,
channelFinder,
eventFinder,
userFinder,
@@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
@@ -33,6 +35,8 @@ val ReportsAndBookmarksFromKeyKinds =
listOf(
ReportEvent.KIND,
BookmarkListEvent.KIND,
OldBookmarkListEvent.KIND,
LabeledBookmarkListEvent.KIND,
PinListEvent.KIND,
RequestToVanishEvent.KIND,
)
@@ -30,14 +30,16 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEve
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
val FollowAndMutesFromKeyKinds =
listOf(
PeopleListEvent.KIND,
FollowListEvent.KIND,
MuteListEvent.KIND,
BadgeProfilesEvent.KIND,
AcceptedBadgeSetEvent.KIND,
ProfileBadgesEvent.KIND,
EmojiPackSelectionEvent.KIND,
EphemeralChatListEvent.KIND,
ChannelListEvent.KIND,
@@ -47,7 +47,9 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
@@ -87,6 +89,8 @@ val NotificationsPerKeyKinds2 =
CalendarRSVPEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
LiveChessGameAcceptEvent.KIND,
LiveChessMoveEvent.KIND,
)
val NotificationsPerKeyKinds3 =
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
@@ -261,7 +262,7 @@ fun observeUserBookmarkCount(
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
val newFlow =
remember(user) {
accountViewModel
.bookmarks(user)
@@ -274,7 +275,27 @@ fun observeUserBookmarkCount(
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(0)
val oldFlow =
remember(user) {
accountViewModel
.oldBookmarks(user)
.flow()
.metadata.stateFlow
.sample(200)
.mapLatest { noteState ->
(noteState.note.event as? OldBookmarkListEvent)?.countBookmarks() ?: 0
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
val combined =
remember(user) {
kotlinx.coroutines.flow.combine(newFlow, oldFlow) { newCount, oldCount ->
newCount + oldCount
}
}
return combined.collectAsStateWithLifecycle(0)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -39,13 +39,14 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
@@ -61,6 +62,7 @@ val SearchPostsByTextKinds1 =
BadgeDefinitionEvent.KIND,
PeopleListEvent.KIND,
BookmarkListEvent.KIND,
OldBookmarkListEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlin.coroutines.cancellation.CancellationException
class UrlUserTagOutputTransformation(
val color: Color,
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val text = asCharSequence().toString()
// Find all user mentions using regex and replace in reverse order
// so that earlier indices remain valid after replacements.
// Matches: @npub1..., nostr:npub1..., @nprofile1..., nostr:nprofile1...
val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)")
val matches = mentionRegex.findAll(text).toList().reversed()
for (match in matches) {
try {
val bech32 =
match.value
.removePrefix("@")
.removePrefix("nostr:")
val hex = decodePublicKeyAsHexOrNull(bech32) ?: continue
val user = LocalCache.getOrCreateUser(hex)
val displayName = "@${user.toBestDisplayName()}"
replace(match.range.first, match.range.last + 1, displayName)
// Apply color styling to the replaced display name
addStyle(
SpanStyle(color = color, textDecoration = TextDecoration.None),
match.range.first,
match.range.first + displayName.length,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
// Highlight URLs in remaining text
highlightUrls(color)
}
}
private fun TextFieldBuffer.highlightUrls(color: Color) {
val text = asCharSequence().toString()
val urlPattern = android.util.Patterns.WEB_URL
val matcher = urlPattern.matcher(text)
while (matcher.find()) {
addStyle(
SpanStyle(color = color, textDecoration = TextDecoration.None),
matcher.start(),
matcher.end(),
)
}
}
@@ -31,8 +31,10 @@ import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlin.coroutines.cancellation.CancellationException
data class RangesChanges(
@@ -60,20 +62,19 @@ fun buildAnnotatedStringWithUrlHighlighting(
text.text.split('\n').joinToString("\n") { paragraph: String ->
paragraph.split(' ').joinToString(" ") { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
val mention = parseUserMention(word)
if (mention != null) {
val (keyPortion, restOfWord, hexKey) = mention
val startIndex = builderBefore.toString().length
builderBefore.append(
"$keyB32$restOfWord ",
"$keyPortion$restOfWord ",
) // accounts for the \n at the end of each paragraph
val endIndex = startIndex + keyB32.length
val endIndex = startIndex + keyPortion.length
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
val user = LocalCache.getOrCreateUser(hexKey)
val newWord = "@${user.toBestDisplayName()}"
val startNew = builderAfter.toString().length
@@ -181,3 +182,46 @@ fun buildAnnotatedStringWithUrlHighlighting(
numberOffsetTranslator,
)
}
private data class UserMention(
val keyPortion: String,
val restOfWord: String,
val hexKey: String,
)
private fun parseUserMention(word: String): UserMention? {
var key = word
val prefix: String
if (key.startsWith("nostr:", true)) {
prefix = key.substring(0, 6)
key = key.substring(6)
} else if (key.startsWith("@")) {
prefix = "@"
key = key.substring(1)
} else {
return null
}
if (key.startsWith("npub1", true) && key.length >= 63) {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
val hex = decodePublicKeyAsHexOrNull(keyB32) ?: return null
return UserMention("$prefix$keyB32", restOfWord, hex)
} else if (key.startsWith("nprofile1", true)) {
val parsed = Nip19Parser.uriToRoute(key) ?: return null
val entity = parsed.entity
if (entity !is NProfile && entity !is NPub) return null
val hex =
when (entity) {
is NProfile -> entity.hex
is NPub -> entity.hex
else -> return null
}
val bech32Len = parsed.nip19raw.length
val restOfWord = key.substring(bech32Len)
return UserMention("$prefix${parsed.nip19raw}", restOfWord, hex)
}
return null
}
@@ -69,6 +69,7 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -439,6 +440,7 @@ fun Event.toKindName(): String =
is VoiceEvent -> stringRes(R.string.voice_post)
is VoiceReplyEvent -> stringRes(R.string.voice_reply)
is BookmarkListEvent -> stringRes(R.string.bookmarks)
is OldBookmarkListEvent -> stringRes(R.string.bookmarks)
else -> stringRes(R.string.post)
}
@@ -20,6 +20,14 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.content.MediaType
import androidx.compose.foundation.content.ReceiveContentListener
import androidx.compose.foundation.content.TransferableContent
import androidx.compose.foundation.content.consume
import androidx.compose.foundation.content.contentReceiver
import androidx.compose.foundation.content.hasMediaType
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.PaddingValues
@@ -27,6 +35,10 @@ import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
@@ -35,8 +47,11 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
@@ -50,6 +65,155 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
// COPIED FROM TEXT FIELD
// The only change is the contentPadding below
// New TextFieldState-based overload for GIF keyboard support
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ThinPaddingTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
onTextChanged: (() -> Unit)? = null,
onContentReceived: ((Uri, String?) -> Unit)? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
inputTransformation: InputTransformation? = null,
outputTransformation: OutputTransformation? = null,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
suffix: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource? = null,
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors(),
contentPadding: PaddingValues =
if (label == null) {
TextFieldDefaults.contentPaddingWithoutLabel(
start = 10.dp,
top = 12.dp,
end = 10.dp,
bottom = 12.dp,
)
} else {
TextFieldDefaults.contentPaddingWithLabel(
start = 10.dp,
top = 12.dp,
end = 10.dp,
bottom = 12.dp,
)
},
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
if (onTextChanged != null) {
val callback by rememberUpdatedState(onTextChanged)
LaunchedEffect(state) {
snapshotFlow { state.text }
.collect { callback() }
}
}
val textColor =
textStyle.color.takeOrElse {
val focused by interactionSource.collectIsFocusedAsState()
when {
!enabled -> MaterialTheme.colorScheme.placeholderText
isError -> MaterialTheme.colorScheme.onSurface
focused -> MaterialTheme.colorScheme.onSurface
else -> MaterialTheme.colorScheme.onSurface
}
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
val lineLimits =
if (singleLine) {
TextFieldLineLimits.SingleLine
} else {
TextFieldLineLimits.MultiLine(minLines, maxLines)
}
val contentModifier =
if (onContentReceived != null) {
modifier.contentReceiver(
object : ReceiveContentListener {
override fun onReceive(transferableContent: TransferableContent): TransferableContent? {
if (!transferableContent.hasMediaType(MediaType.Image)) {
return transferableContent
}
val remaining =
transferableContent.consume { item ->
val uri = item.uri
if (uri != null) {
onContentReceived(
uri,
transferableContent.clipEntry.clipData.description
.getMimeType(0),
)
true
} else {
false
}
}
return remaining
}
},
)
} else {
modifier
}
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
state = state,
modifier =
contentModifier
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = 36.dp,
),
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = keyboardOptions,
lineLimits = lineLimits,
interactionSource = interactionSource,
inputTransformation = inputTransformation,
outputTransformation = outputTransformation,
decorator = { innerTextField ->
TextFieldDefaults.DecorationBox(
value = state.text.toString(),
visualTransformation = VisualTransformation.None,
innerTextField = innerTextField,
placeholder = placeholder,
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
prefix = prefix,
suffix = suffix,
supportingText = supportingText,
shape = shape,
singleLine = singleLine,
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
contentPadding = contentPadding,
)
},
)
}
}
// Legacy TextFieldValue-based overload (kept for readonly dropdown fields)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ThinPaddingTextField(
@@ -76,7 +240,6 @@ fun ThinPaddingTextField(
interactionSource: MutableInteractionSource? = null,
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors(),
// new fields
contentPadding: PaddingValues =
if (label == null) {
TextFieldDefaults.contentPaddingWithoutLabel(
@@ -113,6 +276,7 @@ fun ThinPaddingTextField(
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
@Suppress("DEPRECATION")
BasicTextField(
value = value,
modifier =
@@ -56,6 +56,9 @@ object ScrollStateKeys {
const val DISCOVER_CHATS = "DiscoverChatsFeed"
const val POLLS_SCREEN = "PollsFeed"
const val PICTURES_SCREEN = "PicturesFeed"
const val SHORTS_SCREEN = "ShortsFeed"
const val LONGS_SCREEN = "LongsFeed"
const val SEARCH_SCREEN = "SearchFeed"
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -64,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfB
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata.BookmarkGroupMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
@@ -97,10 +99,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.LongsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPickFollowsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
@@ -124,6 +128,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
@@ -175,6 +180,9 @@ fun BuildNavigation(
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
@@ -210,6 +218,7 @@ fun BuildNavigation(
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(nav) }
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(nav) }
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.OldBookmarks> { OldBookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
@@ -58,7 +58,10 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Photo
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SmartDisplay
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -581,6 +584,30 @@ fun ListContent(
route = Route.Polls,
)
NavigationRow(
title = R.string.pictures,
icon = Icons.Outlined.Photo,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Pictures,
)
NavigationRow(
title = R.string.shorts,
icon = Icons.Outlined.PlayCircle,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Shorts,
)
NavigationRow(
title = R.string.longs,
icon = Icons.Outlined.SmartDisplay,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Longs,
)
NavigationRow(
title = R.string.wallet,
icon = Icons.Outlined.AccountBalanceWallet,
@@ -43,6 +43,12 @@ sealed class Route {
@Serializable object Polls : Route()
@Serializable object Pictures : Route()
@Serializable object Shorts : Route()
@Serializable object Longs : Route()
@Serializable object Chess : Route()
@Serializable object Wallet : Route()
@@ -65,6 +71,8 @@ sealed class Route {
@Serializable object Bookmarks : Route()
@Serializable object OldBookmarks : Route()
@Serializable object BookmarkGroups : Route()
@Serializable object ImportFollowsSelectUser : Route()
@@ -256,8 +256,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.messagefield
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.foundation.text.input.TextFieldState
interface IMessageField {
var message: TextFieldValue
val message: TextFieldState
fun updateMessage(newMessage: TextFieldValue)
fun onMessageChanged()
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.messagefield
import android.net.Uri
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
@@ -39,7 +40,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -51,6 +52,7 @@ fun MessageField(
placeholder: Int,
viewModel: IMessageField,
requestFocus: Boolean = true,
onContentReceived: ((Uri, String?) -> Unit)? = null,
) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
@@ -65,8 +67,9 @@ fun MessageField(
}
ThinPaddingTextField(
value = viewModel.message,
onValueChange = viewModel::updateMessage,
state = viewModel.message,
onTextChanged = viewModel::onMessageChanged,
onContentReceived = onContentReceived,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
@@ -91,7 +94,7 @@ fun MessageField(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
contentPadding =
TextFieldDefaults.contentPaddingWithoutLabel(
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.note.creators.previews
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.service.CachedUrlParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -33,22 +32,22 @@ import kotlinx.coroutines.flow.map
@Stable
class PreviewState {
var source = MutableStateFlow(TextFieldValue(""))
var source = MutableStateFlow("")
@OptIn(FlowPreview::class)
val results =
source
.debounce(500)
.map {
CachedUrlParser.parseValidUrls(it.text)
CachedUrlParser.parseValidUrls(it)
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
fun reset() {
source.tryEmit(TextFieldValue(""))
source.tryEmit("")
}
fun update(text: TextFieldValue) {
fun update(text: String) {
source.tryEmit(text)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
@@ -192,11 +193,24 @@ class UserSuggestionState(
item: User,
): TextFieldValue {
val lastWordStart = message.selection.end - word.length
val wordToInsert = "@${item.pubkeyNpub()}"
val wordToInsert = "@${item.pubkeyNpub()} "
return TextFieldValue(
message.text.replaceRange(lastWordStart, message.selection.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length),
)
}
fun replaceCurrentWord(
state: TextFieldState,
word: String,
item: User,
) {
val wordToInsert = "@${item.pubkeyNpub()} "
state.edit {
val lastWordStart = selection.end - word.length
replace(lastWordStart, selection.end, wordToInsert)
selection = TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
}
}
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.note.nip22Comments
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -34,6 +36,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.currentWord
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -147,7 +150,7 @@ open class CommentPostViewModel :
var notifying by mutableStateOf<List<User>?>(null)
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -242,7 +245,7 @@ open class CommentPostViewModel :
}
open fun quote(quote: Note) {
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
message.setTextAndPlaceCursorAtBeginning(message.text.toString() + "\nnostr:${quote.toNEvent()}")
quote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
@@ -262,7 +265,7 @@ open class CommentPostViewModel :
wantsForwardZapTo = true
}
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draft: Note) {
@@ -322,11 +325,11 @@ open class CommentPostViewModel :
wantsForwardZapTo = true
}
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
suspend fun sendPostSync() {
@@ -359,7 +362,7 @@ open class CommentPostViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val attachments = mutableSetOf<Event>()
@@ -376,7 +379,7 @@ open class CommentPostViewModel :
private suspend fun createTemplate(): EventTemplate<out Event>? {
val tagger =
NewMessageTagger(
message = message.text,
message = message.text.toString(),
dao = accountViewModel,
)
tagger.run()
@@ -525,8 +528,8 @@ open class CommentPostViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message)
message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message.text.toString())
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
@@ -549,8 +552,8 @@ open class CommentPostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
message = message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message)
message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message.text.toString())
}
}
@@ -576,7 +579,7 @@ open class CommentPostViewModel :
open fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
replyingTo = null
externalIdentity = null
@@ -618,9 +621,8 @@ open class CommentPostViewModel :
notifying = notifying?.filter { it != userToRemove }
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
urlPreviews.update(message)
override fun onMessageChanged() {
urlPreviews.update(message.text.toString())
if (message.selection.collapsed) {
val lastWord = message.currentWord()
@@ -651,8 +653,8 @@ open class CommentPostViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message)
userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
@@ -668,8 +670,8 @@ open class CommentPostViewModel :
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -685,8 +687,8 @@ open class CommentPostViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -694,14 +696,14 @@ open class CommentPostViewModel :
}
fun canPost(): Boolean =
message.text.isNotBlank() &&
message.text.toString().isNotBlank() &&
!mediaUploadTracker.isUploading &&
!wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -718,7 +720,7 @@ open class CommentPostViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger =
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -46,7 +47,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -125,7 +125,8 @@ fun ReplyCommentPostScreen(
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {
@@ -53,8 +53,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
@Composable
fun BadgeDisplay(
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -97,11 +98,12 @@ fun RenderLiveChessChallenge(
) {
val event = (note.event as? LiveChessGameChallengeEvent) ?: return
val gameId = event.gameId()
val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity
val chessViewModel: ChessViewModelNew =
viewModel(
key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, activity.application),
)
val isOpenChallenge = event.opponentPubkey() == null
@@ -40,11 +40,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal.DraftEventsFeedF
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.dal.LongsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.dal.PictureFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal.ShortsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
import kotlinx.coroutines.CoroutineScope
@@ -72,6 +75,10 @@ class AccountFeedContentStates(
val pollsFeed = FeedContentState(PollsFeedFilter(account), scope, LocalCache)
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache)
val longsFeed = FeedContentState(LongsFeedFilter(account), scope, LocalCache)
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
val notificationsOpenPolls = OpenPollsState(account, scope)
val notificationSummary = NotificationSummaryState(account)
@@ -108,6 +115,10 @@ class AccountFeedContentStates(
pollsFeed.updateFeedWith(newNotes)
picturesFeed.updateFeedWith(newNotes)
shortsFeed.updateFeedWith(newNotes)
longsFeed.updateFeedWith(newNotes)
notifications.updateFeedWith(newNotes)
notificationSummary.invalidateInsertData(newNotes)
@@ -138,6 +149,10 @@ class AccountFeedContentStates(
pollsFeed.deleteFromFeed(newNotes)
picturesFeed.deleteFromFeed(newNotes)
shortsFeed.deleteFromFeed(newNotes)
longsFeed.deleteFromFeed(newNotes)
notifications.deleteFromFeed(newNotes)
notificationSummary.invalidateInsertData(newNotes)
@@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -850,6 +851,8 @@ class AccountViewModel(
fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex))
fun oldBookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(OldBookmarkListEvent.createBookmarkAddress(user.pubkeyHex))
fun pinnedNotes(user: User): Note = LocalCache.getOrCreateAddressableNote(PinListEvent.createPinAddress(user.pubkeyHex))
fun addPin(note: Note) {
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.Discove
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.launch
@@ -165,7 +166,22 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
@Composable
private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
if (accountViewModel.account.signer is IActivityLauncher) {
val externalSignerLauncher =
when (val signer = accountViewModel.account.signer) {
is IActivityLauncher -> {
signer
}
is NostrSignerWithClientTag if signer.inner is IActivityLauncher -> {
signer.inner as IActivityLauncher
}
else -> {
null
}
}
if (externalSignerLauncher != null) {
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
@@ -173,7 +189,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
if (result.resultCode == Activity.RESULT_OK) {
result.data?.let {
accountViewModel.runOnIO {
accountViewModel.account.signer.newResponse(it)
externalSignerLauncher.newResponse(it)
}
}
}
@@ -181,7 +197,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
)
DisposableEffect(accountViewModel, accountViewModel.account, launcher) {
val launcher: (Intent) -> Unit = { intent ->
val intentLauncher: (Intent) -> Unit = { intent ->
try {
launcher.launch(intent)
} catch (e: ActivityNotFoundException) {
@@ -193,9 +209,9 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
}
}
accountViewModel.account.signer.registerForegroundLauncher(launcher)
externalSignerLauncher.registerForegroundLauncher(intentLauncher)
onDispose {
accountViewModel.account.signer.unregisterForegroundLauncher(launcher)
externalSignerLauncher.unregisterForegroundLauncher(intentLauncher)
}
}
}
@@ -43,6 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.stringRes
@@ -55,8 +56,10 @@ import kotlinx.coroutines.flow.StateFlow
@Composable
fun ListOfBookmarkGroupsFeedView(
defaultBookmarks: BookmarkListState,
oldBookmarks: OldBookmarkListState,
groupListFeedSource: StateFlow<List<LabeledBookmarkList>>,
openDefaultBookmarks: () -> Unit,
openOldBookmarks: () -> Unit,
onOpenItem: (String, BookmarkType) -> Unit,
onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit,
onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -74,6 +77,11 @@ fun ListOfBookmarkGroupsFeedView(
HorizontalDivider(thickness = DividerThickness)
}
item {
OldBookmarkList(oldBookmarks, openOldBookmarks)
HorizontalDivider(thickness = DividerThickness)
}
itemsIndexed(
bookmarkGroupFeedState,
key = { _: Int, item: LabeledBookmarkList -> item.identifier },
@@ -135,3 +143,47 @@ fun DefaultBookmarkList(
},
)
}
@Composable
fun OldBookmarkList(
oldBookmarks: OldBookmarkListState,
openOldBookmarks: () -> Unit,
) {
val bookmarkState by oldBookmarks.bookmarks.collectAsStateWithLifecycle()
ListItem(
modifier = Modifier.clickable(onClick = openOldBookmarks),
headlineContent = {
Text(stringRes(R.string.old_bookmarks_title), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
supportingContent = {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringRes(R.string.old_bookmarks_explainer),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
}
},
leadingContent = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Outlined.BookmarkBorder,
contentDescription = stringRes(R.string.bookmark_list_icon_label),
modifier = Size40Modifier,
)
Spacer(StdVertSpacer)
BookmarkMembershipStatusAndNumberDisplay(
modifier = Modifier.align(Alignment.CenterHorizontally),
postBookmarksSize = bookmarkState.public.size + bookmarkState.private.size,
articleBookmarksSize = 0,
)
}
},
)
}
@@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -51,8 +52,10 @@ fun ListOfBookmarkGroupsScreen(
) {
ListOfBookmarkGroupsFeed(
defaultBookmarks = accountViewModel.account.bookmarkState,
oldBookmarks = accountViewModel.account.oldBookmarkState,
listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow,
openDefaultBookmarks = { nav.nav(Route.Bookmarks) },
openOldBookmarks = { nav.nav(Route.OldBookmarks) },
addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) },
openBookmarkGroup = { identifier, bookmarkType ->
nav.nav(Route.BookmarkGroupView(identifier, bookmarkType))
@@ -88,8 +91,10 @@ fun ListOfBookmarkGroupsScreen(
@Composable
fun ListOfBookmarkGroupsFeed(
defaultBookmarks: BookmarkListState,
oldBookmarks: OldBookmarkListState,
listSource: StateFlow<List<LabeledBookmarkList>>,
openDefaultBookmarks: () -> Unit,
openOldBookmarks: () -> Unit,
addBookmarkGroup: () -> Unit,
openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit,
renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -115,8 +120,10 @@ fun ListOfBookmarkGroupsFeed(
) {
ListOfBookmarkGroupsFeedView(
defaultBookmarks = defaultBookmarks,
oldBookmarks = oldBookmarks,
groupListFeedSource = listSource,
openDefaultBookmarks = openDefaultBookmarks,
openOldBookmarks = openOldBookmarks,
onOpenItem = openBookmarkGroup,
onRenameItem = renameBookmarkGroup,
onItemDescriptionChange = changeBookmarkGroupDescription,
@@ -0,0 +1,174 @@
/*
* 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.bookmarkgroups.old
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.DriveFileMove
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal.OldBookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal.OldBookmarkPublicFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch
@Composable
fun OldBookmarkListScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val publicFeedViewModel: OldBookmarkPublicFeedViewModel =
viewModel(
key = "NostrOldBookmarkPublicFeedViewModel",
factory = OldBookmarkPublicFeedViewModel.Factory(accountViewModel.account),
)
val privateFeedViewModel: OldBookmarkPrivateFeedViewModel =
viewModel(
key = "NostrOldBookmarkPrivateFeedViewModel",
factory = OldBookmarkPrivateFeedViewModel.Factory(accountViewModel.account),
)
val bookmarkState by accountViewModel.account.oldBookmarkState.bookmarks
.collectAsStateWithLifecycle(null)
LaunchedEffect(bookmarkState) {
publicFeedViewModel.invalidateData()
privateFeedViewModel.invalidateData()
}
RenderOldBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderOldBookmarkScreen(
publicFeedViewModel: OldBookmarkPublicFeedViewModel,
privateFeedViewModel: OldBookmarkPrivateFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pagerState = rememberPagerState { 2 }
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
Column {
TopBarWithBackButton(stringRes(id = R.string.old_bookmarks_title), nav::popBack)
SecondaryTabRow(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
selectedTabIndex = pagerState.currentPage,
modifier = TabRowHeight,
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = { Text(text = stringRes(R.string.private_bookmarks)) },
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = { Text(text = stringRes(R.string.public_bookmarks)) },
)
}
}
},
floatingButton = {
ExtendedFloatingActionButton(
text = { Text(stringRes(R.string.migrate_bookmarks_button)) },
icon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.DriveFileMove,
contentDescription = stringRes(R.string.migrate_bookmarks_button),
)
},
onClick = {
accountViewModel.launchSigner {
accountViewModel.account.migrateOldBookmarksToNew()
coroutineScope.launch {
Toast
.makeText(
context,
context.getString(R.string.migrate_bookmarks_success),
Toast.LENGTH_SHORT,
).show()
}
}
},
containerColor = MaterialTheme.colorScheme.primary,
)
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it).fillMaxHeight()) {
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> {
RefresheableFeedView(
privateFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}
1 -> {
RefresheableFeedView(
publicFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
}
@@ -0,0 +1,36 @@
/*
* 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.bookmarkgroups.old.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
class OldBookmarkPrivateFeedFilter(
val account: Account,
) : FeedFilter<Note>() {
override fun feedKey(): String =
account.oldBookmarkState.bookmarks.value
.hashCode()
.toString()
override fun feed(): List<Note> = account.oldBookmarkState.bookmarks.value.private
}
@@ -0,0 +1,39 @@
/*
* 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.bookmarkgroups.old.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
@Stable
class OldBookmarkPrivateFeedViewModel(
val account: Account,
) : AndroidFeedViewModel(OldBookmarkPrivateFeedFilter(account)) {
class Factory(
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = OldBookmarkPrivateFeedViewModel(account) as T
}
}
@@ -0,0 +1,36 @@
/*
* 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.bookmarkgroups.old.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
class OldBookmarkPublicFeedFilter(
val account: Account,
) : FeedFilter<Note>() {
override fun feedKey(): String =
account.oldBookmarkState.bookmarks.value
.hashCode()
.toString()
override fun feed(): List<Note> = account.oldBookmarkState.bookmarks.value.public
}
@@ -0,0 +1,39 @@
/*
* 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.bookmarkgroups.old.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
@Stable
class OldBookmarkPublicFeedViewModel(
val account: Account,
) : AndroidFeedViewModel(OldBookmarkPublicFeedFilter(account)) {
class Factory(
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = OldBookmarkPublicFeedViewModel(account) as T
}
}
@@ -24,11 +24,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
@@ -108,7 +108,8 @@ fun ChatroomView(
if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) {
newPostModel.updateMessage(TextFieldValue(draftMessage))
newPostModel.message.setTextAndPlaceCursorAtEnd(draftMessage)
newPostModel.onMessageChanged()
}
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -203,7 +205,7 @@ class ChatNewMessageViewModel :
var uploadsWaitingToBeSent by mutableStateOf<List<SuccessfulUploads>>(emptyList())
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -215,8 +217,8 @@ class ChatNewMessageViewModel :
var emojiSuggestions: EmojiSuggestionState? = null
var toUsers by mutableStateOf(TextFieldValue(""))
var subject by mutableStateOf(TextFieldValue(""))
val toUsers = TextFieldState()
val subject = TextFieldState()
// Invoices
var canAddInvoice by mutableStateOf(false)
@@ -273,10 +275,9 @@ class ChatNewMessageViewModel :
fun load(room: ChatroomKey) {
this.room.tryEmit(room)
this.toUsers =
TextFieldValue(
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
this.toUsers.setTextAndPlaceCursorAtEnd(
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
}
fun reply(replyNote: Note) {
@@ -290,8 +291,8 @@ class ChatNewMessageViewModel :
}
fun quote(quote: Note) {
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
urlPreviews.update(message)
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
urlPreviews.update(message.text.toString())
// creates a split with that author.
val quotedAuthor = quote.author ?: return
@@ -371,14 +372,13 @@ class ChatNewMessageViewModel :
}
draftEvent.subject()?.let {
subject = TextFieldValue()
subject.setTextAndPlaceCursorAtEnd("")
}
if (draftEvent is NIP17Group) {
toUsers =
TextFieldValue(
draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
toUsers.setTextAndPlaceCursorAtEnd(
draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
val replyId =
when (draftEvent) {
@@ -392,7 +392,7 @@ class ChatNewMessageViewModel :
}
} else if (draftEvent is PrivateDmEvent) {
val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() }
toUsers = TextFieldValue("@$recipientNPub")
toUsers.setTextAndPlaceCursorAtEnd("@$recipientNPub")
val replyId = draftEvent.replyTo()
if (replyId != null) {
@@ -400,13 +400,14 @@ class ChatNewMessageViewModel :
}
}
message =
val draftText =
if (draftEvent is PrivateDmEvent) {
TextFieldValue(accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: "")
accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: ""
} else {
TextFieldValue(draftEvent.content)
draftEvent.content
}
urlPreviews.update(message)
message.setTextAndPlaceCursorAtEnd(draftText)
urlPreviews.update(message.text.toString())
iMetaAttachments.addAll(draftEvent.imetas())
}
@@ -421,7 +422,7 @@ class ChatNewMessageViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
account.deleteDraftIgnoreErrors(draftTag.current)
} else {
innerSendPost(draftTag.current)
@@ -563,11 +564,12 @@ class ChatNewMessageViewModel :
private suspend fun innerSendPost(draftTag: String?) {
val room = room.value ?: return
val urls = findURLs(message.text)
val messageText = message.text.toString()
val urls = findURLs(messageText)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value)
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
val message = message.text
val message = messageText
val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null
val localExpirationDate = if (wantsExpirationDate) expirationDate else null
@@ -633,8 +635,8 @@ class ChatNewMessageViewModel :
fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
subject = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
subject.setTextAndPlaceCursorAtEnd("")
replyTo.value = null
@@ -667,15 +669,15 @@ class ChatNewMessageViewModel :
}
fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it))
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
urlPreviews.update(newMessage)
override fun onMessageChanged() {
urlPreviews.update(message.text.toString())
if (message.selection.collapsed) {
val lastWord = newMessage.currentWord()
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord)
@@ -689,11 +691,9 @@ class ChatNewMessageViewModel :
draftTag.newVersion()
}
fun updateToUsers(newToUsersValue: TextFieldValue) {
toUsers = newToUsersValue
if (newToUsersValue.selection.collapsed) {
val lastWord = newToUsersValue.currentWord()
fun onToUsersChanged() {
if (toUsers.selection.collapsed) {
val lastWord = toUsers.currentWord()
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
userSuggestions?.processCurrentWord(lastWord)
}
@@ -706,7 +706,7 @@ class ChatNewMessageViewModel :
fun updateRoomFromUsersInput() {
viewModelScope.launch(Dispatchers.IO) {
delay(300)
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, accountViewModel)
val toUsersTagger = NewMessageTagger(toUsers.text.toString(), null, null, accountViewModel)
toUsersTagger.run()
val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex }
@@ -720,8 +720,7 @@ class ChatNewMessageViewModel :
}
}
fun updateSubject(it: TextFieldValue) {
subject = it
fun onSubjectChanged() {
draftTag.newVersion()
}
@@ -738,14 +737,14 @@ class ChatNewMessageViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message)
userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = toUsers.currentWord()
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
updateRoomFromUsersInput()
}
@@ -759,8 +758,8 @@ class ChatNewMessageViewModel :
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -776,8 +775,8 @@ class ChatNewMessageViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -794,8 +793,8 @@ class ChatNewMessageViewModel :
recipientsMissingDmRelays.value.isEmpty()
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
urlPreviews.update(message)
message.insertUrlAtCursor(newElement)
urlPreviews.update(message.text.toString())
}
override fun onCleared() {
@@ -816,7 +815,7 @@ class ChatNewMessageViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
@@ -67,7 +68,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -80,7 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -158,7 +158,8 @@ fun NewGroupDMScreen(
LaunchedEffect(postViewModel, accountViewModel) {
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.let {
withContext(Dispatchers.IO) {
@@ -530,8 +531,8 @@ fun SendDirectMessageTo(
)
ThinPaddingTextField(
value = postViewModel.toUsers,
onValueChange = postViewModel::updateToUsers,
state = postViewModel.toUsers,
onTextChanged = postViewModel::onToUsersChanged,
modifier =
Modifier
.weight(1f)
@@ -547,8 +548,8 @@ fun SendDirectMessageTo(
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
outputTransformation =
UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
@@ -572,8 +573,8 @@ fun SendDirectMessageTo(
)
ThinPaddingTextField(
value = postViewModel.subject,
onValueChange = { postViewModel.updateSubject(it) },
state = postViewModel.subject,
onTextChanged = { postViewModel.onSubjectChanged() },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -581,8 +582,8 @@ fun SendDirectMessageTo(
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
outputTransformation =
UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
@@ -54,7 +54,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
@@ -201,8 +201,8 @@ fun EditField(
accountViewModel: AccountViewModel,
) {
ThinPaddingTextField(
value = channelScreenModel.message,
onValueChange = { channelScreenModel.updateMessage(it) },
state = channelScreenModel.message,
onTextChanged = { channelScreenModel.onMessageChanged() },
keyboardOptions = PostKeyboard,
shape = EditFieldBorder,
modifier = Modifier.fillMaxWidth(),
@@ -231,7 +231,7 @@ fun EditField(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
)
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -137,7 +139,7 @@ open class ChannelNewMessageViewModel :
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var message by mutableStateOf(TextFieldValue(""))
val message = TextFieldState()
var urlPreview by mutableStateOf<String?>(null)
val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false
val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false
@@ -274,7 +276,7 @@ open class ChannelNewMessageViewModel :
}
}
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
@@ -302,7 +304,7 @@ open class ChannelNewMessageViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val attachments = mutableSetOf<Event>()
@@ -365,14 +367,14 @@ open class ChannelNewMessageViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor(it.toNostrUri())
message.insertUrlAtCursor(it.toNostrUri())
}
urlPreview = findUrlInMessage()
} else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason)
message = message.insertUrlAtCursor(upload.result.url)
message.insertUrlAtCursor(upload.result.url)
urlPreview = findUrlInMessage()
}
}
@@ -392,18 +394,19 @@ open class ChannelNewMessageViewModel :
private suspend fun createTemplate(): EventTemplate<out Event>? {
val channel = channel ?: return null
val messageText = message.text.toString()
val tagger =
NewMessageTagger(
message = message.text,
message = messageText,
pTags = listOfNotNull(replyTo.value?.author),
eTags = listOfNotNull(replyTo.value),
dao = accountViewModel,
)
tagger.run()
val urls = findURLs(message.text)
val urls = findURLs(messageText)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value)
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
val channelRelays = channel.relays()
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
@@ -533,7 +536,7 @@ open class ChannelNewMessageViewModel :
open fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
replyTo.value = null
@@ -561,18 +564,18 @@ open class ChannelNewMessageViewModel :
emojiSuggestions?.reset()
}
open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text).withScheme.firstOrNull()
open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text.toString()).withScheme.firstOrNull()
open fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it))
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
}
open fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
open fun onMessageChanged() {
urlPreview = findUrlInMessage()
if (newMessage.selection.collapsed) {
val lastWord = newMessage.currentWord()
if (message.selection.collapsed) {
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord)
@@ -600,7 +603,7 @@ open class ChannelNewMessageViewModel :
userSuggestions?.let {
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = it.replaceCurrentWord(message, lastWord, item)
it.replaceCurrentWord(message, lastWord, item)
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.addItem(item)
forwardZapToEditting = TextFieldValue("")
@@ -615,7 +618,7 @@ open class ChannelNewMessageViewModel :
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
@@ -631,7 +634,7 @@ open class ChannelNewMessageViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
@@ -648,7 +651,7 @@ open class ChannelNewMessageViewModel :
uploadState?.multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
override fun locationManager(): LocationState = Amethyst.instance.locationManager
@@ -675,7 +678,7 @@ open class ChannelNewMessageViewModel :
fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.items.any { it.key == taggedUser }) {
@@ -38,7 +38,7 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -114,8 +114,8 @@ fun EditFieldRow(
}
ThinPaddingTextField(
value = channelScreenModel.message,
onValueChange = { channelScreenModel.updateMessage(it) },
state = channelScreenModel.message,
onTextChanged = { channelScreenModel.onMessageChanged() },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
@@ -151,7 +151,7 @@ fun EditFieldRow(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
)
}
}
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
import com.vitorpamplona.quartz.utils.Log
/**
* Android implementation of ChessEventPublisher using Jester protocol.
@@ -59,9 +60,9 @@ class AndroidChessPublisher(
* Uses ChessEventBroadcaster to ensure relay connections before sending.
*/
private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean {
println("[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster")
Log.d("chessdebug") { "[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster" }
val result = broadcaster.broadcast(event)
println("[AndroidChessPublisher] Broadcast result: ${result.message}")
Log.d("chessdebug") { "[AndroidChessPublisher] Broadcast result: ${result.message}" }
return result.success
}
@@ -92,7 +93,7 @@ class AndroidChessPublisher(
account.cache.justConsumeMyOwnEvent(signedEvent)
if (success) signedEvent.id else null
} catch (e: Exception) {
println("[AndroidChessPublisher] publishStart failed: ${e.message}")
Log.d("chessdebug") { "[AndroidChessPublisher] publishStart failed: ${e.message}" }
null
}
@@ -118,7 +119,7 @@ class AndroidChessPublisher(
account.cache.justConsumeMyOwnEvent(signedEvent)
if (success) signedEvent.id else null
} catch (e: Exception) {
println("[AndroidChessPublisher] publishMove failed: ${e.message}")
Log.d("chessdebug") { "[AndroidChessPublisher] publishMove failed: ${e.message}" }
null
}
@@ -145,7 +146,7 @@ class AndroidChessPublisher(
account.cache.justConsumeMyOwnEvent(signedEvent)
success
} catch (e: Exception) {
println("[AndroidChessPublisher] publishGameEnd failed: ${e.message}")
Log.d("chessdebug") { "[AndroidChessPublisher] publishGameEnd failed: ${e.message}" }
false
}
@@ -180,7 +181,7 @@ class AndroidRelayFetcher(
}
override fun getRelayUrls(): List<String> {
println("[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays")
Log.d("chessdebug") { "[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays" }
return ChessConfig.CHESS_RELAYS
}
@@ -193,7 +194,7 @@ class AndroidRelayFetcher(
* 2. Fetch moves that reference the start event via #e tag
*/
override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents {
println("[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId" }
// Filter 1: Fetch the start event by its ID
val startEventFilter =
@@ -223,7 +224,7 @@ class AndroidRelayFetcher(
}
}
println("[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}" }
return JesterGameEvents(
startEvent = startEvent,
@@ -237,30 +238,30 @@ class AndroidRelayFetcher(
*/
override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List<JesterEvent> {
val relays = chessRelayUrls()
println("[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays" }
if (relays.isEmpty()) {
println("[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!" }
return emptyList()
}
val filter = ChessFilterBuilder.challengesFilter(userPubkey)
println("[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}")
println("[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}" }
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}" }
val relayFilters = relays.associateWith { listOf(filter) }
val events = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress)
println("[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events" }
// Debug: If no events, also try without #e filter to see if relays have ANY kind 30 events
if (events.isEmpty()) {
println("[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter...")
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter..." }
val debugFilter = ChessFilterBuilder.recentGamesFilter()
val debugFilters = relays.associateWith { listOf(debugFilter) }
val debugEvents = fetchHelper.fetchEvents(debugFilters, timeoutMs = 10_000)
println("[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events")
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events" }
debugEvents.take(5).forEach { e ->
println("[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}")
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}" }
}
}
@@ -286,7 +287,7 @@ class AndroidRelayFetcher(
}
}
println("[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events" }
return challenges
}
@@ -356,7 +357,7 @@ class AndroidRelayFetcher(
* Uses one-shot fetch for reliability.
*/
override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set<String> {
println("[AndroidRelayFetcher] fetchUserGameIds: fetching from relays")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchUserGameIds: fetching from relays" }
// Fetch events authored by user AND events tagging user
val authoredFilter = ChessFilterBuilder.userGamesFilter(userPubkey)
@@ -386,7 +387,7 @@ class AndroidRelayFetcher(
}
}
println("[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events")
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events" }
return gameIds
}
}
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus
import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner
import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription
import com.vitorpamplona.amethyst.ui.stringRes
@@ -101,6 +102,7 @@ fun ChessGameScreen(
factory =
ChessViewModelFactory(
accountViewModel.account,
activity.application,
),
)
@@ -283,7 +285,10 @@ fun ChessGameScreen(
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { nav.popBack() }) {
Button(onClick = {
chessViewModel.removeGame(gameId)
nav.popBack()
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
@@ -295,12 +300,42 @@ fun ChessGameScreen(
}
else -> {
// Resolve opponent display name
// Resolve opponent display name and avatar
val opponentUser =
remember(gameState.opponentPubkey) {
accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey)
}
val opponentDisplayName =
remember(gameState.opponentPubkey) {
accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey)?.toBestDisplayName()
?: gameState.opponentPubkey.take(8)
opponentUser?.toBestDisplayName() ?: gameState.opponentPubkey.take(8)
}
val opponentAvatarUrl =
remember(gameState.opponentPubkey) {
opponentUser?.profilePicture()
}
// Resolve player display name and avatar
val playerUser =
remember(gameState.playerPubkey) {
accountViewModel.checkGetOrCreateUser(gameState.playerPubkey)
}
val playerDisplayName =
remember(gameState.playerPubkey) {
playerUser?.toBestDisplayName() ?: gameState.playerPubkey.take(8)
}
val playerAvatarUrl =
remember(gameState.playerPubkey) {
playerUser?.profilePicture()
}
// Determine white/black pubkeys, names and avatars based on player color
val isPlayerWhite = gameState.playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE
val whitePubkey = if (isPlayerWhite) gameState.playerPubkey else gameState.opponentPubkey
val blackPubkey = if (isPlayerWhite) gameState.opponentPubkey else gameState.playerPubkey
val whiteDisplayName = if (isPlayerWhite) playerDisplayName else opponentDisplayName
val blackDisplayName = if (isPlayerWhite) opponentDisplayName else playerDisplayName
val whiteAvatarUrl = if (isPlayerWhite) playerAvatarUrl else opponentAvatarUrl
val blackAvatarUrl = if (isPlayerWhite) opponentAvatarUrl else playerAvatarUrl
// Determine spectator status:
// 1. If game was accepted locally, user is definitely NOT a spectator
@@ -319,6 +354,23 @@ fun ChessGameScreen(
},
onResign = { chessViewModel.resign(gameId) },
isSpectatorOverride = isSpectating,
onGameEndDismiss = { chessViewModel.dismissGame(gameId) },
onLeaveSpectating =
if (isSpectating) {
{
chessViewModel.stopSpectating(gameId)
nav.popBack()
}
} else {
null
},
whiteName = whiteDisplayName,
whiteHex = whitePubkey,
whiteAvatarUrl = whiteAvatarUrl,
blackName = blackDisplayName,
blackHex = blackPubkey,
blackAvatarUrl = blackAvatarUrl,
onPlayerClick = { pubkeyHex -> nav.nav(Route.Profile(pubkeyHex)) },
)
}
}
@@ -45,6 +45,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -67,8 +68,10 @@ import com.vitorpamplona.amethyst.commons.chess.ActiveGameCard
import com.vitorpamplona.amethyst.commons.chess.ChallengeCard
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner
import com.vitorpamplona.amethyst.commons.chess.CompletedGameCard
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
import com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard
import com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars
import com.vitorpamplona.amethyst.commons.chess.PublicGameCard
import com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
@@ -94,7 +97,7 @@ fun ChessLobbyScreen(
viewModel(
viewModelStoreOwner = activity,
key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, activity.application),
)
// Subscribe to chess events when screen is visible
@@ -294,13 +297,19 @@ fun ChessLobbyContent(
val spectatingGames by chessViewModel.spectatingGames.collectAsState()
val publicGames by chessViewModel.publicGames.collectAsState()
val challenges by chessViewModel.challenges.collectAsState()
val completedGames by chessViewModel.completedGames.collectAsState()
val userPubkey = accountViewModel.account.userProfile().pubkeyHex
val currentUser =
remember(userPubkey) {
accountViewModel.checkGetOrCreateUser(userPubkey)
}
val hasContent =
activeGames.isNotEmpty() ||
spectatingGames.isNotEmpty() ||
publicGames.isNotEmpty() ||
challenges.isNotEmpty()
challenges.isNotEmpty() ||
completedGames.isNotEmpty()
if (!hasContent) {
// Empty state - use LazyColumn so pull-to-refresh works
@@ -359,15 +368,24 @@ fun ChessLobbyContent(
}
items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) ->
val displayName =
val opponent =
remember(state.opponentPubkey) {
accountViewModel.checkGetOrCreateUser(state.opponentPubkey)?.toBestDisplayName() ?: state.opponentPubkey.take(8)
accountViewModel.checkGetOrCreateUser(state.opponentPubkey)
}
val displayName = opponent?.toBestDisplayName() ?: state.opponentPubkey.take(8)
ActiveGameCard(
gameId = gameId,
opponentName = displayName,
isYourTurn = state.isPlayerTurn(),
onClick = { onSelectGame(gameId) },
avatar = {
OverlappingAvatars(
avatar1Hex = state.playerPubkey,
avatar2Hex = state.opponentPubkey,
avatar1Url = currentUser?.profilePicture(),
avatar2Url = opponent?.profilePicture(),
)
},
)
}
}
@@ -386,14 +404,28 @@ fun ChessLobbyContent(
}
items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge ->
val opponentName =
challenge.opponentPubkey?.let { pubkey ->
accountViewModel.checkGetOrCreateUser(pubkey)?.toBestDisplayName() ?: pubkey.take(8)
val opponentUser =
remember(challenge.opponentPubkey) {
challenge.opponentPubkey?.let { accountViewModel.checkGetOrCreateUser(it) }
}
val opponentName =
opponentUser?.toBestDisplayName()
?: challenge.opponentPubkey?.take(8)
OutgoingChallengeCard(
opponentName = opponentName,
userPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
onClick = { onOpenOwnChallenge(challenge) },
avatar =
challenge.opponentPubkey?.let { opPubkey ->
{
OverlappingAvatars(
avatar1Hex = userPubkey,
avatar2Hex = opPubkey,
avatar1Url = currentUser?.profilePicture(),
avatar2Url = opponentUser?.profilePicture(),
)
}
},
)
}
}
@@ -447,6 +479,14 @@ fun ChessLobbyContent(
challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
isIncoming = true,
onAccept = { onAcceptChallenge(challenge) },
avatar = {
OverlappingAvatars(
avatar1Hex = challenge.challengerPubkey,
avatar2Hex = userPubkey,
avatar1Url = challenge.challengerAvatarUrl,
avatar2Url = currentUser?.profilePicture(),
)
},
)
}
}
@@ -476,6 +516,14 @@ fun ChessLobbyContent(
challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
isIncoming = false,
onAccept = { onAcceptChallenge(challenge) },
avatar = {
OverlappingAvatars(
avatar1Hex = challenge.challengerPubkey,
avatar2Hex = userPubkey,
avatar1Url = challenge.challengerAvatarUrl,
avatar2Url = currentUser?.profilePicture(),
)
},
)
}
}
@@ -504,6 +552,63 @@ fun ChessLobbyContent(
}
}
// Finished games section
if (completedGames.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Finished Games",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
if (completedGames.size >= 2) {
TextButton(onClick = { chessViewModel.dismissAllCompletedGames() }) {
Text("Clear all")
}
}
}
}
items(
completedGames.take(10),
key = { "completed-${it.gameId}-${it.completedAt}" },
) { game ->
val opponentPubkey =
if (game.whitePubkey == userPubkey) game.blackPubkey else game.whitePubkey
val opponentUser =
remember(opponentPubkey) {
accountViewModel.checkGetOrCreateUser(opponentPubkey)
}
val opponentName =
opponentUser?.toBestDisplayName()
?: game.blackDisplayName
?: game.whiteDisplayName
?: opponentPubkey.take(8)
CompletedGameCard(
opponentName = opponentName,
result = game.result,
didUserWin = game.didUserWin(userPubkey),
isDraw = game.isDraw,
moveCount = game.moveCount,
onClick = { onSelectGame(game.gameId) },
onDismiss = { chessViewModel.dismissCompletedGame(game.gameId) },
avatar = {
OverlappingAvatars(
avatar1Hex = userPubkey,
avatar2Hex = opponentPubkey,
avatar1Url = currentUser?.profilePicture(),
avatar2Url = opponentUser?.profilePicture(),
)
},
)
}
}
// Bottom padding for FAB
item {
Spacer(Modifier.height(80.dp))
@@ -30,11 +30,12 @@ import com.vitorpamplona.amethyst.model.Account
*/
class ChessViewModelFactory(
private val account: Account,
private val application: android.app.Application,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ChessViewModelNew::class.java)) {
return ChessViewModelNew(account) as T
return ChessViewModelNew(account, application) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
@@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
import com.vitorpamplona.amethyst.commons.chess.ChessDismissedGamesStorage
import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus
@@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.StateFlow
/**
@@ -50,6 +52,7 @@ import kotlinx.coroutines.flow.StateFlow
@Stable
class ChessViewModelNew(
private val account: Account,
application: android.app.Application,
) : ViewModel() {
// Instance ID for debugging ViewModel sharing
val instanceId = System.identityHashCode(this)
@@ -58,6 +61,7 @@ class ChessViewModelNew(
private val publisher = AndroidChessPublisher(account)
private val fetcher = AndroidRelayFetcher(account)
private val metadataProvider = AndroidMetadataProvider()
private val dismissedStorage = ChessDismissedGamesStorage.create(application)
// Shared business logic (creates its own ChessLobbyState internally)
private val logic =
@@ -68,6 +72,7 @@ class ChessViewModelNew(
metadataProvider = metadataProvider,
scope = viewModelScope,
pollingConfig = ChessPollingDefaults.android,
dismissedStorage = dismissedStorage,
)
// ============================================
@@ -94,6 +99,7 @@ class ChessViewModelNew(
// ============================================
init {
Log.d("chessdebug") { "[AndroidVM] init: instanceId=$instanceId, userPubkey=${account.userProfile().pubkeyHex.take(8)}" }
logic.startPolling()
}
@@ -103,6 +109,10 @@ class ChessViewModelNew(
fun forceRefresh() = logic.forceRefresh()
fun dismissCompletedGame(gameId: String) = logic.dismissCompletedGame(gameId)
fun dismissAllCompletedGames() = logic.dismissAllCompletedGames()
/**
* Ensure a game ID is being polled for updates.
* Call this when entering a game screen.
@@ -132,7 +142,12 @@ class ChessViewModelNew(
fun handleIncomingEvent(event: Event) {
if (event.kind != JesterProtocol.KIND) return
val jesterEvent = event.toJesterEvent() ?: return
val jesterEvent =
event.toJesterEvent() ?: run {
Log.d("chessdebug") { "[AndroidVM] handleIncomingEvent: failed to parse kind ${event.kind} event ${event.id.take(8)} as JesterEvent" }
return
}
Log.d("chessdebug") { "[AndroidVM] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${jesterEvent.isStartEvent()}, isMove=${jesterEvent.isMoveEvent()}" }
logic.handleIncomingEvent(jesterEvent)
}
@@ -166,6 +181,8 @@ class ChessViewModelNew(
fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId)
fun dismissGame(gameId: String) = logic.dismissGame(gameId)
// ============================================
// Spectator operations
// ============================================
@@ -174,7 +191,9 @@ class ChessViewModelNew(
fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId)
fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId)
fun stopSpectating(gameId: String) = logic.stopSpectating(gameId)
fun removeGame(gameId: String) = logic.removeGame(gameId)
// ============================================
// Utility
@@ -22,9 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Query state for chess subscription
@@ -39,16 +44,43 @@ data class ChessQueryState(
)
/**
* Sub-assembler that creates the actual relay filters
* Sub-assembler that creates the actual relay filters.
* Intercepts incoming chess events from relays and forwards them to the ViewModel.
*/
class ChessFeedFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<ChessQueryState>,
) : PerUniqueIdEoseManager<ChessQueryState, String>(client, allKeys) {
var onChessEvent: ((Event) -> Unit)? = null
override fun updateFilter(
key: ChessQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = filterChessEvents(key, since)
override fun id(key: ChessQueryState): String = key.userPubkey
override fun newSub(key: ChessQueryState): Subscription =
requestNewSubscription(
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (isLive) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
onChessEvent?.invoke(event)
}
},
)
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
/**
@@ -29,10 +30,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
class ChessFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<ChessQueryState>() {
val group =
listOf(
ChessFeedFilterSubAssembler(client, ::allKeys),
)
private val subAssembler = ChessFeedFilterSubAssembler(client, ::allKeys)
val group = listOf(subAssembler)
fun setOnChessEvent(callback: ((Event) -> Unit)?) {
subAssembler.onChessEvent = callback
}
override fun invalidateKeys() = invalidateFilters()
@@ -66,8 +66,19 @@ fun ChessSubscription(
)
}
// Wire incoming chess events from relay subscriptions to the ViewModel
val chessAssembler = accountViewModel.dataSources().chess
DisposableEffect(chessAssembler, chessViewModel) {
chessAssembler.setOnChessEvent { event ->
chessViewModel.handleIncomingEvent(event)
}
onDispose {
chessAssembler.setOnChessEvent(null)
}
}
// Register subscription with Amethyst's subscription system
KeyDataSourceSubscription(state, accountViewModel.dataSources().chess)
KeyDataSourceSubscription(state, chessAssembler)
// Trigger ViewModel refresh when subscription state changes
// This fetches challenges from LocalCache after events arrive
@@ -62,12 +62,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
@@ -293,7 +294,7 @@ private fun MarkdownPostScreenBody(
.padding(Size10dp),
) {
RenderContentAsMarkdown(
content = postViewModel.message.text,
content = postViewModel.message.text.toString(),
tags = EmptyTagList,
canPreview = true,
quotesLeft = 1,
@@ -304,9 +305,9 @@ private fun MarkdownPostScreenBody(
}
} else {
// Markdown editor
OutlinedTextField(
value = postViewModel.message,
onValueChange = postViewModel::updateMessage,
ThinPaddingTextField(
state = postViewModel.message,
onTextChanged = postViewModel::onMessageChanged,
modifier =
Modifier
.fillMaxWidth()
@@ -328,7 +329,7 @@ private fun MarkdownPostScreenBody(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -147,7 +149,7 @@ class LongFormPostViewModel :
var isUploadingCoverImage by mutableStateOf(false)
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
var showPreview by mutableStateOf(false)
@@ -241,7 +243,7 @@ class LongFormPostViewModel :
summary = TextFieldValue(noteEvent.summary() ?: "")
publishedAt = noteEvent.publishedAt() ?: noteEvent.createdAt
coverImageUrl = noteEvent.image() ?: ""
message = TextFieldValue(noteEvent.content)
message.setTextAndPlaceCursorAtEnd(noteEvent.content)
existingDTag = noteEvent.dTag()
}
@@ -265,7 +267,7 @@ class LongFormPostViewModel :
summary = TextFieldValue(draftEvent.summary() ?: "")
publishedAt = draftEvent.publishedAt() ?: draftEvent.createdAt
coverImageUrl = draftEvent.image() ?: ""
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
existingDTag = draftEvent.dTag()
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
@@ -335,7 +337,7 @@ class LongFormPostViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank() && title.text.isBlank()) {
if (message.text.toString().isBlank() && title.text.isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val template = createTemplate() ?: return
@@ -349,7 +351,7 @@ class LongFormPostViewModel :
val tagger =
NewMessageTagger(
message.text,
message.text.toString(),
null,
null,
accountViewModel,
@@ -539,7 +541,7 @@ class LongFormPostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
val markdownImage = "![${alt ?: ""}](${state.result.url})"
message = message.insertUrlAtCursor(markdownImage)
message.insertUrlAtCursor(markdownImage)
}
}
@@ -559,7 +561,7 @@ class LongFormPostViewModel :
title = TextFieldValue("")
summary = TextFieldValue("")
coverImageUrl = ""
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
publishedAt = TimeUtils.now()
showPreview = false
@@ -594,11 +596,9 @@ class LongFormPostViewModel :
this.multiOrchestrator?.remove(selected)
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
override fun onMessageChanged() {
if (message.selection.collapsed) {
val lastWord = newMessage.currentWord()
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord)
@@ -626,7 +626,7 @@ class LongFormPostViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
userSuggestions.replaceCurrentWord(message, lastWord, item)
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
@@ -641,7 +641,7 @@ class LongFormPostViewModel :
fun autocompleteWithEmoji(item: EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -655,21 +655,21 @@ class LongFormPostViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
draftTag.newVersion()
}
fun canPost(): Boolean =
title.text.isNotBlank() &&
message.text.isNotBlank() &&
message.text.toString().isNotBlank() &&
!isUploadingImage &&
!wantsInvoice &&
(!wantsZapRaiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -699,7 +699,7 @@ class LongFormPostViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger =
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -75,5 +75,5 @@ open class DiscoverMarketplaceFeedFilter(
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
}
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@@ -41,7 +42,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -112,7 +112,8 @@ fun NewProductScreen(
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -131,7 +133,7 @@ open class NewProductViewModel :
var productImages by mutableStateOf<List<ProductImageMeta>>(emptyList())
val iMetaDescription = IMetaAttachments()
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -151,10 +153,10 @@ open class NewProductViewModel :
val strippingFailureConfirmation = SuspendableConfirmation()
// Classifieds
var title by mutableStateOf(TextFieldValue(""))
var price by mutableStateOf(TextFieldValue(""))
var locationText by mutableStateOf(TextFieldValue(""))
var category by mutableStateOf(TextFieldValue(""))
val title = TextFieldState()
val price = TextFieldState()
val locationText = TextFieldState()
val category = TextFieldState()
var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW)
// Invoices
@@ -226,7 +228,7 @@ open class NewProductViewModel :
open fun quote(quote: Note) {
val accountViewModel = accountViewModel
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
quote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
@@ -246,7 +248,7 @@ open class NewProductViewModel :
wantsForwardZapTo = true
}
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draft: Note) {
@@ -284,10 +286,10 @@ open class NewProductViewModel :
zapRaiserAmount.value = zapraiser
}
title = TextFieldValue(draftEvent.title() ?: "")
price = TextFieldValue(draftEvent.price()?.amount ?: "")
category = TextFieldValue(draftEvent.categories().firstOrNull() ?: "")
locationText = TextFieldValue(draftEvent.location() ?: "")
title.setTextAndPlaceCursorAtEnd(draftEvent.title() ?: "")
price.setTextAndPlaceCursorAtEnd(draftEvent.price()?.amount ?: "")
category.setTextAndPlaceCursorAtEnd(draftEvent.categories().firstOrNull() ?: "")
locationText.setTextAndPlaceCursorAtEnd(draftEvent.location() ?: "")
condition = draftEvent.conditionValid() ?: ConditionTag.CONDITION.USED_LIKE_NEW
val imageSet = draftEvent.images().toMutableSet()
@@ -305,9 +307,9 @@ open class NewProductViewModel :
productImages = productImages + ProductImageMeta(it)
}
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
suspend fun sendPostSync() {
@@ -323,7 +325,7 @@ open class NewProductViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val template = createTemplate() ?: return
@@ -336,7 +338,7 @@ open class NewProductViewModel :
val tagger =
NewMessageTagger(
message = message.text,
message = message.text.toString(),
dao = accountViewModel,
)
tagger.run()
@@ -356,15 +358,15 @@ open class NewProductViewModel :
val template =
ClassifiedsEvent.build(
title.text,
PriceTag(price.text, "SATS", null),
title.text.toString(),
PriceTag(price.text.toString(), "SATS", null),
tagger.message,
locationText.text.ifBlank { null },
locationText.text.toString().ifBlank { null },
condition,
) {
productImages.forEach { image(it.url) }
hashtags(listOfNotNull(category.text.ifBlank { null }) + findHashtags(tagger.message))
hashtags(listOfNotNull(category.text.toString().ifBlank { null }) + findHashtags(tagger.message))
quotes(quotes)
geoHash?.let { geohash(it) }
@@ -438,8 +440,8 @@ open class NewProductViewModel :
} else {
iMetaDescription.add(it.result, alt, contentWarningReason)
message = message.insertUrlAtCursor(it.result.url)
urlPreviews.update(message)
message.insertUrlAtCursor(it.result.url)
urlPreviews.update(message.text.toString())
}
}
}
@@ -458,7 +460,7 @@ open class NewProductViewModel :
open fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
multiOrchestrator = null
mediaUploadTracker.finishUpload()
@@ -468,10 +470,10 @@ open class NewProductViewModel :
zapRaiserAmount.value = null
condition = ConditionTag.CONDITION.USED_LIKE_NEW
locationText = TextFieldValue("")
title = TextFieldValue("")
category = TextFieldValue("")
price = TextFieldValue("")
locationText.setTextAndPlaceCursorAtEnd("")
title.setTextAndPlaceCursorAtEnd("")
category.setTextAndPlaceCursorAtEnd("")
price.setTextAndPlaceCursorAtEnd("")
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
@@ -505,9 +507,8 @@ open class NewProductViewModel :
this.multiOrchestrator?.remove(selected)
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
urlPreviews.update(message)
override fun onMessageChanged() {
urlPreviews.update(message.text.toString())
if (message.selection.collapsed) {
val lastWord = message.currentWord()
@@ -538,8 +539,8 @@ open class NewProductViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message)
userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
@@ -555,8 +556,8 @@ open class NewProductViewModel :
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -572,8 +573,8 @@ open class NewProductViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -591,7 +592,7 @@ open class NewProductViewModel :
multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -620,7 +621,7 @@ open class NewProductViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -648,19 +649,11 @@ open class NewProductViewModel :
draftTag.newVersion()
}
fun updateTitle(it: TextFieldValue) {
title = it
fun onTitleChanged() {
draftTag.newVersion()
}
fun updatePrice(it: TextFieldValue) {
runCatching {
if (it.text.isEmpty()) {
price = TextFieldValue("")
} else if (it.text.toLongOrNull() != null) {
price = it
}
}
fun onPriceChanged() {
draftTag.newVersion()
}
@@ -669,13 +662,12 @@ open class NewProductViewModel :
draftTag.newVersion()
}
fun updateCategory(value: TextFieldValue) {
category = value
fun updateCategory(text: String) {
category.setTextAndPlaceCursorAtEnd(text)
draftTag.newVersion()
}
fun updateLocation(it: TextFieldValue) {
locationText = it
fun onLocationChanged() {
draftTag.newVersion()
}
@@ -44,12 +44,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.TextFieldValue // kept for readonly dropdown ThinPaddingTextField
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
@@ -109,10 +109,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
)
ThinPaddingTextField(
value = postViewModel.title,
onValueChange = {
postViewModel.updateTitle(it)
},
state = postViewModel.title,
onTextChanged = { postViewModel.onTitleChanged() },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -120,8 +118,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
outputTransformation =
UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
@@ -146,10 +144,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
ThinPaddingTextField(
modifier = Modifier.fillMaxWidth(),
value = postViewModel.price,
onValueChange = {
postViewModel.updatePrice(it)
},
state = postViewModel.price,
onTextChanged = { postViewModel.onPriceChanged() },
placeholder = {
Text(
text = "1000",
@@ -275,10 +271,10 @@ fun SellProduct(postViewModel: NewProductViewModel) {
categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList()
}
TextSpinner(
placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text }?.second ?: "",
placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text.toString() }?.second ?: "",
options = categoryOptions,
onSelect = {
postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second))
postViewModel.updateCategory(categoryTypes[it].second)
},
modifier =
Modifier
@@ -313,10 +309,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
)
ThinPaddingTextField(
value = postViewModel.locationText,
onValueChange = {
postViewModel.updateLocation(it)
},
state = postViewModel.locationText,
onTextChanged = { postViewModel.onLocationChanged() },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -324,8 +318,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
outputTransformation =
UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -67,7 +67,8 @@ fun GeoHashPostScreen(
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -67,7 +67,8 @@ fun HashtagPostScreen(
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import androidx.activity.compose.LocalActivity
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.FloatingActionButton
@@ -47,11 +48,12 @@ fun NewChessGameButton(
nav: INav,
) {
var showDialog by remember { mutableStateOf(false) }
val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity
val chessViewModel: ChessViewModelNew =
viewModel(
key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, activity.application),
)
FloatingActionButton(
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Poll
@@ -54,7 +55,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.IntentCompat
@@ -150,7 +150,8 @@ fun ShortNotePostScreen(
val draft = draftId?.let { accountViewModel.getNoteIfExists(it) }
postViewModel.load(baseReplyTo, quote, fork, version, draft)
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {
@@ -322,6 +323,13 @@ private fun NewPostScreenBody(
MessageField(
R.string.what_s_on_your_mind,
postViewModel,
onContentReceived = { uri, mimeType ->
postViewModel.selectImage(
persistentListOf(
SelectedMedia(uri, mimeType),
),
)
},
)
}
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -36,6 +38,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.currentWord
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -183,7 +186,7 @@ open class ShortNotePostViewModel :
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -367,7 +370,7 @@ open class ShortNotePostViewModel :
multiOrchestrator = null
quote?.let { quotedNote ->
message = TextFieldValue(message.text + "\nnostr:${quotedNote.toNEvent()}")
message.setTextAndPlaceCursorAtBeginning(message.text.toString() + "\nnostr:${quotedNote.toNEvent()}")
quotedNote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != user.pubkeyHex) {
@@ -385,7 +388,7 @@ open class ShortNotePostViewModel :
}
fork?.let { forkedNoted ->
message = TextFieldValue(version?.event?.content ?: forkedNoted.event?.content ?: "")
message.setTextAndPlaceCursorAtEnd(version?.event?.content ?: forkedNoted.event?.content ?: "")
forkedNoted.event?.isSensitiveOrNSFW()?.let {
if (it) wantsToMarkAsSensitive = true
@@ -436,7 +439,7 @@ open class ShortNotePostViewModel :
}
}
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draft: Note) {
@@ -533,11 +536,11 @@ open class ShortNotePostViewModel :
wantsPoll = false
wantsZapPoll = false
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draftEvent: PollEvent) {
@@ -605,11 +608,11 @@ open class ShortNotePostViewModel :
pollType = draftEvent.pollType()
closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead()
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draftEvent: ZapPollEvent) {
@@ -679,11 +682,11 @@ open class ShortNotePostViewModel :
zapPollConsensusThreshold = draftEvent.consensusThreshold()?.let { (it * 100).toInt() }
zapPollClosedAt = draftEvent.closedAt() ?: TimeUtils.oneDayAhead()
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
suspend fun sendPostSync() {
@@ -750,7 +753,7 @@ open class ShortNotePostViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val attachments = mutableSetOf<Event>()
@@ -811,7 +814,7 @@ open class ShortNotePostViewModel :
val tagger =
NewMessageTagger(
message.text,
message.text.toString(),
pTags,
eTags,
accountViewModel,
@@ -1000,8 +1003,8 @@ open class ShortNotePostViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message)
message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message.text.toString())
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
@@ -1024,8 +1027,8 @@ open class ShortNotePostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
message = message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message)
message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message.text.toString())
}
}
@@ -1042,7 +1045,7 @@ open class ShortNotePostViewModel :
open fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
forkedFromNote = null
@@ -1104,15 +1107,15 @@ open class ShortNotePostViewModel :
}
open fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it))
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
urlPreviews.update(message)
override fun onMessageChanged() {
urlPreviews.update(message.text.toString())
if (message.selection.collapsed) {
val lastWord = newMessage.currentWord()
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord)
@@ -1140,8 +1143,8 @@ open class ShortNotePostViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message)
userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
@@ -1157,8 +1160,8 @@ open class ShortNotePostViewModel :
open fun autocompleteWithEmoji(item: EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -1174,8 +1177,8 @@ open class ShortNotePostViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -1201,7 +1204,7 @@ open class ShortNotePostViewModel :
}
// Regular text/media posts require text
return message.text.isNotBlank() &&
return message.text.toString().isNotBlank() &&
!mediaUploadTracker.isUploading &&
!isUploadingVoice &&
!wantsInvoice &&
@@ -1226,7 +1229,7 @@ open class ShortNotePostViewModel :
}
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -1378,7 +1381,7 @@ open class ShortNotePostViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger =
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.VideoCardCompose
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip71Video.VideoEvent
@Composable
fun LongsFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
if (item.event is VideoEvent) {
VideoCardCompose(
baseNote = item,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(
thickness = DividerThickness,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@@ -0,0 +1,118 @@
/*
* 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.longs
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.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssemblerSubscription
@Composable
fun LongsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
LongsScreen(
longsFeedContentState = accountViewModel.feedStates.longsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun LongsScreen(
longsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(longsFeedContentState)
WatchAccountForLongsScreen(videoFeedState = longsFeedContentState, accountViewModel = accountViewModel)
LongsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
LongsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Longs, accountViewModel) { route ->
if (route == Route.Longs) {
longsFeedContentState.sendToTop()
} else {
nav.newStack(route)
}
}
},
accountViewModel = accountViewModel,
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
RefresheableBox(longsFeedContentState, true) {
SaveableFeedContentState(longsFeedContentState, scrollStateKey = ScrollStateKeys.LONGS_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = longsFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "LongsFeed",
onLoaded = { loaded ->
LongsFeedLoaded(
loaded = loaded,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
}
}
}
}
}
@Composable
fun WatchAccountForLongsScreen(
videoFeedState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveLongsFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
videoFeedState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* 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.longs
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.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun LongsTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultLongsFollowList
.collectAsStateWithLifecycle()
LongsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultLongsFollowList,
)
}
}
@Composable
private fun LongsTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,118 @@
/*
* 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.longs.dal
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.SupportedContent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoMeta
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import kotlin.collections.plus
class LongsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultLongsFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, params)
} +
LocalCache.addressables.filterIntoSet(VideoHorizontalEvent.KIND) { _, it ->
acceptableEvent(it, params)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.liveLongsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) {
acceptableEvent(it, params)
}
}
val videoFeedSupport =
SupportedContent(
blockedUrls = listOf("youtu.be", "youtube.com"),
mimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES_SET,
supportedFileExtensions = (RichTextParser.videoExtensions + RichTextParser.imageExtensions).toSet(),
)
fun acceptableVideoiMetas(iMetas: List<VideoMeta>): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) }
fun acceptanceEvent(noteEvent: VideoHorizontalEvent) = acceptableVideoiMetas(noteEvent.imetaTags())
fun acceptanceEvent(noteEvent: VideoNormalEvent) = acceptableVideoiMetas(noteEvent.imetaTags())
fun acceptableEvent(
note: Note,
params: FilterByListParams,
): Boolean {
val noteEvent = note.event
if (noteEvent is AddressableEvent && note !is AddressableNote) {
return false
}
return (
(noteEvent is VideoHorizontalEvent && acceptanceEvent(noteEvent)) ||
(noteEvent is VideoNormalEvent && acceptanceEvent(noteEvent))
) &&
params.match(noteEvent, note.relays) &&
(params.isHiddenList || account.isAcceptable(note))
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,50 @@
/*
* 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.longs.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class LongsQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class LongsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<LongsQueryState>() {
val group =
listOf(
LongsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,48 @@
/*
* 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.longs.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun LongsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
LongsFilterAssemblerSubscription(
accountViewModel.dataSources().longs,
accountViewModel,
)
}
@Composable
fun LongsFilterAssemblerSubscription(
dataSource: LongsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
LongsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
KeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,97 @@
/*
* 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.longs.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class LongsSubAssembler(
client: INostrClient,
allKeys: () -> Set<LongsQueryState>,
) : PerUserAndFollowListEoseManager<LongsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: LongsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val feedSettings = key.followsPerRelay()
return makeLongsFilter(feedSettings, since, key.feedStates.longsFeed.lastNoteCreatedAtIfFilled())
}
override fun user(key: LongsQueryState) = key.account.userProfile()
override fun list(key: LongsQueryState) = key.listName()
fun LongsQueryState.listNameFlow() = account.settings.defaultLongsFollowList
fun LongsQueryState.listName() = listNameFlow().value
fun LongsQueryState.followsPerRelayFlow() = account.liveLongsFollowListsPerRelay
fun LongsQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: LongsQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.longsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByCommunity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByGeohashes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByMutedAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsGlobal
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun makeLongsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllCommunitiesTopNavPerRelayFilterSet -> filterLongsByAllCommunities(feedSettings, since, defaultSince)
is AllFollowsTopNavPerRelayFilterSet -> filterLongsByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterLongsByAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterLongsGlobal(feedSettings, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterLongsByHashtag(feedSettings, since, defaultSince)
is LocationTopNavPerRelayFilterSet -> filterLongsByGeohashes(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterLongsByMutedAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterLongsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
@@ -0,0 +1,154 @@
/*
* 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.longs.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
val LongsFromCommunityKinds =
listOf(
VideoNormalEvent.KIND,
VideoHorizontalEvent.KIND,
)
val LongsFromCommunityKindsStr =
listOf(
VideoNormalEvent.KIND.toString(),
VideoHorizontalEvent.KIND.toString(),
)
fun filterLongsFromAllCommunities(
relay: NormalizedRelayUrl,
communities: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> {
val communityList = communities.sorted()
return listOf(
// approved
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = CommunityPostApprovalEvent.KIND_LIST,
tags =
mapOf(
"a" to communityList,
"k" to LongsFromCommunityKindsStr,
),
limit = communityList.size * 20,
since = since,
),
),
// not approved
RelayBasedFilter(
relay = relay,
filter =
Filter(
tags = mapOf("a" to communityList),
kinds = LongsFromCommunityKinds,
limit = communityList.size * 20,
since = since,
),
),
)
}
fun filterLongsByAllCommunities(
communitySet: AllCommunitiesTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
return communitySet.set
.mapNotNull {
filterLongsFromAllCommunities(
relay = it.key,
communities = it.value.communities,
since = since?.get(it.key)?.time ?: defaultSince,
)
}.flatten()
}
fun filterLongsFromCommunity(
relay: NormalizedRelayUrl,
community: String,
authors: Set<String>?,
since: Long? = null,
): List<RelayBasedFilter> {
val authors = authors?.sorted()
return listOf(
// approved
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authors,
kinds = CommunityPostApprovalEvent.KIND_LIST,
tags =
mapOf(
"a" to listOf(community),
"k" to LongsFromCommunityKindsStr,
),
limit = 100,
since = since,
),
),
// not approved
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authors,
tags = mapOf("a" to listOf(community)),
kinds = LongsFromCommunityKinds,
limit = 100,
since = since,
),
),
)
}
fun filterLongsByCommunity(
communitySet: SingleCommunityTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
return communitySet.set
.mapNotNull {
filterLongsFromCommunity(
relay = it.key,
community = it.value.community,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}.flatten()
}
@@ -0,0 +1,93 @@
/*
* 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.longs.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
fun filterLongsByAuthors(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
val authorList = authors.sorted()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authorList,
kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND),
limit = 200,
since = since,
),
),
)
}
fun filterLongsByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterLongsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
fun filterLongsByMutedAuthors(
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterLongsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,44 @@
/*
* 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.longs.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun filterLongsByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
return followsSet.set.flatMap {
val since = since?.get(it.key)?.time ?: defaultSince
val relay = it.key
listOfNotNull(
it.value.authors?.let {
filterLongsByAuthors(relay, it, since)
},
).flatten()
}
}
@@ -0,0 +1,71 @@
/*
* 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.longs.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
fun filterLongsByGeohashes(
relay: NormalizedRelayUrl,
geotags: Set<String>,
since: Long?,
): List<RelayBasedFilter> {
if (geotags.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND),
tags = mapOf("g" to geotags.sorted()),
limit = 100,
since = since,
),
),
)
}
fun filterLongsByGeohashes(
geoSet: LocationTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> {
if (geoSet.set.isEmpty()) return emptyList()
return geoSet.set
.mapNotNull {
if (it.value.geotags.isEmpty()) {
null
} else {
filterLongsByGeohashes(
relay = it.key,
geotags = it.value.geotags,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,68 @@
/*
* 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.longs.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
fun filterLongsByHashtag(
relay: NormalizedRelayUrl,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND),
tags = mapOf("t" to hashtags.toList()),
limit = 200,
since = since,
),
),
)
fun filterLongsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
return hashtagSet.set
.mapNotNull { relayHashSet ->
if (relayHashSet.value.hashtags.isEmpty()) {
null
} else {
filterLongsByHashtag(
relay = relayHashSet.key,
hashtags = relayHashSet.value.hashtags,
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,50 @@
/*
* 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.longs.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.utils.TimeUtils
fun filterLongsGlobal(
relays: GlobalTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
return relays.set.map {
val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo()
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND),
limit = 200,
since = since,
),
)
}
}
@@ -49,7 +49,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.collections.immutable.ImmutableList
@@ -262,8 +262,7 @@ class CardFeedContentState(
val sortedList =
singleList
.get(string)
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.reversed()
?.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
sortedList?.chunked(30)?.map { chunk ->
MultiSetCard(
@@ -293,8 +292,7 @@ class CardFeedContentState(
ZapUserSetCard(
user.key,
zaps
.sortedWith(compareBy({ it.createdAt() }, { it.idHex() }))
.reversed()
.sortedWith(compareByDescending<CombinedZap> { it.createdAt() }.thenBy { it.idHex() })
.toImmutableList(),
)
}
@@ -318,8 +316,7 @@ class CardFeedContentState(
}
return (multiCards + textNoteCards + userZaps)
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
.reversed()
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
}
private fun updateFeed(notes: ImmutableList<Card>) {
@@ -383,8 +380,7 @@ class CardFeedContentState(
val updatedCards =
(oldNotesState.feed.value.list + newCards)
.distinctBy { it.id() }
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
.reversed()
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
.take(localFilter.limit())
.toImmutableList()
@@ -80,6 +80,6 @@ class OpenPollsState(
false
}
}
}.sortedByDescending { it.createdAt() }
}.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
}
}
@@ -56,7 +56,9 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
@@ -83,6 +85,8 @@ class NotificationFeedFilter(
CalendarRSVPEvent.KIND,
ClassifiedsEvent.KIND,
LiveActivitiesEvent.KIND,
LiveChessGameAcceptEvent.KIND,
LiveChessMoveEvent.KIND,
LongTextNoteEvent.KIND,
NipTextEvent.KIND,
VideoVerticalEvent.KIND,
@@ -120,7 +124,7 @@ class NotificationFeedFilter(
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value
fun followList(): TopFilter = account.settings.defaultNotificationFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
@@ -185,9 +189,12 @@ class NotificationFeedFilter(
}
}
// Chess events bypass the follow filter — opponents may not be followed
val isChessEvent = noteEvent is LiveChessGameAcceptEvent || noteEvent is LiveChessMoveEvent
return noteEvent?.kind in NOTIFICATION_KINDS &&
(noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) &&
(filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
(isChessEvent || filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
noteEvent?.isTaggedUser(loggedInUserHex) ?: false &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
(noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) &&
@@ -95,7 +95,7 @@ fun ZapTheDevsCardPreview() {
sig = "e036ecce534e22efd47634c56328af62576ab3a36c565f7c8c5fbea67f48cd46d4041ecfc0ca01dafa0ebe8a0b119d125527a28f88aa30356b80c26dd0953aed",
)
LocalCache.consume(releaseNotes, null, true)
LocalCache.consumeRegularEvent(releaseNotes, null, true)
}
val accountViewModel = mockAccountViewModel()
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
@@ -415,8 +415,8 @@ fun SendDirectMessageTo(
)
ThinPaddingTextField(
value = postViewModel.toUsers,
onValueChange = postViewModel::updateToUsers,
state = postViewModel.toUsers,
onTextChanged = postViewModel::onToUsersChanged,
modifier =
Modifier
.weight(1f)
@@ -432,8 +432,8 @@ fun SendDirectMessageTo(
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
outputTransformation =
UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -147,7 +149,7 @@ class NewPublicMessageViewModel :
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -160,7 +162,7 @@ class NewPublicMessageViewModel :
var emojiSuggestions: EmojiSuggestionState? = null
var toUsers by mutableStateOf(TextFieldValue(""))
val toUsers = TextFieldState()
// Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
@@ -218,10 +220,9 @@ class NewPublicMessageViewModel :
fun load(users: Set<HexKey>) {
val userSet = users - account.userProfile().pubkeyHex
toUsers =
TextFieldValue(
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
toUsers.setTextAndPlaceCursorAtEnd(
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
}
fun reply(post: Note) {
@@ -229,8 +230,8 @@ class NewPublicMessageViewModel :
}
fun quote(quote: Note) {
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
urlPreviews.update(message)
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
urlPreviews.update(message.text.toString())
// creates a split with that author.
val quotedAuthor = quote.author ?: return
@@ -306,13 +307,12 @@ class NewPublicMessageViewModel :
val userSet = draftEvent.groupKeys() - account.userProfile().pubkeyHex
toUsers =
TextFieldValue(
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
toUsers.setTextAndPlaceCursorAtEnd(
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
message = TextFieldValue(draftEvent.content)
urlPreviews.update(message)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
urlPreviews.update(message.text.toString())
iMetaAttachments.addAll(draftEvent.imetas())
}
@@ -349,7 +349,7 @@ class NewPublicMessageViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val broadcast = mutableSetOf<Event>()
@@ -364,13 +364,14 @@ class NewPublicMessageViewModel :
}
private suspend fun createTemplate(): EventTemplate<PublicMessageEvent> {
val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text, null, null, accountViewModel)
val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text.toString(), null, null, accountViewModel)
toUsersTagger.run()
val messageText = message.text.toString()
val msg =
replyingTo?.let {
it.toNostrUri() + "\n" + message.text
} ?: message.text
it.toNostrUri() + "\n" + messageText
} ?: messageText
val tagger = NewMessageTagger(msg, null, null, accountViewModel)
tagger.run()
@@ -470,8 +471,8 @@ class NewPublicMessageViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message)
message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message.text.toString())
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
@@ -494,8 +495,8 @@ class NewPublicMessageViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
message = message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message)
message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message.text.toString())
}
}
@@ -512,8 +513,8 @@ class NewPublicMessageViewModel :
fun cancel() {
draftTag.rotate()
toUsers = TextFieldValue("")
message = TextFieldValue("")
toUsers.setTextAndPlaceCursorAtEnd("")
message.setTextAndPlaceCursorAtEnd("")
multiOrchestrator = null
wantsInvoice = false
@@ -546,15 +547,15 @@ class NewPublicMessageViewModel :
}
fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it))
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
urlPreviews.update(newMessage)
override fun onMessageChanged() {
urlPreviews.update(message.text.toString())
if (message.selection.collapsed) {
val lastWord = newMessage.currentWord()
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord)
@@ -569,11 +570,9 @@ class NewPublicMessageViewModel :
draftTag.newVersion()
}
fun updateToUsers(newToUsersValue: TextFieldValue) {
toUsers = newToUsersValue
if (newToUsersValue.selection.collapsed) {
val lastWord = newToUsersValue.currentWord()
fun onToUsersChanged() {
if (toUsers.selection.collapsed) {
val lastWord = toUsers.currentWord()
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
userSuggestions?.processCurrentWord(lastWord)
}
@@ -594,14 +593,14 @@ class NewPublicMessageViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message)
userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = toUsers.currentWord()
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
}
userSuggestionsMainMessage = null
@@ -614,8 +613,8 @@ class NewPublicMessageViewModel :
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -631,8 +630,8 @@ class NewPublicMessageViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -648,7 +647,7 @@ class NewPublicMessageViewModel :
multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -671,7 +670,7 @@ class NewPublicMessageViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -0,0 +1,172 @@
/*
* 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.pictures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.observeEdits
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import kotlinx.collections.immutable.toImmutableList
@Composable
fun PictureCardCompose(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = (baseNote.event as? PictureEvent) ?: return
val backgroundColor = remember { mutableStateOf(Color.Transparent) }
val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel)
Column(
modifier = Modifier.fillMaxWidth(),
) {
// Author header row
UserCardHeader(baseNote, accountViewModel, nav)
// Image content
PictureCardImage(baseNote, event, backgroundColor, accountViewModel)
// Reactions row
ReactionsRow(
baseNote = baseNote,
showReactionDetail = true,
addPadding = true,
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
)
// Title and content
PictureCardCaption(event)
}
}
@Composable
private fun PictureCardImage(
note: Note,
event: PictureEvent,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
) {
val uri = note.toNostrUri()
val images by
remember(note) {
mutableStateOf(
event
.imetaTags()
.map {
MediaUrlImage(
url = it.url,
description = it.alt,
hash = it.hash,
blurhash = it.blurhash,
dim = it.dimension,
uri = uri,
mimeType = it.mimeType,
)
}.toImmutableList(),
)
}
if (images.isNotEmpty()) {
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
if (images.size == 1) {
ZoomableContentView(
content = images.first(),
images = images,
roundedCorner = false,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
} else {
AutoNonlazyGrid(images.size) {
ZoomableContentView(
content = images[it],
images = images,
roundedCorner = false,
contentScale = ContentScale.Crop,
accountViewModel = accountViewModel,
)
}
}
}
}
}
@Composable
internal fun PictureCardCaption(event: PictureEvent) {
val title = event.title()
val content = event.content
if (title != null || content.isNotBlank()) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
}
if (content.isNotBlank()) {
Text(
text = content,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,218 @@
/*
* 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.pictures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
private const val SAMPLE_PICTURE_EVENT_JSON =
"{\"id\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"," +
"\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\"," +
"\"created_at\":1708695717," +
"\"kind\":20," +
"\"tags\":[[\"title\",\"Sunset at the Beach\"]," +
"[\"imeta\",\"url https://image.nostr.build/sample-sunset.jpg\"," +
"\"m image/jpeg\",\"dim 1200x800\",\"alt A beautiful sunset over the ocean\"," +
"\"blurhash LKO2:N%2Tw=w]~RBVZRi};RPxuwH\"]]," +
"\"content\":\"Caught this amazing sunset while walking along the shore. The colors were absolutely breathtaking, painting the sky in shades of orange and purple.\"," +
"\"sig\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"}"
private const val SAMPLE_PICTURE_EVENT_NO_TITLE_JSON =
"{\"id\":\"b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3\"," +
"\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\"," +
"\"created_at\":1708695000," +
"\"kind\":20," +
"\"tags\":[[\"imeta\",\"url https://image.nostr.build/sample-mountain.jpg\"," +
"\"m image/jpeg\",\"dim 1080x1080\",\"alt Mountain landscape\"]]," +
"\"content\":\"Mountain vibes today. Fresh air and clear skies.\"," +
"\"sig\":\"b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3\"}"
private const val SAMPLE_MULTI_IMAGE_EVENT_JSON =
"{\"id\":\"c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\"," +
"\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\"," +
"\"created_at\":1708694000," +
"\"kind\":20," +
"\"tags\":[[\"title\",\"Travel Photos\"]," +
"[\"imeta\",\"url https://image.nostr.build/sample-travel1.jpg\"," +
"\"m image/jpeg\",\"dim 800x600\",\"alt City street\"]," +
"[\"imeta\",\"url https://image.nostr.build/sample-travel2.jpg\"," +
"\"m image/jpeg\",\"dim 800x600\",\"alt Market square\"]]," +
"\"content\":\"Exploring the old town district. Every corner has a story to tell. The architecture here is incredible and the food is even better.\"," +
"\"sig\":\"c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\"}"
@Preview
@Composable
private fun PictureCardComposePreview() {
val event = Event.fromJson(SAMPLE_PICTURE_EVENT_JSON) as PictureEvent
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav()
runBlocking {
withContext(Dispatchers.IO) {
LocalCache.justConsume(event, null, false)
}
}
LoadNote(
baseNoteHex = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
accountViewModel = accountViewModel,
) { baseNote ->
ThemeComparisonColumn {
if (baseNote != null) {
PictureCardCompose(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Preview
@Composable
private fun PictureCardComposeNoTitlePreview() {
val event = Event.fromJson(SAMPLE_PICTURE_EVENT_NO_TITLE_JSON) as PictureEvent
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav()
runBlocking {
withContext(Dispatchers.IO) {
LocalCache.justConsume(event, null, false)
}
}
LoadNote(
baseNoteHex = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3",
accountViewModel = accountViewModel,
) { baseNote ->
ThemeComparisonColumn {
if (baseNote != null) {
PictureCardCompose(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Preview
@Composable
private fun PictureCardComposeMultiImagePreview() {
val event = Event.fromJson(SAMPLE_MULTI_IMAGE_EVENT_JSON) as PictureEvent
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav()
runBlocking {
withContext(Dispatchers.IO) {
LocalCache.justConsume(event, null, false)
}
}
LoadNote(
baseNoteHex = "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
accountViewModel = accountViewModel,
) { baseNote ->
ThemeComparisonColumn {
if (baseNote != null) {
PictureCardCompose(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Preview
@Composable
private fun PictureCardCaptionWithTitlePreview() {
val event = Event.fromJson(SAMPLE_PICTURE_EVENT_JSON) as PictureEvent
ThemeComparisonColumn {
PictureCardCaption(event)
}
}
@Preview
@Composable
private fun PictureCardCaptionNoTitlePreview() {
val event = Event.fromJson(SAMPLE_PICTURE_EVENT_NO_TITLE_JSON) as PictureEvent
ThemeComparisonColumn {
PictureCardCaption(event)
}
}
@Preview
@Composable
private fun PictureCardCaptionLongContentPreview() {
ThemeComparisonColumn {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) {
Text(
text = "A Very Long Title That Should Be Truncated When It Exceeds The Available Width",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text =
"This is a long description that spans multiple lines to test the three-line " +
"truncation behavior. The text should be cut off after three lines with an " +
"ellipsis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do " +
"eiusmod tempor incididunt ut labore et dolore magna aliqua.",
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@@ -0,0 +1,74 @@
/*
* 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.pictures
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
@Composable
fun PictureFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
if (item.event is PictureEvent) {
PictureCardCompose(
baseNote = item,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(
thickness = DividerThickness,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@@ -0,0 +1,118 @@
/*
* 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.pictures
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.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssemblerSubscription
@Composable
fun PicturesScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
PicturesScreen(
picturesFeedContentState = accountViewModel.feedStates.picturesFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun PicturesScreen(
picturesFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(picturesFeedContentState)
WatchAccountForPicturesScreen(picturesFeedContentState = picturesFeedContentState, accountViewModel = accountViewModel)
PicturesFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
PicturesTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Pictures, accountViewModel) { route ->
if (route == Route.Pictures) {
picturesFeedContentState.sendToTop()
} else {
nav.newStack(route)
}
}
},
accountViewModel = accountViewModel,
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
RefresheableBox(picturesFeedContentState, true) {
SaveableFeedContentState(picturesFeedContentState, scrollStateKey = ScrollStateKeys.PICTURES_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = picturesFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "PicturesFeed",
onLoaded = { loaded ->
PictureFeedLoaded(
loaded = loaded,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
}
}
}
}
}
@Composable
fun WatchAccountForPicturesScreen(
picturesFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.livePicturesFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
picturesFeedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* 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.pictures
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.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun PicturesTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultPicturesFollowList
.collectAsStateWithLifecycle()
PicturesTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultPicturesFollowList,
)
}
}
@Composable
private fun PicturesTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
accountViewModel = accountViewModel,
)
}

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