Commit Graph

11410 Commits

Author SHA1 Message Date
dependabot[bot] f098fb4cdc chore(actions): bump the actions group with 4 updates
Bumps the actions group with 4 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/github-script](https://github.com/actions/github-script), [nick-fields/retry](https://github.com/nick-fields/retry) and [softprops/action-gh-release](https://github.com/softprops/action-gh-release).


Updates `actions/upload-artifact` from 6 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

Updates `nick-fields/retry` from 3.0.2 to 4.0.0
- [Release notes](https://github.com/nick-fields/retry/releases)
- [Commits](https://github.com/nick-fields/retry/compare/ce71cc2ab81d554ebbe88c79ab5975992d79ba08...ad984534de44a9489a53aefd81eb77f87c70dc60)

Updates `softprops/action-gh-release` from 2.6.2 to 3.0.0
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/3bb12739c298aeb8a4eeaf626c5b8d85266b0e65...b4309332981a82ec1c5618f44dd2e27cc8bfbfda)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: nick-fields/retry
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-17 12:20:54 +00:00
Vitor Pamplona f05fda1485 Merge pull request #2428 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-17 08:19:51 -04:00
Vitor Pamplona f5f8bfd7dc Merge pull request #2430 from nrobi144/feat/desktop-multiplatform-distribution
feat(desktop): multi-platform distribution — 8 assets, Homebrew + Winget
2026-04-17 08:19:40 -04:00
nrobi144 54dffddf2f chore: remove plan doc from PR (local artifact) 2026-04-17 11:32:31 +03:00
nrobi144 84c4b461a9 fix(release): address code review findings (P1-P3)
P1 fixes (must-fix):
- AppRun: VLC path corrected to usr/lib/app/linux/vlc (jpackage actual
  path), add VLC_PLUGIN_PATH env var for codec discovery
- linuxdeploy: use APPIMAGE_EXTRACT_AND_RUN=1 env var to bypass FUSE
  on CI runners instead of fragile pre-extract + symlink approach
- Prerelease classifier: inverted to positive-match stable format
  (^v[0-9]+\.[0-9]+\.[0-9]+$) across desktop, Android, and composite
  action — eliminates regex drift between allowlists
- assert-stable-release: now accepts inputs (tag, is_prerelease,
  is_draft) so workflow_dispatch on bump workflows also validates

P2 fixes (should-fix):
- .gitignore: add linuxdeploy-*.AppImage and extracted dirs
- RPM version: use replace("-", "~") instead of substringBefore("-")
  for correct RPM prerelease ordering (1.08.0~rc1 < 1.08.0)
- linux-portable → linux alias moved into collect_assets() in
  scripts/asset-name.sh (single source of truth)
- Android cache key: add gradle/libs.versions.toml to hashFiles
- r0adkll/sign-android-release: SHA-pinned to 349ebdef (v1)

P3 fixes (nice-to-have):
- LINUXDEPLOY_OUTPUT_VERSION env var replaced with
  APPIMAGE_EXTRACT_AND_RUN (misleading comment + redundant var)
- nick-fields/retry timeout: 40m → 15m (surface real hangs)
- generate_release_notes: true only on Android job (avoid
  last-writer-wins race across 6 concurrent uploaders)
- apt-get install rpm: tightened guard to matrix.family == 'linux'
  (linux-portable leg doesn't need rpm tooling)
2026-04-17 11:03:01 +03:00
Crowdin Bot 2be1036cd0 New Crowdin translations by GitHub Action 2026-04-17 01:16:22 +00:00
Vitor Pamplona a525ebabd5 Merge pull request #2429 from vitorpamplona/claude/redesign-longform-header-ajshi
Enhance long-form article preview with metadata and topics
2026-04-16 21:14:39 -04:00
Claude 71537a8950 feat: redesign LongFormHeader with richer article card layout
- 16:9 cover image with rounded top corners
- Topic/hashtag chips row above title (up to 3)
- Bolder titleLarge headline, max 2 lines
- Gray summary with max 3 lines
- Author row with display name, time ago, and reading-time badge
2026-04-16 22:05:03 +00:00
Crowdin Bot 6ff1a7ef8f New Crowdin translations by GitHub Action 2026-04-16 21:19:11 +00:00
Vitor Pamplona f0730ce4f7 Merge pull request #2427 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-16 17:17:52 -04:00
Crowdin Bot 55ef3e89f6 New Crowdin translations by GitHub Action 2026-04-16 21:17:27 +00:00
Vitor Pamplona 449c052cb9 Spotless Apply 2026-04-16 17:16:56 -04:00
Vitor Pamplona cb98961f87 Merge pull request #2425 from vitorpamplona/claude/fix-call-ringing-lifecycle-ukm5g
Refactor call session lifecycle to Activity-owned CallSession
2026-04-16 17:15:56 -04:00
Claude bc424acfcf fix: pass Activity context (not applicationContext) to CallSession
WebRTC's Camera2Session accesses WindowManager for device orientation.
WindowManager is a visual service that requires an Activity context —
applicationContext throws IllegalAccessException on Android 12+.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
2026-04-16 20:02:30 +00:00
Claude 7e93f82577 fix(audio): switch ringtone from Ringtone to MediaPlayer + idempotent start
Root cause of "ringing survives reject on old Android":

1. startRinging() was NOT idempotent. When CallManager re-emitted
   IncomingCall state (e.g., when another group member rejected while
   still ringing), the state collector called startRinging() again.
   startRingtone() OVERWROTE the ringtone field reference without
   calling stop() on the old one. Old Ringtone instances kept
   playing with no reachable reference — stopRingtone() on reject
   only stopped the latest one.

2. Ringtone.isLooping was only set on API 28+. On older Android the
   Ringtone class had documented reliability problems with stop().
   MediaPlayer has reliable stop/release on all API levels and
   supports looping universally.

Fixes:

- Switch from android.media.Ringtone to android.media.MediaPlayer.
- Make startRinging() idempotent — always call stopRinging() first
  so any existing player/vibrator is torn down before a new one
  starts.
- Add aggressive tracing logs throughout the reject path:
  * CallAudioManager: instance id + thread on every start/stop,
    MediaPlayer error listener, before-and-after player hashes.
  * CallSession: log every state collector tick, log close() entry.
  * CallManager: log rejectCall() entry/exit + transitionToEnded.
  * CallNotificationReceiver: log every action with state transitions.

With these logs, if ringing still survives reject we can trace
exactly which primitive is failing.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
2026-04-16 19:51:25 +00:00
davotoula e4d17afcc5 fix: use _sessionEvents.tryEmit instead of recursive self-call in emitSessionEvent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 18:56:46 +00:00
davotoula 3e0d79a091 spotless: fix formatting violations in call lifecycle files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 18:56:46 +00:00
Claude 29c5c26af7 fix: restore onDestroy hangup, prevent event drops, fix deadlock risk
Three fixes from audit:

1. PiP swipe-away regression (Bug 5): Restored hangup signaling in
   CallActivity.onDestroy via detached CoroutineScope. This is the
   PRIMARY signaling path for PiP dismiss, back press, and finish().
   CallForegroundService.onTaskRemoved is the BACKUP for task swipe
   from Recents. Both paths are idempotent — double-publishing is safe.

2. SharedFlow event drops (Bug 1): Increased sessionEvents buffer from
   16 to 256 to handle worst-case ICE candidate bursts. Added
   emitSessionEvent() helper that logs drops as errors. Increased
   renegotiationEvents buffer from 8 to 32. tryEmit is kept (not
   emit) to avoid suspending while holding stateMutex, which would
   block all signaling.

3. runBlocking deadlock risk (Bug 2): CallSessionBridge.clear() now
   uses CallManager.reset() (non-blocking, no mutex) instead of
   runBlocking { hangup() }. The bridge teardown is for local state
   cleanup during account switch — hangup signaling is the Activity's
   and Service's responsibility.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
2026-04-16 18:56:46 +00:00
Claude 89cf5e14d4 fix: move hangup signaling from fire-and-forget scope to CallForegroundService
The previous approach used a detached CoroutineScope in
CallActivity.onDestroy to publish hangup/reject events. This was
unreliable: the process could be killed before the coroutine completed,
leaving the remote peer's phone ringing.

Fix: CallForegroundService now owns hangup signaling via two paths:

1. onTaskRemoved() — fires when user swipes app from Recents. Uses
   runBlocking with 3-second timeout to synchronously publish hangup
   before the service is killed.

2. onDestroy() — fires when the service is stopped for any other
   reason. Same runBlocking pattern as a safety net.

Changes:
- AndroidManifest: stopWithTask="false" so onTaskRemoved fires instead
  of the service being silently killed.
- CallForegroundService: added onTaskRemoved(), onDestroy(), and
  publishHangupBlocking() helper.
- CallActivity.onDestroy: removed fire-and-forget CoroutineScope.
  Session resource cleanup (close()) still happens here; signaling
  hangup is delegated to the foreground service.
- CallSessionBridge.clear(): converted fire-and-forget hangup to
  runBlocking with 3-second timeout for account-switch safety.

The three-layer hangup guarantee is now:
1. CallForegroundService.onTaskRemoved/onDestroy — synchronous,
   reliable (runBlocking).
2. CallManager ringing watchdog (65s) — if nothing else fires.
3. Remote peer's own timeout — last resort.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
2026-04-16 18:56:46 +00:00
Claude ac6d8eb417 refactor: replace CallManager mutable callbacks with SharedFlow
Convert all 5 mutable `var` callback fields on CallManager
(onAnswerReceived, onIceCandidateReceived, onNewPeerInGroupCall,
onMidCallOfferReceived, onPeerLeft) into a single
`sessionEvents: SharedFlow<CallSessionEvent>`.

Benefits:
- Eliminates the stale-callback window between Activity destroy and
  recreate. No more nulling callbacks in onDestroy — the `closed`
  flag on CallSession is sufficient.
- No mutable state shared between Activity and background code.
- CallActivity no longer wires or tears down callbacks — CallSession
  subscribes to the flow in its init block.
- Type-safe sealed interface (CallSessionEvent) replaces 5 separate
  lambda types.

The renegotiationEvents SharedFlow remains separate because
renegotiation has its own glare-resolution logic in CallSession.

Updated all 11 test sites in CallManagerTest to collect from
sessionEvents instead of setting callback vars.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
2026-04-16 18:56:45 +00:00
Claude 980515c0cc fix: add closed guards to WebRTC callbacks, connecting watchdog, account-switch safety
Three additional hardening fixes:

1. WebRTC callback guards: Every callback passed to WebRtcCallSession
   (onIceCandidate, onPeerConnected, onRemoteVideoTrack, onDisconnected,
   onError, onRenegotiationNeeded, onIceRestartOffer) now checks `closed`
   before touching any session resource. Prevents native crashes from
   libwebrtc callbacks firing after close() has disposed PeerConnections.

2. Connecting-state watchdog: New 30-second timer armed when entering
   Connecting state, disarmed on Connected/Ended/reset. If ICE
   negotiation hangs (broken TURN, restrictive NAT), the call ends
   with TIMEOUT instead of leaving the user on a "Connecting..." screen
   forever.

3. Account-switch safety: CallSessionBridge.clear() now calls
   callManager.hangup() before nulling references. Prevents a stale
   CallSession from invoking signing/publishing lambdas on a disposed
   Account after logout or account switch.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
2026-04-16 18:56:45 +00:00
Claude 8075579168 fix: address race conditions in CallSession close/callback lifecycle
Fixes found by audit:

1. Set `closed = true` at the top of close() so the init collectors
   (state, renegotiation, proximity) short-circuit when they see
   the flag. Prevents them from calling methods on disposed resources
   during the window between close() and lifecycleScope cancellation.

2. Null out all CallManager callbacks in onDestroy after close() so
   signaling events arriving between Activity destroy and recreate
   don't route to a dead CallSession.

3. Synchronize peerSessionMgr.disposeAll() + reassignment in close()
   to prevent concurrent collector reads on a half-disposed manager.

4. Reorder onDestroy: close() runs first (synchronous resource
   release), then callback nulling, then best-effort hangup signaling.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
2026-04-16 18:56:45 +00:00
Claude 3cddda6ef9 fix: CallActivity owns the entire call session lifecycle
Root cause: WebRTC call ringing continued forever after cancellation
because ringing, WebRTC, and notifications were owned by CallController
which lived in viewModelScope (account lifetime), not by CallActivity.
Cleanup depended on a state collector observing Ended in time, which
raced, dropped, and went stale.

Architecture after fix:
- CallManager (background, AccountViewModel scope): state machine only.
  No audio, no WebRTC, no notifications. Exposes state as StateFlow,
  renegotiation events as SharedFlow, and a 65-second ringing watchdog
  as a fail-safe.
- CallSession (Activity-scoped, replaces CallController): owns all call
  resources. Created in onCreate, destroyed via close() in onDestroy.
  Implements AutoCloseable. No cleanedUp guard flag needed.
- CallSessionBridge: slimmed down — holds only callManager +
  accountViewModel for cross-Activity reference sharing. No
  callController field.

Key changes:
- CallController renamed to CallSession, moved to ui/call/session/.
- CallSession.close() replaces cleanup() — single-shot, no guard flags.
- CallActivity creates and owns CallSession directly.
- CallManager.onRenegotiationOfferReceived callback replaced with
  renegotiationEvents SharedFlow (buffered, survives Activity restarts).
- CallManager gains ringing watchdog (65s) that forces Ended(TIMEOUT)
  if state stays in IncomingCall/Offering past the deadline.
- CallNotifier extracted from NotificationUtils into dedicated class.
- AccountViewModel.initCallController deleted; callManager wired to
  newNotesPreProcessor eagerly in init block.
- ChatroomScreen uses CallActivity.launchForOutgoingCall() with intent
  extras instead of directly calling callController.initiateGroupCall().
- AndroidManifest adds navigation|fontScale|density to configChanges
  so no config change recreates CallActivity mid-call.

Invariants:
1. CallActivity.onDestroy → session.close() → all resources released.
2. Ringing cannot outlive any code path: Activity close, state
   collector, and 65s watchdog provide three independent stop paths.
3. Config changes cannot kill the call (manifest prevents recreation).
4. No global mutable singleton for CallController.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
2026-04-16 18:56:45 +00:00
Vitor Pamplona 8466a0c0d5 fixes compilation errors 2026-04-16 09:15:23 -04:00
Vitor Pamplona fb9171fb9a spotless 2026-04-16 08:51:15 -04:00
Vitor Pamplona 42cd1cdf0c Merge pull request #2423 from vitorpamplona/claude/add-discovery-articles-screen-IRDoN
Add Articles feed screen with NIP-23 long-form content support
2026-04-16 08:50:35 -04:00
Vitor Pamplona fbbe93aa3b Merge pull request #2422 from vitorpamplona/claude/add-discovery-products-screen-1hsvy
Add Products feed screen with classified listings support
2026-04-16 08:50:16 -04:00
Vitor Pamplona 6e7849eae7 Merge pull request #2424 from vitorpamplona/claude/add-marmot-media-upload-d0nNK
Implement MIP-04 encrypted media support for Marmot groups
2026-04-16 08:50:06 -04:00
Vitor Pamplona 6eff64f589 Merge pull request #2421 from vitorpamplona/claude/replace-mls-groups-fab-waio7
Add Marmot group messages to notifications feed
2026-04-16 08:43:51 -04:00
davotoula f41c062be4 lightcompressor-enhanced patch upgrade, fix for gif2mp4 2026-04-16 14:33:47 +02:00
nrobi144 c428661601 feat(release): expand desktop distribution to 8 assets + 2 package managers
Phases 3, 4, 5, 6 of the multi-platform distribution plan.

## create-release.yml rewrite
- Replace deprecated actions/create-release@v1 + upload-release-asset@v1 with
  softprops/action-gh-release@v2 (SHA-pinned)
- Expand build-desktop matrix: macos-13 (Intel), macos-14 (ARM),
  windows-latest, ubuntu-latest × 2 legs (deb+rpm, AppImage+tar.gz)
- Each matrix job uploads directly to release (no artifact round-trip — saves
  ~10 min + 1.5GB transfer per run)
- Inline portable archives: Windows .zip via 7z, Linux .tar.gz via tar
- linuxdeploy SHA-verified fetch for AppImage builds (not `continuous` tag)
- Per-asset size budget: hard fail at 1 GB per asset
- prerelease inferred from tag regex (-rc|-beta|-alpha|-dev|-snapshot)
- workflow_dispatch dry_run input: builds all assets without publishing,
  skips Android + bump workflows
- Tag-vs-libs.versions.toml assertion as first step in each matrix job
- Android + Quartz jobs preserved; migrated to softprops/action-gh-release@v2

## Package manager bump workflows (Homebrew + Winget)
- .github/workflows/bump-homebrew.yml — macauley/action-homebrew-bump-cask
  on ubuntu-latest (saves macOS runner quota). Cask name: `amethyst-nostr`.
- .github/workflows/bump-winget.yml — vedantmgoyal9/winget-releaser on
  windows-latest. PackageIdentifier: `VitorPamplona.Amethyst`.
- Shared composite action .github/actions/assert-stable-release rejects
  draft/prerelease/malformed-tag releases at action boundary (defense in
  depth vs workflow-level `if:` alone).
- Both workflows auto-open `release-ops`-labeled GH Issues on failure.
- Concurrency groups per tag prevent re-fire races.
- AUR + Scoop deferred to follow-up PR (unresolved ownership questions).

## Documentation
- BUILDING.md: per-platform build commands, asset naming contract, release
  runbook, bootstrap runbook, troubleshooting, uninstall paths, incident
  response, fallback plans (macos-13 retirement, Homebrew Sept 2026 deadline)
- README: expanded Download section with per-OS install matrix for 7 formats
  + 2 package managers. Deploying section points at BUILDING.md.

## Supply chain
- .github/dependabot.yml: monthly bumps for github-actions ecosystem
- All new third-party actions SHA-pinned:
  - softprops/action-gh-release v2.6.2
  - macauley/action-homebrew-bump-cask v4.0.0
  - vedantmgoyal9/winget-releaser v2
  - nick-fields/retry v3.0.2
- linuxdeploy binary SHA256-verified against pinned release tag
2026-04-16 14:54:51 +03:00
nrobi144 da1037423c feat(desktop): version source-of-truth + RPM + AppImage packaging
Phase 1+2 of multi-platform distribution plan:
- gradle/libs.versions.toml: add `app = "1.08.0"` as single source of truth
- Root build.gradle: allprojects { version = libs.versions.app.get() }
- amethyst/build.gradle: versionName from catalog (versionCode stays local)
- desktopApp/build.gradle.kts: drop hardcoded "1.0.0"; inherit project.version;
  add TargetFormat.Rpm; add linux DSL (menuGroup, appCategory, debMaintainer,
  rpmLicenseType, rpmPackageVersion with dashes stripped)
- desktopApp/build.gradle.kts: new createReleaseAppImage task wrapping
  createReleaseDistributable with linuxdeploy (TargetFormat.AppImage is
  broken in Compose 1.10.x — CMP-7101)
- packaging/appimage/: AppRun launcher (sets LD_LIBRARY_PATH for bundled VLC),
  amethyst.desktop XDG entry, 512x512 icon extracted from icon.icns
- scripts/asset-name.sh: single source for release asset naming contract
2026-04-16 14:35:01 +03:00
Claude 403ce57b79 feat: Add standalone Products screen with own filters and Around Me default
Extracts the Marketplace/Products functionality from the Discovery tab
into a dedicated standalone screen following the Pictures/Polls pattern.
The screen displays NIP-99 classifieds in a 2-column grid with its own
filter system, defaulting to Around Me (geolocation-based) filtering.

https://claude.ai/code/session_01PyrUZzSj7A3ZXucs7DTYbB
2026-04-16 03:28:16 +00:00
Claude 3d5366e608 feat: add standalone Articles screen for long-form content
Add a new dedicated Articles screen accessible from the left drawer that
displays long-form content (NIP-23, kind 30023) with its own filters,
data sources, and FAB. The screen follows the same pattern as Pictures,
Polls, and Longs screens. Default filter is set to "All Follows".

- New route: Route.Articles with navigation and drawer entry
- ArticlesFeedFilter using LongTextNoteEvent with own follow list
- ArticlesFilterAssembler/SubAssembler reusing makeLongFormFilter
- ArticlesScreen with DisappearingScaffold, top bar filter, and FAB
- ArticlesFeedLoaded rendering via ChannelCardCompose/LongFormHeader
- Account settings for defaultArticlesFollowList with persistence

https://claude.ai/code/session_01QnP527Zq3xrtcLDmdx3LNf
2026-04-16 03:24:01 +00:00
Claude 5f5d576f1e feat: implement MIP-04 encrypted media upload for Marmot groups
Adds full MIP-04 (Encrypted Media) support for Marmot group chats:

Protocol layer (quartz):
- Mip04MediaEncryption: ChaCha20-Poly1305 AEAD with MLS exporter key
  derivation (HKDF-Expand), random nonces, and AAD binding
- Mip04Cipher/Mip04NostrCipher: NostrCipher adapters for the existing
  EncryptedBlobInterceptor download-and-decrypt pipeline
- Mip04IMetaTag: NIP-92 imeta tag builder/parser with MIP-04 fields
  (url, m, filename, x, n, v=mip04-v2)
- MlsGroupManager.mediaExporterSecret(): MLS-Exporter("marmot",
  "encrypted-media", 32)

Upload flow (amethyst):
- MarmotFileUploader: per-file Mip04NostrCipher → existing
  UploadOrchestrator.uploadEncrypted pipeline (compression, stripping,
  Blossom upload)
- MarmotFileSender: builds imeta tags and sends kind:9 inner events
  through the MLS group message pipeline
- MarmotGroupMessageComposer: adds SelectFromGallery leading icon and
  ChatFileUploadDialog (mirrors NIP-17 DM pattern)

Display flow:
- RenderMarmotEncryptedMedia: parses imeta v=mip04-v2 tags, derives
  Mip04Cipher, registers in EncryptionKeyCache, renders via
  ZoomableContentView (same path as NIP-17 encrypted files)
- MarmotGroupList.noteToGroupIndex: reverse index for mapping notes
  back to their group ID (needed for exporter secret lookup)
- ChatMessageCompose NoteRow: routes MIP-04 events to the new renderer

https://claude.ai/code/session_01TckzZLpJdmE6p198DHBQ5i
2026-04-16 02:08:45 +00:00
Claude e168b9dde2 feat: replace MLS Groups FAB with Create Group and show marmot messages in notifications
Replace the "MLS Groups" FAB button on MessagesScreen with a "Create
Group" action that navigates directly to the group creation screen
(Route.CreateMarmotGroup) instead of the group list page. This aligns
with the plan to merge all marmot groups into the Messages screen's
Known and New Requests tabs.

Add marmot group messages to the notification screen so they appear
alongside regular DMs. This includes:
- Including marmot group messages in NotificationFeedFilter.feed()
- Accepting marmot group notes in acceptableEvent() via inGatherers check
- Converting marmot group notes to MessageSetCard in CardFeedContentState
- Routing marmot group notification clicks to the group chat screen

https://claude.ai/code/session_012wbykgUYHNVdHVNbDDnMUt
2026-04-16 01:55:44 +00:00
Vitor Pamplona 3356059d50 Removes warnings 2026-04-15 21:32:00 -04:00
Vitor Pamplona e4e7d4816a Fixes desktop compilation 2026-04-15 21:31:40 -04:00
Vitor Pamplona 51992dcf73 Merge pull request #2419 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-15 21:27:55 -04:00
Crowdin Bot af030aa1e2 New Crowdin translations by GitHub Action 2026-04-16 00:43:30 +00:00
Vitor Pamplona 85d9b60843 Merge pull request #2418 from vitorpamplona/claude/fix-group-metadata-persistence-kyTVP
Add persistent storage for Marmot KeyPackages and group messages
2026-04-15 20:42:01 -04:00
Vitor Pamplona 22dc89db70 Merge pull request #2417 from vitorpamplona/claude/review-nip34-compliance-WGPXe
Add NIP-34 Git pull requests, status events, and repository state
2026-04-15 20:41:37 -04:00
Claude 6a0f3982e8 feat(quartz): expand NIP-34 git collaboration coverage
Fills out Quartz's NIP-34 implementation so clients can build and parse
every spec-defined event kind, not just repository announcements and issues.

Kind 30617 (Repository Announcement):
- Multi-value web/clone accessors and builders
- relays, maintainers, hashtags, earliestUniqueCommit, isPersonalFork
- New tag classes: RelaysTag, MaintainersTag, EucTag
- New build() overload covering every optional tag

Kind 30618 (Repository State): new GitRepositoryStateEvent with
refs/heads, refs/tags, HEAD, and a RefTag/HeadTag pair.

Kind 1617 (Patch): rewrite builder to use the eventTemplate DSL with
required a/r/p tags, optional commit/parent-commit/commit-pgp-sig/
committer/root/root-revision markers, and a reply() helper that adds
NIP-10 marked e-tags pointing at a prior patch.

Kind 1618/1619 (Pull Request + Update): new GitPullRequestEvent and
GitPullRequestUpdateEvent with c/clone/branch-name/merge-base tags and
NIP-22 E/P parent references for updates.

Kinds 1630/1631/1632/1633 (Status Open/Applied/Closed/Draft): new
GitStatusEvent base + four subclasses sharing a GitStatusBuilders DSL.
Applied status carries merge-commit, applied-as-commits, and q tags for
the specific patch events that were merged.

Kind 10317 (User Grasp List): new UserGraspListEvent replaceable list of
g tags (websocket URLs in preference order) for discovering grasp
hosting servers.

NIP-51 Kind 10017 (Git Authors List): now uses a dedicated GitAuthorTag
class with optional petname (NIP-02 follow-list 4th element) instead of
the mute-list UserTag, so round-tripping follow lists no longer drops
petnames.

All new event kinds are registered in EventFactory. Spotless-clean and
compiles via :quartz:compileKotlinJvm.

https://claude.ai/code/session_018Fz3AeEdGkeFoeHBKuMwbH
2026-04-16 00:14:08 +00:00
Claude 2265b0635d fix(marmot): discard legacy KeyPackage snapshots on upgrade
The diagnostic trace from the invitee shows the welcome flow working
end-to-end (gift wrap → seal → processMarmotWelcomeFlow) but finally
failing at

    MarmotInboundProcessor.processWelcome: NO matching KeyPackageBundle
    for eventId=41177359… — inviter referenced a KeyPackage we don't
    have private keys for

The eventId the welcome carries IS on relays — the inviter just
fetched it successfully in `fetchKeyPackageAndAddMember` — but on the
invitee's device the `eventIdToSlot` map is empty, so the lookup
misses. Root cause: the invitee's persisted KeyPackage snapshot is
in v1 format (from before the eventId→slot index existed).
`restoreFromStore` loaded v1 just fine, leaving bundles in place but
the eventId map empty. Then `Account.ensureMarmotKeyPackagePublished`
saw `hasActiveKeyPackages()` return true and *skipped republishing*
— so the stale kind:30443 that the inviter just fetched has no
matching mapping on the invitee.

Fix: `restoreFromStore` now refuses to load any snapshot whose
version is not the current `SNAPSHOT_VERSION` (2). On a v1 file it:

  1. Logs a warning.
  2. Deletes the on-disk snapshot via `store.delete()` so the next
     save writes fresh v2.
  3. Returns without touching `activeBundles` / `pendingRotations` /
     `eventIdToSlot`.

This means `hasActiveKeyPackages()` will return false right after
restoreAll finishes, `ensureMarmotKeyPackagePublished` will generate
+ publish a fresh bundle (which `recordPublishedEventId` indexes by
its real Nostr event id), the new kind:30443 replaces the old one on
relays via d-tag addressability, and the next inviter's
`fetchKeyPackageAndAddMember` will find the new event + the invitee
will have the matching private keys to process its Welcome.

The same defensive wipe also fires for a v2 snapshot that somehow
carries bundles with an empty eventId map (corner case, crash during
upgrade, etc.).

Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
2026-04-15 23:47:40 +00:00
Claude 6c723147b0 fix(marmot): route seal-wrapped welcomes to the welcome handler
User reported the only logs Charlie's device produced when Bob added
him to a group were:

    GiftWrapEventHandler.processNewGiftWrap: id=40e2073c… recipient=9a960f0b…
    GiftWrapEventHandler.processNewGiftWrap: unwrapped innerKind=13 innerId=ffda2949…

That kind=13 is the smoking gun. The previous code in
`processNewGiftWrap` checked `isWelcomeEvent(innerGift)` directly on
the gift-wrap-unwrapped result, expecting kind:444. But Marmot
`WelcomeGiftWrap.wrapForRecipient` actually produces a three-layer
envelope:

    GiftWrap (1059) → SealedRumor (13) → WelcomeEvent (444)

So `event.unwrapOrNull(signer)` returns the kind:13 Seal, not the
WelcomeEvent. The previous check fell through to `eventProcessor.
consumeEvent`, which dispatched to `SealedRumorEventHandler`, which
unsealed correctly to a kind:444 — and then handed it back to
`eventProcessor.consumeEvent` again. There's no LocalCache event
handler registered for kind:444, so the WelcomeEvent was silently
dropped on every invitee.

Fix: extract the welcome-handling code from `GiftWrapEventHandler.
processMarmotWelcome` into a top-level `processMarmotWelcomeFlow`
helper, and call it from BOTH:

  - `GiftWrapEventHandler.processNewGiftWrap` (kept for the unlikely
    case where Marmot ever publishes a Welcome with no Seal layer)
  - `SealedRumorEventHandler.processNewSealedRumor` (the actual
    production path) — after unsealing, if the inner rumor is a
    WelcomeEvent it now goes straight to the shared flow instead of
    being passed to the unrouted event processor.

Also added matching `MarmotDbg` logs in `processNewSealedRumor` so
the seal-side trace is visible: id, unsealed inner kind/id, and the
"detected Marmot WelcomeEvent inside seal" routing line.

Compile-checked: `:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
2026-04-15 23:13:49 +00:00
Claude 1c9585e5e4 fix(marmot): look up KeyPackageBundle by Nostr event id + add MarmotDbg logs
While adding diagnostic logs to trace why invitees were receiving
nothing on Marmot group adds, I found the actual root cause: a
fundamental hash mismatch between the value the Welcome event carries
and the value the receiver looks up by.

### The bug

A Marmot WelcomeEvent (kind:444) carries a tag `["e", <eventId>]`
referencing the kind:30443 KeyPackage event that was consumed — see
`KeyPackageEventTag` and `MarmotWelcomeSender.wrapWelcome`. That
`<eventId>` is the *Nostr event id* (a hash of the signed event JSON).

`MarmotInboundProcessor.processWelcome` then called
`keyPackageRotationManager.findBundleByRef(hexToBytes(eventId))`,
which compares each stored bundle's `keyPackage.reference()` —
**the MLS-spec KeyPackageRef, an entirely different hash computed by
`MlsCryptoProvider.refHash("MLS 1.0 KeyPackage Reference", encoded)`
over the TLS-encoded KeyPackage**.

Those two values are never equal, so the lookup ALWAYS missed and
every invitee path returned `WelcomeResult.Error("No matching
KeyPackageBundle found")`. Combined with the previous in-memory-only
storage, this is why nothing ever appeared on the invitee's screen.

### The fix

`KeyPackageRotationManager` now also indexes bundles by the Nostr
event id of the corresponding kind:30443 event:

- `eventIdToSlot: Map<HexKey, String>` — populated by
  `recordPublishedEventId(slot, eventId)`, called from
  `MarmotManager.generateKeyPackageEvent` immediately after signing
  the event template (which is when the event id is first known).
- `findBundleByEventId(eventId)` — looks up the slot via the new
  index, then returns the bundle.
- `markConsumedByEventId(eventId)` — symmetric consume-by-event-id
  for the welcome receive path.
- The persisted snapshot format is bumped from v1 → v2 to include
  the eventId map. v1 snapshots are still readable (loaded as if
  the eventId map were empty); republishing a KeyPackage will refill
  it. Also cleans the index when slots are consumed.

`MarmotInboundProcessor.processWelcome` now uses
`findBundleByEventId(keyPackageEventId)` instead of
`findBundleByRef(hexToBytes(...))`, and `markConsumedByEventId` for
the consume call. The dead `hexToBytes` helper + import are removed.

### Diagnostic logging

Added `MarmotDbg`-tagged logs across the entire add-member / send /
receive chain so the user can `adb logcat -s MarmotDbg` to see
exactly what's being sent and what's being received:

- `Account.fetchKeyPackageAndAddMember` — querying relays, KP found
  / not found, kind, author
- `Account.addMarmotGroupMember` — commit publish target, welcome
  delivery presence, full relay union with sources
- `Account.sendMarmotGroupMessage` — group, inner kind, target relays
- `Account.publishMarmotKeyPackage` + `ensureMarmotKeyPackagePublished`
  — publish target relays, signed event id
- `DecryptAndIndexProcessor.processNewGiftWrap` — gift wrap unwrap
  result, inner kind, route to Marmot welcome handler
- `DecryptAndIndexProcessor.processMarmotWelcome` — manager null,
  WelcomeResult, synced metadata, group id
- `DecryptAndIndexProcessor.GroupEventHandler.add` — kind:445 arrival,
  membership check, processGroupEvent result, decrypt path
- `MarmotInboundProcessor.processWelcome` — eventId lookup,
  bundle-not-found details, MLS join success
- `MarmotGroupEventsEoseManager.updateFilter` — active groups,
  per-group relay set, fallback usage, total emitted filters
- `AccountGiftWrapsEoseManager.updateFilter` — pubkey + dmRelay set
  used for kind:1059 subscription

Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
2026-04-15 23:04:58 +00:00
Claude 99506e3db0 fix(marmot): land welcomes + group msgs on the invitee's relays
The user reported that NIP-17 DMs from Bob to Charlie arrive instantly,
but Marmot Welcomes and group messages produce nothing on Charlie's
device. Three independent bugs combined to make Marmot invisible on
the receiving side.

### 1. kind:445 EOSE filter never invalidated when joining a group

`MarmotGroupEventsEoseManager` only re-polled its filter set when
`account.homeRelays.flow` emitted. So an invitee that just processed
a Welcome got the new group added to `MarmotSubscriptionManager` and
to `marmotGroupList`, but the per-`h`-tag kind:445 subscription was
never actually sent to the relays — the EOSE manager kept using the
filter snapshot from app start. The same was true for restored groups
on every restart.

Fix: add a second job in `MarmotGroupEventsEoseManager.newSub` that
collects `account.marmotGroupList.groupListChanges` and calls
`invalidateFilters()`. `Account.init`'s restore loop now also fires
`marmotGroupList.notifyGroupChanged(groupId)` for every restored
group so the EOSE manager picks them up at startup.

### 2. Group metadata had no relays — both sides used different sets

`MarmotGroupData.relays` was always empty. `marmotGroupRelays` and
`MarmotGroupEventsEoseManager` both fall back to "the local user's
own relays" when the metadata has none — so Bob published kind:445 to
Bob's outbox while Charlie subscribed on Charlie's home relays. The
two only overlap by accident.

Fix: `AccountViewModel.updateMarmotGroupMetadata` now stamps the
inviter's outbox relays into the GCE proposal (merged with any
existing relays). The Welcome carries the GroupContext extensions, so
the invitee learns the same canonical relay set at join time.
`CreateGroupScreen` now always issues the metadata commit (even when
the user left the name blank) so this stamping happens for every new
group.

### 3. Welcome gift wrap delivery used a different relay path than NIP-17

`addMarmotGroupMember` had its own ad-hoc relay fallback chain
(`outboxRelays + cache.getOrCreateUser(...).dmInboxRelays()`) and
explicitly avoided `computeRelayListToBroadcast`. The comment claimed
that path returned an empty set in some cases, but the actual code in
`computeRelayListToBroadcast(GiftWrapEvent)` falls back through
kind:10050 → NIP-65 read → relay hints — exactly the same chain that
NIP-17 DMs use, which empirically reach the recipient.

Fix: union `computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent)`
with the previous outbox + dmInboxRelays set so the welcome takes the
same route NIP-17 DMs do, plus belt-and-braces fallbacks. Added a log
line so we can tell from logcat exactly how many relays the welcome
went out on.

Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
2026-04-15 22:45:50 +00:00
Claude d5ec7fcaf3 fix(marmot): persist KeyPackage bundles + auto-publish at startup
When user A wants to invite user B to a Marmot group, A must fetch B's
published KeyPackage (kind:30443) from relays. If B has never had one
published, A's `fetchKeyPackageAndAddMember` returns "No KeyPackage
found" and the invite silently fails — which matches the user's
report that "creating a new group with another user, nothing happens
for that user". Two fixes:

### 1. Persist KeyPackageBundles to disk

`KeyPackageRotationManager` was 100% in-memory: every restart wiped
both the active bundles (with their private init/encryption/signature
keys) AND the pending-rotation set. The relay echo of the kind:30443
event is meaningless without the matching private keys, so once a
session ended the user could no longer process Welcome events
referencing the published KeyPackage.

This change adds a quartz `KeyPackageBundleStore` interface and an
Android `AndroidKeyPackageBundleStore` implementation backed by the
same `KeyStoreEncryption` (AES/GCM via Android KeyStore) used by
`AndroidMlsGroupStateStore`. Storage layout:

    <rootDir>/marmot_keypackages/state    — encrypted snapshot

`KeyPackageRotationManager` accepts the store via constructor, encodes
the `(activeBundles, pendingRotations)` pair into a versioned TLS
snapshot, and persists after every state-mutating call:
`generateKeyPackage`, `markConsumed`, `markConsumedByRef`,
`clearPendingRotation`, `rotateSlot`. A new `restoreFromStore()`
method is invoked from `MarmotManager.restoreAll()` at startup.

The store is wired through:
  MarmotManager(... keyPackageStore)
    → Account(... marmotKeyPackageStore)
    → AccountCacheState (constructs AndroidKeyPackageBundleStore)

### 2. Ensure a published KeyPackage at app startup

`Account.init` now calls a new private `ensureMarmotKeyPackagePublished`
right after `marmotManager.restoreAll()`. If no active bundle exists
in memory after the restore (i.e., the user never published one), it
generates a fresh bundle (which the rotation manager auto-persists)
and publishes the corresponding `KeyPackageEvent` to the user's
outbox relays. Best-effort — failures are logged and swallowed so a
flaky relay or missing outbox config at startup doesn't break account
init.

The redundant `LaunchedEffect` in `MarmotGroupListScreen` that did the
same thing on first screen visit is now removed: KeyPackage publishing
is no longer tied to the user navigating to the group list.

This means freshly installed accounts (and any account that had never
opened the Marmot Groups screen) will now have a published KeyPackage
on relays as soon as the account loads, so other users can actually
invite them.

Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
2026-04-15 22:18:45 +00:00
Vitor Pamplona 88b3d8a221 Merge pull request #2416 from davotoula/hls-improvements
keep screen on + smooth per-file upload progress bar
2026-04-15 18:07:12 -04:00
Claude a7960f9484 fix(marmot): don't mark own messages unread + pad empty group list
- `MarmotGroupList.addMessage` now routes self-authored messages
  through `restoreMessageSync` (the no-unread-bump path) so the
  relay round-trip of a message the user just sent doesn't show up
  as an unread bold entry in the group list and doesn't bump the
  unread badge counter.
- `MarmotGroupListScreen` empty state now has 32dp horizontal
  padding and centered text alignment so the "No invitations" /
  "No groups yet" copy doesn't run into the screen edges.
2026-04-15 21:54:57 +00:00