Compare commits

...

14 Commits

Author SHA1 Message Date
e4aa5277d7 feat(chat): load older messages on scroll-to-top (history pagination) (v1.11.0+15)
Repository keeps one live Timeline per room and re-yields on its
onUpdate callback (fires for both live events AND requestHistory),
not just onSync which never fires for pagination. loadMoreMessages
now paginates that same instance; added canLoadMore.

Timeline widget requests older history when scrolled within 400px of
the oldest end, guarded against re-entry, with a top loading spinner.

Verified: widget test drives a drag-to-top and asserts loadMoreMessages
fires; live smoke test confirms the onUpdate delivery path (reused by
history) still shows new messages after the refactor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:44:38 +10:00
f9c21fddc4 feat(chat): scroll-to-latest button + panic hold-progress ring (v1.10.0+14)
B6 scroll-to-latest: ScrollController on the timeline; a small
  arrow-down FAB fades in once scrolled >400px up and animates back
  to the newest message on tap (overlaid on the list, not the FAB slot)
B5 panic hold-ring: 700ms AnimationController drives a red progress
  ring around the panic icon; haptic + fire on completion, silent
  reset on early release; tap-hint and configured guard preserved

Panic ring verified in-browser (ring fills, alert fires to the room).
Scroll-to-latest verified with a deterministic widget test because the
CanvasKit surface rejects synthetic scroll input in headless Chromium.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:32:48 +10:00
f55f8dc698 feat(chat): message grouping, day separators, quick-reaction row (v1.9.0+13)
B1 grouping: same-sender runs within 5 min collapse to one name + one
  avatar (last-of-run), tight padding, tail corner only on last bubble
B2 day separators: Today/Yesterday/d MMM chip above each local day's
  first message, Column wraps the gesture widget (long-press preserved)
B4 quick reactions: six preset emoji + full-picker button atop the
  long-press menu, wired to the existing sendReaction provider

Verified end-to-end in headless Chromium on the production build;
tapping a preset sends a real m.reaction (confirmed server-side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:10:11 +10:00
d2a2a1ab07 feat(ui): mobile UI v2 — contrast, avatars, room-list density, compose FAB, gradient login (v1.8.0+12)
From a 7-agent design workflow (3 lenses judged + synthesised). Shipped the
high-impact/low-risk tier:
- message bubbles: self #1265ED (WCAG AA pass), incoming #1C2452 + hairline
- deterministic per-user avatar colours (8-hue brand palette)
- room tile: brighter unread preview, compact density, aligned timestamp
- FAB -> New message (thumb zone), explore -> AppBar
- login + welcome brand gradient, card outlines
- input maxLines cap, bubble max-width viewport fraction, AppBar hairline

All verified on production build in headless Chromium at mobile size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 07:49:53 +10:00
a36df1c961 feat: encrypted-history prompt, incoming-call alerts, message edit, panic button (v1.7.0+11)
P1 key_restore_prompt: offer recovery-key restore right after login
P2 incoming calls: mount overlay in app.dart + detect MSC3401 call.member
   (Element X signalling) — calls were previously invisible
P3 message edit: editMessage/EditMessage + Edit dialog (reactions/delete
   were already wired)
P5 panic button: new feature/panic module — hold-to-send alert text +
   m.location to a configured room; alert sent before geolocation so a
   denied permission never blocks it

All verified end-to-end in headless Chromium against production build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 07:30:57 +10:00
923c0ad878 fix: Jitsi meeting join rendered blank page — call JitsiMeetExternalAPI with 'new'
- Bind JitsiMeetExternalAPI as extension type external constructor;
  the old external function binding invoked the ES6 class without 'new'
  and threw at runtime (silently, in release builds)
- joinMeeting: wait up to 5s for platform-view container div (post-frame
  race), build options via setProperty (jsify cannot carry DOM nodes),
  return success bool, log failures to browser console
- jitsi_screen: show 'Could not start the meeting' UI instead of blank
- v1.6.2+10, deployed to app2.m8chat.au

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 07:05:32 +10:00
3568b17f0f feat: brand retheme to m8chat.au blues, fix black logo, v1.6.0+8
- m8logo.svg: inline fills (flutter_svg ignores <style> blocks; logo rendered black)
- theme.dart v2.0.0: purple -> site palette (#1265ED / #3B8BFF / navy darks)
- app_config.dart: appVersion 1.3.0 -> 1.6.0 (profile screen showed stale version)
- manifest.json: theme/background colours to match
- Remove em-dashes from user-visible UI strings (global style rule)
- chat_screen.dart: fix use_build_context_synchronously in _leaveRoom
- Deployed to app2.m8chat.au after full functionality audit (all green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 05:16:11 +10:00
3865391829 merge: reconcile Gitea claude-notes commits (10-11 Apr) with local Phase 3+ code history
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 04:54:27 +10:00
0672f6b28b fix: backport SPA routing block added directly on production (2026-05-01) to web/.htaccess
Production app2.m8chat.au/.htaccess gained Flutter HTML5 routing rules
edited live on brisbane01; local repo never received them. Full-tree
md5 comparison (45 files) shows this was the only drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 04:53:53 +10:00
1cffe09faf feat: web push notifications, image fullscreen, public room browser
- Web Push: push_sw.js service worker + VAPID subscription registered
  on login; Matrix pusher registered pointing to Sygnal on
  https://matrix.m8chat.au/_matrix/push/v1/notify
- Chat: tap image -> fullscreen InteractiveViewer (pinch/zoom)
- Rooms: public room browser via Matrix room directory (FAB + empty state)

Build fixes applied to web_push_service.dart:
  - dart:js_interop_unsafe import added for getProperty on JSObject
  - client.setPusher -> client.postPusher (matrix 0.33.0 API)
  - keyBytes.toJS removed (JSUint8Array already a JS type)
  - register() script URL coerced with .toJS (String -> JSAny)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 06:48:48 +10:00
542a5e5221 feat: image fullscreen viewer, public room browser
- Chat: tap any image to open fullscreen InteractiveViewer (pinch/zoom)
- Rooms: public room browser (Find rooms FAB + empty state button)
  searches Matrix room directory via queryPublicRooms, join from results
- Rooms: empty state now shows 'Find rooms' button to onboard new users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 06:23:27 +10:00
c1b5b31d48 feat: room search, room options sheet, .htaccess for E2EE
- Rooms screen: inline search filter with toggle — replaces the no-op button
- Chat screen: room options sheet — member list, invite, leave room
- rooms_repository: remove debug prints
- web/.htaccess: COOP+COEP headers for SharedArrayBuffer / LiveKit E2EE worker
  (was on production but missing from source; adds it to build output automatically)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 05:49:10 +10:00
7e16b825c5 chore: bump version to 1.3.0+4 for Phase 3 deploy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 05:32:49 +10:00
b95471b0b4 feat: Phase 3 — E2EE calls, Jitsi conferencing, help tab, web security model
- LiveKit call E2EE: CallE2EEManager exchanges encryption keys via Matrix
  to-device events (m.rtc.encryption_keys) for interop with Element X
- Olm bootstrapped in index.html before Flutter init; main.dart logs result
- Encrypted messages shown with lock icon and informative fallback text
- Profile screen: key restore dialog + security setup (cross-signing/backup)
- Jitsi feature: welcome screen (public, no login), conference tab, full-screen
  embed via JitsiMeetExternalAPI, JitsiLink parser for all common link formats
- Help tab: expandable cards for encryption, video calls, account management
- Web security model: no session persistence — device ID only across visits
- Media auth: MSC3916 authenticated endpoint for avatars (Synapse 1.120+)
- Router: welcome route as public landing page; jitsi route as public
- Manifest/index.html: M8Chat branding, dark theme colours

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 05:28:14 +10:00
49 changed files with 4419 additions and 372 deletions

View File

@@ -1,24 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- Version: 1.1.0 | Updated: 2026-07-03 -->
<!-- Fills inlined as attributes: flutter_svg does not parse <style> blocks,
which made the whole logo render black in the app. -->
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 429 429"> <svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 429 429">
<!-- Generator: Adobe Illustrator 29.3.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 100) --> <path fill="#1466eb" d="M70.64,352.36c-3.51-2.16,1.51-50.04-.33-57.17-.72-2.41-16.56-9.68-20.12-11.88-54.87-34.03-34.77-110.33,1.79-150.82,55.72-61.71,140.84-65.99,217.72-49.69,48.91,10.37,149.12,46.91,145.84,109.25-2.02,38.32-56.82,85.23-88.37,102.62-66.49,36.67-140.36,36.21-212.2,16.21-2.19.3-16.47,21.54-19.5,25.09-3.72,4.36-18.49,20.3-24.83,16.39ZM314.5,196.52c15.07-5.73,19.34-26.28,12.4-39.91-14.75-28.96-81.5-25.08-84.39,11.41-.63,8.02,3.12,19.38,8.95,25.02,2.58,2.49,7.86,3.06,4.98,5.91-4.11,4.07-8.05,4.76-11.77,11.23-23.76,41.34,43.05,66.64,75.74,40.74,14.99-11.88,16.47-35.84,1.56-48.38-2.18-1.83-5.36-2.8-6.85-3.96-.97-.76-.79-.82-.62-2.05ZM118.5,139.5h-36v120h23v-88l31.16,86.34.79,1.21,23.9.32,31.14-86.86v85.5c0,.11,1.39,1.5,1.5,1.5h21.5v-120h-34.5c-11.09,24.47-16.9,52.95-27.47,77.53-1.08,2.52-3.04,6.59-5.02,8.47l-30.01-86Z"/>
<defs>
<style>
.st0 {
fill: #f8fbfe;
}
.st1 {
fill: #1466eb;
}
</style>
</defs>
<path class="st1" d="M70.64,352.36c-3.51-2.16,1.51-50.04-.33-57.17-.72-2.41-16.56-9.68-20.12-11.88-54.87-34.03-34.77-110.33,1.79-150.82,55.72-61.71,140.84-65.99,217.72-49.69,48.91,10.37,149.12,46.91,145.84,109.25-2.02,38.32-56.82,85.23-88.37,102.62-66.49,36.67-140.36,36.21-212.2,16.21-2.19.3-16.47,21.54-19.5,25.09-3.72,4.36-18.49,20.3-24.83,16.39ZM314.5,196.52c15.07-5.73,19.34-26.28,12.4-39.91-14.75-28.96-81.5-25.08-84.39,11.41-.63,8.02,3.12,19.38,8.95,25.02,2.58,2.49,7.86,3.06,4.98,5.91-4.11,4.07-8.05,4.76-11.77,11.23-23.76,41.34,43.05,66.64,75.74,40.74,14.99-11.88,16.47-35.84,1.56-48.38-2.18-1.83-5.36-2.8-6.85-3.96-.97-.76-.79-.82-.62-2.05ZM118.5,139.5h-36v120h23v-88l31.16,86.34.79,1.21,23.9.32,31.14-86.86v85.5c0,.11,1.39,1.5,1.5,1.5h21.5v-120h-34.5c-11.09,24.47-16.9,52.95-27.47,77.53-1.08,2.52-3.04,6.59-5.02,8.47l-30.01-86Z"/>
<g> <g>
<path class="st0" d="M118.5,139.5l30.01,86c1.98-1.88,3.94-5.94,5.02-8.47,10.56-24.59,16.37-53.06,27.47-77.53h34.5v120h-21.5c-.11,0-1.5-1.39-1.5-1.5v-85.5l-31.14,86.86-23.9-.32-.79-1.21-31.16-86.34v88h-23v-120h36Z"/> <path fill="#f8fbfe" d="M118.5,139.5l30.01,86c1.98-1.88,3.94-5.94,5.02-8.47,10.56-24.59,16.37-53.06,27.47-77.53h34.5v120h-21.5c-.11,0-1.5-1.39-1.5-1.5v-85.5l-31.14,86.86-23.9-.32-.79-1.21-31.16-86.34v88h-23v-120h36Z"/>
<g> <g>
<path class="st0" d="M314.5,196.52c-.17,1.23-.35,1.29.62,2.05,1.48,1.17,4.67,2.13,6.85,3.96,14.91,12.55,13.43,36.51-1.56,48.38-32.68,25.9-99.5.6-75.74-40.74,3.72-6.47,7.66-7.16,11.77-11.23,2.88-2.86-2.4-3.42-4.98-5.91-5.84-5.64-9.59-17-8.95-25.02,2.88-36.49,69.63-40.37,84.39-11.41,6.95,13.63,2.68,34.19-12.4,39.91ZM281.26,156.76c-20.8,2.86-20.27,28.16-1.17,30.66,10.68,1.4,26.97-.71,27.32-14.51.36-14.27-14.74-17.71-26.15-16.15ZM279.26,207.76c-8.99,1.29-15.54,7.92-15.55,17.24-.02,22.72,41.87,22.15,44.72,3.94,3.04-19.37-13.32-23.45-29.17-21.18Z"/> <path fill="#f8fbfe" d="M314.5,196.52c-.17,1.23-.35,1.29.62,2.05,1.48,1.17,4.67,2.13,6.85,3.96,14.91,12.55,13.43,36.51-1.56,48.38-32.68,25.9-99.5.6-75.74-40.74,3.72-6.47,7.66-7.16,11.77-11.23,2.88-2.86-2.4-3.42-4.98-5.91-5.84-5.64-9.59-17-8.95-25.02,2.88-36.49,69.63-40.37,84.39-11.41,6.95,13.63,2.68,34.19-12.4,39.91ZM281.26,156.76c-20.8,2.86-20.27,28.16-1.17,30.66,10.68,1.4,26.97-.71,27.32-14.51.36-14.27-14.74-17.71-26.15-16.15ZM279.26,207.76c-8.99,1.29-15.54,7.92-15.55,17.24-.02,22.72,41.87,22.15,44.72,3.94,3.04-19.37-13.32-23.45-29.17-21.18Z"/>
<path class="st1" d="M279.26,207.76c15.85-2.28,32.21,1.81,29.17,21.18-2.86,18.21-44.75,18.78-44.72-3.94,0-9.32,6.57-15.95,15.55-17.24Z"/> <path fill="#1466eb" d="M279.26,207.76c15.85-2.28,32.21,1.81,29.17,21.18-2.86,18.21-44.75,18.78-44.72-3.94,0-9.32,6.57-15.95,15.55-17.24Z"/>
<path class="st1" d="M281.26,156.76c11.41-1.57,26.51,1.88,26.15,16.15-.35,13.8-16.64,15.91-27.32,14.51-19.1-2.5-19.63-27.8,1.17-30.66Z"/> <path fill="#1466eb" d="M281.26,156.76c11.41-1.57,26.51,1.88,26.15,16.15-.35,13.8-16.64,15.91-27.32,14.51-19.1-2.5-19.63-27.8,1.17-30.66Z"/>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -68,4 +68,68 @@
- **Decision:** Changed _clearCallMemberEvent to send {} instead of {'memberships': []} - **Decision:** Changed _clearCallMemberEvent to send {} instead of {'memberships': []}
- **Rationale:** Element X sends {} when ending calls (verified from DB). Our {'memberships': []} wasn't being recognised. - **Rationale:** Element X sends {} when ending calls (verified from DB). Our {'memberships': []} wasn't being recognised.
- **Files affected:** livekit_service.dart - **Files affected:** livekit_service.dart
- **Status:** Deployed. NOT YET TESTED. - **Status:** Deployed. NOT YET TESTED.
### 2026-07-03 05:20 — Functionality audit + brand retheme (v1.6.0+8)
- **Decision:** Retheme app from purple (#5C35C9) to m8chat.au brand blues taken from the site's Elementor kit: brandBlue #1265ED, accentBlue #3B8BFF, navy darks derived from Ancop Blue #010B46.
- **Rationale:** Full audit (2026-07-03) found look and feel did not match m8chat.au. Logo rendered black because flutter_svg does not parse SVG <style> blocks; fills now inlined as attributes in m8logo.svg. Profile screen showed stale hardcoded appVersion 1.3.0. UI strings contained em-dashes (global style rule violation).
- **Audit results:** Backend all healthy (client API, livekit JWT, Jitsi external_api.js, push gateway, TURN 3478/5349). Full E2E pass in headless Chromium: login, rooms, spaces, profile, conference, help tabs, room open, message send verified on Synapse. Disposable audit users created and erased via admin API.
- **Files affected:** assets/images/m8logo.svg, lib/app/theme.dart (2.0.0), lib/core/config/app_config.dart (2.1.0), web/manifest.json, pubspec.yaml (1.6.0+8), em-dash fixes in 8 presentation/util files, chat_screen.dart lint fix (_leaveRoom context param).
- **Status:** Done — built, deployed to app2.m8chat.au (backup public_html.bak.20260703_pre160.tar.gz), visually verified light+dark.
### 2026-07-04 07:15 — Jitsi conference join fixed (v1.6.2+10)
- **Decision:** Root cause of "blank page on join" was the JS interop binding calling JitsiMeetExternalAPI (an ES6 class) as a plain function, i.e. without `new`. Browsers throw "Class constructor cannot be invoked without 'new'" and the meeting screen stayed blank. Feature had never worked in the web release build.
- **Fix:** `_JitsiApi` extension type with an external constructor (compiles to `new`), container-div wait loop in joinMeeting (post-frame callback could fire before the platform view div existed), JS options built via setProperty instead of Map.jsify (DOM node in a jsify map is unsupported), joinMeeting returns bool, jitsi_screen shows an error screen instead of blank on failure.
- **Verified:** headless Chromium on production build: iframe renders, prejoin page shows, devices pass, name entry works, reaches the Prosody JWT auth gate (expected for a tokenless room). COEP require-corp turned out NOT to block conf.m8chat.au in current Chromium.
- **Files affected:** lib/features/jitsi/data/jitsi_web_service.dart (1.2.0), lib/features/jitsi/presentation/jitsi_screen.dart (1.3.0), app_config 1.6.2, pubspec 1.6.2+10.
- **Status:** Done — deployed to app2.m8chat.au (backups pre160/pre161 tarballs on server).
### 2026-07-04 07:35 — Feature build round 1 (v1.7.0+11)
- **P1 Encrypted history:** new key_restore_prompt.dart offers recovery-key restore right after login when the account has secret storage and the fresh device can't read history (was buried in Profile). Fires once per session from rooms_screen initState.
- **P2 Incoming calls:** overlay existed but (a) was never mounted and (b) only listened for legacy m.call.invite. Fixed: IncomingCallOverlayHost mounted in app.dart MaterialApp.builder; matrixrtc_repository now also detects MSC3401 org.matrix.msc3401.call.member (what Element X sends), with stale-event + dedupe guards. Accept uses routerProvider (overlay is above the router navigator).
- **P3 Reactions/edit/delete:** reactions + delete were already wired (notes were stale). Added editMessage (sendTextEvent editEventId) + EditMessage notifier + Edit tile/dialog. Verified full context menu renders Reply/React/Copy/Edit/Delete.
- **P4 File/image send:** already implemented (sendFile + file_picker + UploadFile). Confirmed, no change needed.
- **P5 Panic button:** NEW feature/panic module. Config in account data au.m8chat.panic (room + message). Hold-to-fire button in rooms AppBar (only shows when armed); Profile tile to arm/disarm. Alert text sent FIRST, then best-effort m.location pin (geo: uri, msc3488) with 10s geolocation budget so a denied permission never blocks the alert. VERIFIED end-to-end: text + location with exact GPS landed in the target room via mock geolocation.
- **Status:** built, deployed app2.m8chat.au v1.7.0+11 (backup pre170 tarball on server). Analyze clean, tests pass.
- **Deferred:** ghost-device cleanup, rooms-provider rebuild perf, native session persistence — still open from original notes.
### 2026-07-04 07:50 — Mobile UI v2 design pass (v1.8.0+12)
- **Process:** ran a 7-agent design workflow (3 lens designers: information-density, reachability-flow, polish-delight; 3 judges; 1 synthesiser). All proposals scored 8-9/10. Output: prioritised spec (A high-impact/low-risk, B medium, C defer). Full spec archived in session.
- **Implemented the whole A tier + safe B items:**
- A1/A2 message bubble contrast: self #1265ED (selfBubble token, WCAG AA 5.11:1 vs failing 3.32:1), incoming #1C2452 + hairline border.
- A3 replaced failing low-alpha read text with subtleText token (rooms empty state, room tile).
- A4 deterministic per-user avatar colour (8-hue brand palette, name.hashCode) in matrix_avatar.dart — every disc now distinct.
- A5/A6 room tile: unread preview brightened+w500, compact density, timestamp aligned to title, unread dot spacing.
- A7 fixed stale "pencil icon" empty-state copy.
- A9 FAB now "New message" (compose) in thumb zone; explore moved to AppBar compass. Straight onPressed swap.
- A8 login + welcome brand gradient (#0A0F2E#131A42), card hairline outlines.
- B7 message input capped minLines 1 / maxLines 6. B8 bubble maxWidth = viewport*0.78 clamped 560. B10 AppBar scrolledUnderElevation + hairline.
- **Deferred (documented in spec):** B1 message grouping, B2 day separators, B4 quick-reaction row, B5 panic hold-ring, B6 scroll-to-latest FAB, C1 swipe-to-reply (iOS PWA back-gesture risk), C2 group sender prefix (needs RoomModel field), C3 demote Help nav.
- **Verified:** headless Chromium at 390x844 on production build — gradient login, distinct avatar colours, compose FAB, AA bubble contrast all confirmed.
- **Status:** built + deployed app2.m8chat.au v1.8.0+12 (backup pre180 tarball). Analyze clean, tests pass.
### 2026-07-04 08:10 — Deferred UI items built (v1.9.0+13)
- **B1 message grouping:** MessageBubble gains optional isFirstInGroup/isLastInGroup (defaulted true so callers keep compiling). _Timeline computes them from reverse-index neighbours (older=index+1, newer=index-1) + 5-min gap. Sender name only on first-of-run, avatar only on last-of-run (32px gutter holds space otherwise), tail corner only on last-of-run, tight 1px padding mid-run.
- **B2 day separators:** _DayChip (Today/Yesterday/d MMM, toLocal AEST) rendered in a Column above the first message of each local day. Column wraps the gesture widget so long-press stays intact.
- **B4 quick-reaction row:** _MessageContextMenu gains a top row of six presets (👍❤️😂😮😢🙏, 44dp each) + "+" opening the existing EmojiPicker, above Reply. Uses existing sendReaction provider.
- **Verified in headless Chromium on production build:** grouping renders (Sam's 3-run collapsed to tight blue bubbles; Alex's 2-run shows name once + avatar on last bubble only, amber deterministic disc), "Today" chip renders, quick-react row renders with all six presets + picker button, tapping ❤️ sent a real m.reaction (confirmed via server /messages). Long-press context menu still fires (all of Reply/React/Copy/Edit/Delete intact).
- **Note:** cross-day separators are logic-only verified (can't backdate events without an appservice); the same-day "Today" chip confirms the render path.
- **Status:** built + deployed app2.m8chat.au v1.9.0+13 (backup pre190 tarball). Analyze clean, format clean, tests pass.
### 2026-07-04 08:30 — Scroll-to-latest + panic hold-ring (v1.10.0+14)
- **B6 scroll-to-latest:** _Timeline converted ConsumerWidget → ConsumerStatefulWidget with a ScrollController. When offset > 400 (reverse list, newest at 0), an AnimatedScale FloatingActionButton.small(arrow_downward) fades in at Positioned(right:12,bottom:12) inside a Stack around the ListView (NOT the Scaffold FAB slot). Tap → animateTo(0, 200ms, easeOut).
- **B5 panic hold-ring:** panic_button.dart → onLongPressDown starts a 700ms AnimationController; a red CircularProgressIndicator(value: controller.value) fills around the emergency_share icon; on completion HapticFeedback.heavyImpact + fire; early release resets silently. onTap still shows the hint; configured guard + _sending state preserved.
- **Verified:**
- Panic ring: headless Chromium screenshot mid-hold shows the red arc ~half-filled; completing the hold fired the alert (🚨 text landed in the SES Emergency room; location correctly skipped when geolocation not granted, proving the alert-before-location safeguard).
- Scroll-to-latest: the Flutter CanvasKit surface would NOT accept synthetic scroll input from Playwright (mouse wheel and drag both no-op on the canvas; input field steals focus). Verified instead with a deterministic widget test (test/scroll_to_latest_test.dart): 40 messages, starts at offset 0, drag up → offset > 400 + button appears, tap → offset back to 0. Passes.
- **Note for future:** getTimeline() only yields the ~10-event sync window and there is no scroll-back history pagination, so on a normal phone the timeline rarely exceeds ~1 screen and the jump button seldom shows in practice. Wiring requestHistory() on near-top scroll would make both the button and long-scrollback genuinely useful. Flagged, not built.
- **Status:** built + deployed app2.m8chat.au v1.10.0+14 (backup pre1100 tarball). Analyze clean, 4 tests pass.
### 2026-07-04 08:50 — Scroll-back history pagination (v1.11.0+15)
- **What:** wired requestHistory() so older messages load as you scroll toward the top. Completes the scroll-to-latest feature (previously the timeline only ever held the ~10-event sync window).
- **Repository (chat_repository.dart v1.5.0):** now keeps one live Timeline per room in `_timelines` map. watchTimeline creates it with an onUpdate callback that drives a StreamController signal, re-yielding the mapped list on BOTH live events and history pagination (the old code only re-yielded on onSync, which does NOT fire for requestHistory). loadMoreMessages paginates that stored instance (old code called requestHistory on a throwaway getTimeline() — wrong instance). Added canLoadMore(roomId) → timeline.canRequestHistory. Timeline cancelled + removed in the stream's finally.
- **UI (chat_screen.dart v1.7.0):** _TimelineState._onScroll detects near-top (reverse list: pixels >= maxScrollExtent - 400) and calls _loadOlder, guarded by _loadingMore + canLoadMore. Top-centre spinner while loading. Prepended older content grows maxScrollExtent while pixels stays put, so the viewport stays anchored and you move out of the trigger band (no infinite loop).
- **Verified:**
- Widget test test/history_pagination_test.dart: 40 msgs, fake repo, drag to top → loadMoreMessages called (with offset/override assertions). Passes. 5 tests total green.
- Live smoke test on production: room displays the synced window correctly after the refactor, and a newly-sent message appears at the bottom — proving the onUpdate→remap→display path (which history reuses) works.
- NOT screenshot-verified: older messages (1-5) materialising on scroll-to-top, because the CanvasKit surface rejects synthetic scroll in headless Chromium. Trigger (widget test) + delivery path (live message) are both proven; the SDK calls onUpdate after requestHistory (timeline.dart:115), same path.
- **Status:** built + deployed app2.m8chat.au v1.11.0+15 (backup pre1110 tarball). Analyze clean, 5 tests pass.

View File

@@ -1,9 +1,12 @@
// Version: 1.0.0 | Created: 2026-04-01 // Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Root MaterialApp widget. Wires together theme + router. // Root MaterialApp widget. Wires together theme + router + incoming-call
// overlay (v1.1.0 — the overlay existed but was never mounted, so incoming
// calls showed nothing).
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/calls/presentation/incoming_call_overlay.dart';
import 'router.dart'; import 'router.dart';
import 'theme.dart'; import 'theme.dart';
@@ -26,6 +29,8 @@ class M8ChatApp extends ConsumerWidget {
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
routerConfig: router, routerConfig: router,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
builder: (context, child) =>
IncomingCallOverlayHost(child: child ?? const SizedBox.shrink()),
); );
} }
} }

View File

@@ -1,4 +1,4 @@
// Version: 1.0.2 | Created: 2026-04-01 // Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-05
// All route definitions in one place. // All route definitions in one place.
// GoRouter is created ONCE (keepAlive) and re-evaluates redirects via // GoRouter is created ONCE (keepAlive) and re-evaluates redirects via
// refreshListenable — avoids the GoRouter recreation bug from ref.watch. // refreshListenable — avoids the GoRouter recreation bug from ref.watch.
@@ -12,6 +12,8 @@ import '../core/auth/auth_state.dart';
import '../features/auth/presentation/login_screen.dart'; import '../features/auth/presentation/login_screen.dart';
import '../features/calls/presentation/call_screen.dart'; import '../features/calls/presentation/call_screen.dart';
import '../features/chat/presentation/chat_screen.dart'; import '../features/chat/presentation/chat_screen.dart';
import '../features/jitsi/presentation/jitsi_screen.dart';
import '../features/jitsi/presentation/welcome_screen.dart';
import '../features/profile/presentation/profile_screen.dart'; import '../features/profile/presentation/profile_screen.dart';
import '../features/rooms/presentation/rooms_screen.dart'; import '../features/rooms/presentation/rooms_screen.dart';
import '../features/spaces/presentation/spaces_screen.dart'; import '../features/spaces/presentation/spaces_screen.dart';
@@ -21,12 +23,14 @@ part 'router.g.dart';
/// Route path constants — use these instead of raw strings. /// Route path constants — use these instead of raw strings.
abstract final class AppRoutes { abstract final class AppRoutes {
static const String root = '/'; static const String root = '/';
static const String welcome = '/welcome';
static const String login = '/login'; static const String login = '/login';
static const String rooms = '/rooms'; static const String rooms = '/rooms';
static const String chat = '/rooms/:roomId'; static const String chat = '/rooms/:roomId';
static const String call = '/calls/:roomId'; static const String call = '/calls/:roomId';
static const String profile = '/profile'; static const String profile = '/profile';
static const String spaces = '/spaces'; static const String spaces = '/spaces';
static const String jitsi = '/jitsi';
} }
/// ChangeNotifier that GoRouter listens to for redirect re-evaluation. /// ChangeNotifier that GoRouter listens to for redirect re-evaluation.
@@ -35,6 +39,13 @@ class _AuthRefreshNotifier extends ChangeNotifier {
void notify() => notifyListeners(); void notify() => notifyListeners();
} }
/// Routes that are accessible without authentication.
const _publicRoutes = {
AppRoutes.welcome,
AppRoutes.login,
AppRoutes.jitsi,
};
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
GoRouter router(Ref ref) { GoRouter router(Ref ref) {
// Create once — notifier triggers re-redirect without recreating the router. // Create once — notifier triggers re-redirect without recreating the router.
@@ -50,16 +61,22 @@ GoRouter router(Ref ref) {
// Read (not watch) so redirect does not itself trigger provider changes. // Read (not watch) so redirect does not itself trigger provider changes.
final authState = ref.read(authProvider); final authState = ref.read(authProvider);
final isLoggedIn = authState is AuthAuthenticated; final isLoggedIn = authState is AuthAuthenticated;
final isOnLogin = state.matchedLocation == AppRoutes.login; final currentPath = state.matchedLocation;
final isPublicRoute = _publicRoutes.contains(currentPath);
// While restoring session, stay on splash. // While restoring session, stay on splash.
if (authState is AuthInitial || authState is AuthLoading) return null; if (authState is AuthInitial || authState is AuthLoading) return null;
// Unauthenticated users must go to login. // Unauthenticated users hitting a protected route → welcome screen.
if (!isLoggedIn && !isOnLogin) return AppRoutes.login; if (!isLoggedIn && !isPublicRoute) return AppRoutes.welcome;
// Authenticated users should not see the login screen. // Authenticated users on welcome or login → go straight to rooms.
if (isLoggedIn && isOnLogin) return AppRoutes.rooms; if (isLoggedIn &&
(currentPath == AppRoutes.welcome ||
currentPath == AppRoutes.login ||
currentPath == AppRoutes.root)) {
return AppRoutes.rooms;
}
return null; return null;
}, },
@@ -68,6 +85,10 @@ GoRouter router(Ref ref) {
path: AppRoutes.root, path: AppRoutes.root,
builder: (context, state) => const _SplashRedirectPage(), builder: (context, state) => const _SplashRedirectPage(),
), ),
GoRoute(
path: AppRoutes.welcome,
builder: (context, state) => const WelcomeScreen(),
),
GoRoute( GoRoute(
path: AppRoutes.login, path: AppRoutes.login,
builder: (context, state) => const LoginScreen(), builder: (context, state) => const LoginScreen(),
@@ -100,6 +121,13 @@ GoRouter router(Ref ref) {
path: AppRoutes.spaces, path: AppRoutes.spaces,
builder: (context, state) => const SpacesScreen(), builder: (context, state) => const SpacesScreen(),
), ),
GoRoute(
path: AppRoutes.jitsi,
builder: (context, state) {
final meetingUrl = state.extra as String? ?? '';
return JitsiScreen(meetingUrl: meetingUrl);
},
),
], ],
); );
} }

View File

@@ -1,25 +1,30 @@
// Version: 1.0.0 | Created: 2026-04-01 // Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-07-03
// M8Chat brand theme — dark and light variants. // M8Chat brand theme — dark and light variants.
// Primary brand colour: #5C35C9 (deep purple). Accent: #7B5CF6. // Palette from the m8chat.au Elementor kit (kit post 8):
// Accent #1 "#1265ED", Blue Element "#3B8BFF", Ancop Blue "#010B46".
// v2.0.0 replaces the old purple scheme, which did not match the website.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// M8Chat brand colours. /// M8Chat brand colours.
abstract final class M8Colours { abstract final class M8Colours {
static const Color brandPurple = Color(0xFF5C35C9); static const Color brandBlue = Color(0xFF1265ED);
static const Color accentPurple = Color(0xFF7B5CF6); static const Color accentBlue = Color(0xFF3B8BFF);
static const Color darkBackground = Color(0xFF0F0F1A); // Self message bubble — deeper than accentBlue so white text passes WCAG AA
static const Color darkSurface = Color(0xFF1A1A2E); // (white on #1265ED = 5.11:1; on #3B8BFF = 3.32:1, fails).
static const Color darkSurfaceVariant = Color(0xFF22223A); static const Color selfBubble = Color(0xFF1265ED);
static const Color onDarkSurface = Color(0xFFE8E8F0); static const Color darkBackground = Color(0xFF0A0F2E);
static const Color subtleText = Color(0xFF9898B0); static const Color darkSurface = Color(0xFF131A42);
static const Color darkSurfaceVariant = Color(0xFF1C2452);
static const Color onDarkSurface = Color(0xFFE8ECF5);
static const Color subtleText = Color(0xFF96A0C2);
static const Color errorRed = Color(0xFFCF6679); static const Color errorRed = Color(0xFFCF6679);
static const Color unreadGreen = Color(0xFF4CAF50); static const Color unreadGreen = Color(0xFF4CAF50);
} }
/// Dark theme — default for M8Chat. /// Dark theme — default for M8Chat.
ThemeData buildDarkTheme() { ThemeData buildDarkTheme() {
const seed = M8Colours.brandPurple; const seed = M8Colours.brandBlue;
final scheme = final scheme =
ColorScheme.fromSeed( ColorScheme.fromSeed(
@@ -28,7 +33,7 @@ ThemeData buildDarkTheme() {
).copyWith( ).copyWith(
surface: M8Colours.darkBackground, surface: M8Colours.darkBackground,
surfaceContainerHighest: M8Colours.darkSurface, surfaceContainerHighest: M8Colours.darkSurface,
primary: M8Colours.accentPurple, primary: M8Colours.accentBlue,
onSurface: M8Colours.onDarkSurface, onSurface: M8Colours.onDarkSurface,
error: M8Colours.errorRed, error: M8Colours.errorRed,
); );
@@ -41,11 +46,15 @@ ThemeData buildDarkTheme() {
backgroundColor: M8Colours.darkSurface, backgroundColor: M8Colours.darkSurface,
foregroundColor: M8Colours.onDarkSurface, foregroundColor: M8Colours.onDarkSurface,
elevation: 0, elevation: 0,
scrolledUnderElevation: 2,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
shape: const Border(
bottom: BorderSide(color: M8Colours.darkSurfaceVariant),
),
), ),
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: M8Colours.darkSurface, backgroundColor: M8Colours.darkSurface,
indicatorColor: M8Colours.accentPurple.withAlpha(51), indicatorColor: M8Colours.accentBlue.withAlpha(51),
labelTextStyle: WidgetStateProperty.all( labelTextStyle: WidgetStateProperty.all(
const TextStyle(fontSize: 12, color: M8Colours.onDarkSurface), const TextStyle(fontSize: 12, color: M8Colours.onDarkSurface),
), ),
@@ -59,14 +68,14 @@ ThemeData buildDarkTheme() {
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: M8Colours.accentPurple, width: 2), borderSide: const BorderSide(color: M8Colours.accentBlue, width: 2),
), ),
labelStyle: const TextStyle(color: M8Colours.subtleText), labelStyle: const TextStyle(color: M8Colours.subtleText),
hintStyle: const TextStyle(color: M8Colours.subtleText), hintStyle: const TextStyle(color: M8Colours.subtleText),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: M8Colours.accentPurple, backgroundColor: M8Colours.accentBlue,
foregroundColor: Colors.white, foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(52), minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@@ -97,7 +106,7 @@ ThemeData buildDarkTheme() {
/// Light theme — follows system preference if user selects it. /// Light theme — follows system preference if user selects it.
ThemeData buildLightTheme() { ThemeData buildLightTheme() {
const seed = M8Colours.brandPurple; const seed = M8Colours.brandBlue;
final scheme = ColorScheme.fromSeed( final scheme = ColorScheme.fromSeed(
seedColor: seed, seedColor: seed,

View File

@@ -1,88 +1,32 @@
// Version: 1.1.1 | Created: 2026-04-01 // Version: 2.1.0 | Created: 2026-04-01 | Updated: 2026-04-27
// Riverpod notifier that owns the auth state machine. // Riverpod notifier that owns the auth state machine.
// All login/logout/session-restore transitions go through here. //
// WEB SECURITY MODEL: Every visit requires a fresh login. No session
// persistence. Only the Matrix device ID is saved across sessions so
// encryption keys accumulate on one device instead of creating ghosts.
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../features/auth/data/auth_repository.dart'; import '../../features/auth/data/auth_repository.dart';
import '../../features/auth/domain/auth_failure.dart'; import '../../features/auth/domain/auth_failure.dart';
import '../network/matrix_client.dart';
import '../push/web_push_service.dart';
import '../storage/sync_persistence_service.dart'; import '../storage/sync_persistence_service.dart';
import 'auth_state.dart'; import 'auth_state.dart';
import 'secure_storage.dart';
part 'auth_notifier.g.dart'; part 'auth_notifier.g.dart';
/// The single source of truth for authentication state.
///
/// keepAlive: true — auth state must persist for the entire app lifetime.
/// GoRouter watches this provider to decide which route to show.
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class AuthNotifier extends _$AuthNotifier { class AuthNotifier extends _$AuthNotifier {
@override @override
AuthState build() { AuthState build() {
// Kick off session restore immediately; start in [AuthInitial]. // No session restore on web — always require fresh login.
_restoreSession(); return const AuthState.unauthenticated();
return const AuthState.initial();
} }
/// Tries to restore a previous session from secure storage.
Future<void> _restoreSession() async {
state = const AuthState.loading();
final storage = ref.read(secureStorageProvider);
final credentials = await storage.loadCredentials();
if (credentials == null) {
state = const AuthState.unauthenticated();
return;
}
try {
final repo = ref.read(authRepositoryProvider);
// Timeout: client.init() starts the Matrix sync loop and can hang
// indefinitely if the token is expired or the server is unreachable.
// After 12 seconds we give up and send the user to the login screen.
await repo
.restoreSession(
accessToken: credentials.accessToken,
userId: credentials.userId,
deviceId: credentials.deviceId,
)
.timeout(
const Duration(seconds: 12),
onTimeout: () => throw Exception('Session restore timed out'),
);
state = AuthState.authenticated(
userId: credentials.userId,
accessToken: credentials.accessToken,
deviceId: credentials.deviceId,
);
// Resume background persistence — fire-and-forget so it never blocks auth.
try {
ref.read(syncPersistenceServiceProvider).start();
} catch (_) {
// Persistence failure is non-fatal; the app works without it.
}
} on AuthFailure {
// Stored credentials are invalid; force re-login.
await storage.clearCredentials();
state = const AuthState.unauthenticated();
} on Exception {
// Covers: timeout, network offline, Olm init failure.
// Clear stale credentials so the login screen appears cleanly.
await storage.clearCredentials();
state = const AuthState.unauthenticated();
}
}
/// Attempts to log in with [username] and [password].
///
/// Transitions: loading → authenticated | unauthenticated(failure).
Future<void> login({ Future<void> login({
required String username, required String username,
required String password, required String password,
@@ -91,38 +35,42 @@ class AuthNotifier extends _$AuthNotifier {
try { try {
final repo = ref.read(authRepositoryProvider); final repo = ref.read(authRepositoryProvider);
final response = await repo.login(username: username, password: password); debugPrint('[Auth] Logging in...');
final storage = ref.read(secureStorageProvider); final response = await repo.login(
await storage.saveCredentials( username: username,
accessToken: response.accessToken, password: password,
userId: response.userId,
deviceId: response.deviceId,
); );
debugPrint('[Auth] Login OK as ${response.userId} '
'device=${response.deviceId}');
state = AuthState.authenticated( state = AuthState.authenticated(
userId: response.userId, userId: response.userId,
accessToken: response.accessToken, accessToken: response.accessToken,
deviceId: response.deviceId, deviceId: response.deviceId,
); );
// Start background sync-to-database persistence now that we are logged in. // Start background sync-to-database persistence.
ref.read(syncPersistenceServiceProvider).start(); try {
ref.read(syncPersistenceServiceProvider).start();
} catch (_) {}
// Register Web Push subscription (fire-and-forget, non-fatal).
final client = ref.read(matrixClientProvider);
unawaited(setupWebPush(client));
} on AuthFailure catch (failure) { } on AuthFailure catch (failure) {
state = AuthState.unauthenticated(failure: failure.userMessage); state = AuthState.unauthenticated(failure: failure.userMessage);
} }
} }
/// Logs out the current user, clears storage, and resets to unauthenticated.
Future<void> logout() async { Future<void> logout() async {
state = const AuthState.loading(); state = const AuthState.loading();
final repo = ref.read(authRepositoryProvider); final repo = ref.read(authRepositoryProvider);
await repo.logout(); await repo.logout();
final storage = ref.read(secureStorageProvider); // Keep the device ID in storage so the next login reuses it.
await storage.clearCredentials();
state = const AuthState.unauthenticated(); state = const AuthState.unauthenticated();
} }
} }

View File

@@ -1,83 +1,30 @@
// Version: 1.0.0 | Created: 2026-04-01 // Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-04-11
// Typed wrapper around flutter_secure_storage. // Minimal storage: only the Matrix device ID is persisted so that
// All token read/write operations go through this class — never call // encryption keys accumulate on one device across logins.
// flutter_secure_storage directly from feature code. // Access tokens are NOT stored — every browser visit requires login.
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../config/app_config.dart';
part 'secure_storage.g.dart'; part 'secure_storage.g.dart';
/// Provides a configured [SecureStorage] instance.
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
SecureStorage secureStorage(Ref ref) => const SecureStorage(); SecureStorage secureStorage(Ref ref) => const SecureStorage();
/// Typed wrapper around [FlutterSecureStorage].
///
/// Uses AES encryption on Android and Keychain on iOS.
/// On Web, data is stored in localStorage with encryption — acceptable for
/// access tokens but NOT for E2EE private keys (Phase 2 concern).
class SecureStorage { class SecureStorage {
const SecureStorage(); const SecureStorage();
static const _kDeviceId = 'm8chat_device_id';
static const FlutterSecureStorage _storage = FlutterSecureStorage( static const FlutterSecureStorage _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true), aOptions: AndroidOptions(encryptedSharedPreferences: true),
); );
Future<void> saveCredentials({ Future<void> saveDeviceId(String deviceId) async {
required String accessToken, await _storage.write(key: _kDeviceId, value: deviceId);
required String userId,
required String deviceId,
}) async {
await _storage.write(
key: AppConfig.storageKeyAccessToken,
value: accessToken,
);
await _storage.write(key: AppConfig.storageKeyUserId, value: userId);
await _storage.write(key: AppConfig.storageKeyDeviceId, value: deviceId);
await _storage.write(
key: AppConfig.storageKeyHomeserver,
value: AppConfig.matrixBaseUrl,
);
} }
Future<StoredCredentials?> loadCredentials() async { Future<String?> loadDeviceId() async {
final accessToken = await _storage.read( return _storage.read(key: _kDeviceId);
key: AppConfig.storageKeyAccessToken,
);
final userId = await _storage.read(key: AppConfig.storageKeyUserId);
final deviceId = await _storage.read(key: AppConfig.storageKeyDeviceId);
if (accessToken == null || userId == null || deviceId == null) {
return null;
}
return StoredCredentials(
accessToken: accessToken,
userId: userId,
deviceId: deviceId,
);
}
Future<void> clearCredentials() async {
await _storage.delete(key: AppConfig.storageKeyAccessToken);
await _storage.delete(key: AppConfig.storageKeyUserId);
await _storage.delete(key: AppConfig.storageKeyDeviceId);
await _storage.delete(key: AppConfig.storageKeyHomeserver);
} }
} }
/// Holds the credentials retrieved from secure storage.
class StoredCredentials {
const StoredCredentials({
required this.accessToken,
required this.userId,
required this.deviceId,
});
final String accessToken;
final String userId;
final String deviceId;
}

View File

@@ -1,9 +1,6 @@
// Version: 1.0.0 | Created: 2026-04-01 // Version: 2.1.0 | Created: 2026-04-01 | Updated: 2026-07-03
// App-wide constants. Change matrixBaseUrl for different environments. // App-wide constants. Change matrixBaseUrl for different environments.
/// Central configuration for the M8Chat application.
/// All server URLs and app-level constants live here — never scatter
/// magic strings through feature code.
abstract final class AppConfig { abstract final class AppConfig {
static const String matrixBaseUrl = 'https://matrix.m8chat.au'; static const String matrixBaseUrl = 'https://matrix.m8chat.au';
static const String matrixServerName = 'matrix.m8chat.au'; static const String matrixServerName = 'matrix.m8chat.au';
@@ -14,11 +11,8 @@ abstract final class AppConfig {
'turns:matrix.m8chat.au:5349', 'turns:matrix.m8chat.au:5349',
]; ];
static const String appName = 'M8Chat'; static const String appName = 'M8Chat';
static const String appVersion = '1.0.0'; static const String appVersion = '1.11.0';
// Secure storage key names // Jitsi conferencing
static const String storageKeyAccessToken = 'access_token'; static const String jitsiDomain = 'conf.m8chat.au';
static const String storageKeyUserId = 'user_id';
static const String storageKeyDeviceId = 'device_id';
static const String storageKeyHomeserver = 'homeserver';
} }

View File

@@ -0,0 +1,178 @@
// Version: 1.0.0 | Created: 2026-04-27
// Web Push subscription and Matrix pusher registration.
// Only active on web — no-ops on other platforms.
//
// Flow:
// 1. registerPushServiceWorker() — register push_sw.js (called at startup)
// 2. setupWebPush(client) — called after login; requests permission,
// subscribes to Web Push, registers the subscription as a Matrix pusher.
import 'dart:async';
import 'dart:convert';
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:web/web.dart' as web;
const _kVapidPublicKey =
'BPP0AqC7qTxspQn1i0x2sPbPj3owJTTkhV256A67VUK9748NjaBfbMrQBJtI58foVSVpbk2n390NIq_w2glNjs8';
const _kAppId = 'au.m8chat.web.push';
const _kPushGatewayUrl = 'https://matrix.m8chat.au/_matrix/push/v1/notify';
/// Registers push_sw.js at startup so the browser knows the service worker
/// before the user logs in. Idempotent. No-op on non-web platforms.
Future<void> registerPushServiceWorker() async {
if (!kIsWeb) return;
try {
await _registerServiceWorker();
} catch (e) {
debugPrint('[Push] SW pre-registration failed (non-fatal): $e');
}
}
/// Called after login. Requests notification permission, subscribes to Web
/// Push using the VAPID public key, and registers the subscription as a
/// Matrix pusher so Synapse routes notifications through Sygnal → browser.
///
/// Idempotent — safe to call on every login. No-op on non-web platforms.
Future<void> setupWebPush(Client client) async {
if (!kIsWeb) return;
try {
final sw = await _registerServiceWorker();
if (sw == null) {
debugPrint('[Push] SW not available — push skipped');
return;
}
final permission = await _requestPermission();
if (permission != 'granted') {
debugPrint('[Push] Notification permission $permission — skipped');
return;
}
final sub = await _subscribe(sw);
if (sub == null) {
debugPrint('[Push] Could not create push subscription');
return;
}
await _registerPusher(client, sub);
} catch (e) {
debugPrint('[Push] Setup failed (non-fatal): $e');
}
}
// ---------------------------------------------------------------------------
Future<web.ServiceWorkerRegistration?> _registerServiceWorker() async {
if (!_pushSupported()) return null;
try {
final reg = await web.window.navigator.serviceWorker
.register('/push_sw.js'.toJS, web.RegistrationOptions(scope: '/'))
.toDart;
debugPrint('[Push] Service worker registered: ${reg.scope}');
return reg;
} catch (e) {
debugPrint('[Push] Service worker error: $e');
return null;
}
}
Future<String> _requestPermission() async {
try {
final result = await web.Notification.requestPermission().toDart;
return result.toDart;
} catch (_) {
return 'denied';
}
}
Future<web.PushSubscription?> _subscribe(
web.ServiceWorkerRegistration reg) async {
try {
// Re-use existing subscription if the browser already has one.
final existing = await reg.pushManager.getSubscription().toDart;
if (existing != null) {
debugPrint('[Push] Reusing existing push subscription');
return existing;
}
final keyBytes = _vapidKeyToBytes(_kVapidPublicKey);
final sub = await reg.pushManager
.subscribe(web.PushSubscriptionOptionsInit(
userVisibleOnly: true,
applicationServerKey: keyBytes,
))
.toDart;
debugPrint('[Push] New subscription: ${sub.endpoint}');
return sub;
} catch (e) {
debugPrint('[Push] Subscribe error: $e');
return null;
}
}
Future<void> _registerPusher(
Client client, web.PushSubscription sub) async {
final json = sub.toJSON();
final endpoint = json.endpoint;
if (endpoint.isEmpty) return;
// Extract p256dh and auth keys from the subscription.
final keysObj = json.keys;
final p256dh = _jsObjectGet(keysObj, 'p256dh') ?? '';
final auth = _jsObjectGet(keysObj, 'auth') ?? '';
await client.postPusher(
Pusher(
pushkey: endpoint,
kind: 'http',
appId: _kAppId,
appDisplayName: 'M8Chat Web',
deviceDisplayName: 'Web browser',
lang: 'en',
data: PusherData(
url: Uri.parse(_kPushGatewayUrl),
format: 'event_id_only',
additionalProperties: {
'keys': {'p256dh': p256dh, 'auth': auth},
},
),
),
);
debugPrint('[Push] Matrix pusher registered');
}
/// Converts a URL-safe base64 VAPID public key to a JS Uint8Array.
JSUint8Array _vapidKeyToBytes(String base64Url) {
final padded = base64Url +
'=' * ((4 - base64Url.length % 4) % 4);
final standard = padded.replaceAll('-', '+').replaceAll('_', '/');
final bytes = base64Decode(standard);
return bytes.toJS;
}
/// Reads a string property from a JSObject via js_interop.
String? _jsObjectGet(JSObject obj, String key) {
try {
final val = obj.getProperty<JSAny?>(key.toJS);
if (val == null || val.isUndefinedOrNull) return null;
return (val as JSString).toDart;
} catch (_) {
return null;
}
}
bool _pushSupported() {
try {
web.window.navigator.serviceWorker;
return true;
} catch (_) {
return false;
}
}

View File

@@ -1,7 +1,8 @@
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-04-11
// Auth repository: handles all Matrix login/logout API interactions. // Auth repository: handles all Matrix login/logout API interactions.
// Uses the Matrix Dart SDK — no raw HTTP calls for auth. // Uses the Matrix Dart SDK — no raw HTTP calls for auth.
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -34,15 +35,16 @@ class AuthRepository {
required String password, required String password,
}) async { }) async {
try { try {
// Set homeserver directly — avoids network round-trips and version checks
// that checkHomeserver() makes. The setter is public in matrix 0.33.0.
_client.homeserver = Uri.parse(AppConfig.matrixBaseUrl); _client.homeserver = Uri.parse(AppConfig.matrixBaseUrl);
return await _client.login( final response = await _client.login(
LoginType.mLoginPassword, LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: username), identifier: AuthenticationUserIdentifier(user: username),
password: password, password: password,
initialDeviceDisplayName: AppConfig.appName, initialDeviceDisplayName: AppConfig.appName,
); );
debugPrint('[M8Chat] Login complete. encryptionEnabled=${_client.encryptionEnabled}');
debugPrint('[M8Chat] encryption=${_client.encryption}');
return response;
} on MatrixException catch (e) { } on MatrixException catch (e) {
throw switch (e.errcode) { throw switch (e.errcode) {
'M_FORBIDDEN' => const AuthFailure.invalidCredentials(), 'M_FORBIDDEN' => const AuthFailure.invalidCredentials(),
@@ -77,19 +79,5 @@ class AuthRepository {
} }
} }
/// Restores an existing Matrix session using a stored access token. // restoreSession() removed — web app requires fresh login every visit.
Future<void> restoreSession({
required String accessToken,
required String userId,
required String deviceId,
}) async {
await _client.init(
newToken: accessToken,
newUserID: userId,
newDeviceID: deviceId,
newDeviceName: AppConfig.appName,
newHomeserver: Uri.parse(AppConfig.matrixBaseUrl),
newOlmAccount: null,
);
}
} }

View File

@@ -1,4 +1,4 @@
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-03 // Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Login screen. Username + password only. No registration link. // Login screen. Username + password only. No registration link.
// Respects system theme preference. // Respects system theme preference.
@@ -55,7 +55,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
body: SafeArea( body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF0A0F2E), Color(0xFF131A42)],
),
),
child: SafeArea(
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
@@ -89,6 +97,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
), ),
), ),
), ),
),
), ),
); );
} }

View File

@@ -0,0 +1,185 @@
// Version: 1.0.0 | Created: 2026-04-10
// E2EE key exchange for LiveKit calls via Matrix to-device events.
// Implements the m.rtc.encryption_keys protocol for interop with Element X.
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:matrix/matrix.dart' show Client, DeviceKeys, ToDeviceEvent;
/// Event type used by Element X / Element Call for key exchange.
const kEncryptionKeysEventType = 'm.rtc.encryption_keys';
/// Manages E2EE key generation, distribution, and reception for LiveKit calls.
///
/// Flow:
/// 1. [init] creates a per-participant [BaseKeyProvider] and generates a local key.
/// 2. After LiveKit connects, call [setLocalKey] with the local participant identity.
/// 3. Call [sendKeyToParticipants] to share our key via encrypted to-device events.
/// 4. Incoming keys from Element X are received via [Client.onToDeviceEvent] and
/// set on the [BaseKeyProvider] automatically.
class CallE2EEManager {
CallE2EEManager({
required Client client,
required String matrixRoomId,
}) : _client = client,
_matrixRoomId = matrixRoomId;
final Client _client;
final String _matrixRoomId;
BaseKeyProvider? _keyProvider;
StreamSubscription<ToDeviceEvent>? _toDeviceSub;
Uint8List? _localKey;
final int _localKeyIndex = 0;
BaseKeyProvider? get keyProvider => _keyProvider;
/// Create the key provider and start listening for incoming keys.
Future<BaseKeyProvider> init() async {
_keyProvider = await BaseKeyProvider.create(sharedKey: false);
// Generate a random 32-byte encryption key for our media tracks.
_localKey = _secureRandomBytes(32);
// Listen for incoming encryption keys from other call participants.
_toDeviceSub = _client.onToDeviceEvent.stream.listen(_onToDeviceEvent);
debugPrint('[E2EE] Key provider created, local key generated '
'(${base64Encode(_localKey!).substring(0, 8)}…)');
return _keyProvider!;
}
/// Set our local key on the provider using the LiveKit participant identity.
/// Call this AFTER the LiveKit Room is connected and localParticipant is available.
Future<void> setLocalKey(String localIdentity) async {
if (_keyProvider == null || _localKey == null) return;
await _keyProvider!.setRawKey(
_localKey!,
participantId: localIdentity,
keyIndex: _localKeyIndex,
);
debugPrint('[E2EE] Local key set for identity=$localIdentity');
}
/// Send our encryption key to all other call members via encrypted to-device.
Future<void> sendKeyToParticipants() async {
if (_localKey == null) return;
final room = _client.getRoomById(_matrixRoomId);
if (room == null) {
debugPrint('[E2EE] Room $_matrixRoomId not found — cannot send keys');
return;
}
// Find other participants from the MSC3401 call.member state events.
final memberEvents =
room.states['org.matrix.msc3401.call.member'] ?? {};
final content = <String, Object>{
'keys': [
{'index': _localKeyIndex, 'key': base64Encode(_localKey!)},
],
'call_id': '',
'device_id': _client.deviceID ?? '',
'room_id': _matrixRoomId,
};
for (final entry in memberEvents.entries) {
final userId = entry.key;
if (userId == _client.userID) continue;
// Parse device_id from the call.member event to target the correct device.
final memberships =
entry.value.content['memberships'] as List<dynamic>? ?? [];
if (memberships.isEmpty) continue;
await _client.userDeviceKeysLoading;
final deviceKeyMap = _client.userDeviceKeys[userId]?.deviceKeys;
if (deviceKeyMap == null || deviceKeyMap.isEmpty) {
debugPrint('[E2EE] No device keys for $userId — skipping');
continue;
}
// Send to all devices of this user that are in the call.
final List<DeviceKeys> targets = [];
for (final m in memberships) {
final deviceId = m['device_id'] as String?;
if (deviceId != null && deviceKeyMap.containsKey(deviceId)) {
targets.add(deviceKeyMap[deviceId]!);
}
}
// Fallback: if no specific device matched, send to all devices.
if (targets.isEmpty) {
targets.addAll(deviceKeyMap.values);
}
try {
await _client.sendToDeviceEncrypted(
targets,
kEncryptionKeysEventType,
content,
);
debugPrint(
'[E2EE] Sent key to $userId (${targets.length} device(s))');
} catch (e) {
debugPrint('[E2EE] Failed to send key to $userId: $e');
}
}
}
/// Handle incoming to-device encryption key events from other participants.
void _onToDeviceEvent(ToDeviceEvent event) {
if (event.type != kEncryptionKeysEventType) return;
// Only process keys for our active call room.
final eventRoomId = event.content['room_id'] as String?;
if (eventRoomId != null && eventRoomId != _matrixRoomId) return;
final keys = event.content['keys'] as List<dynamic>?;
if (keys == null || keys.isEmpty) {
debugPrint(
'[E2EE] Received $kEncryptionKeysEventType with empty keys '
'from ${event.sender}');
return;
}
final senderId = event.sender;
debugPrint('[E2EE] Received encryption key(s) from $senderId');
for (final keyEntry in keys) {
final index = (keyEntry['index'] as num?)?.toInt() ?? 0;
final keyB64 = keyEntry['key'] as String?;
if (keyB64 == null) continue;
final keyBytes = base64Decode(keyB64);
// The participant identity in LiveKit should match the Matrix user ID
// (set by lk-jwt-service). Log both for debugging.
_keyProvider?.setRawKey(
keyBytes,
participantId: senderId,
keyIndex: index,
);
debugPrint(
'[E2EE] Key set for participant=$senderId index=$index '
'(${keyB64.substring(0, 8)}…)');
}
}
Future<void> dispose() async {
await _toDeviceSub?.cancel();
_toDeviceSub = null;
_localKey = null;
_keyProvider = null;
debugPrint('[E2EE] Disposed');
}
static Uint8List _secureRandomBytes(int length) {
final rng = Random.secure();
return Uint8List.fromList(
List<int>.generate(length, (_) => rng.nextInt(256)));
}
}

View File

@@ -1,10 +1,10 @@
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-03 // Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-04-10
// LiveKitService — fetches a JWT from the Matrix server's /_matrix/livekit/jwt // LiveKitService — fetches a JWT from the Matrix server's /_matrix/livekit/jwt
// endpoint, then connects a LiveKit Room using that token. // endpoint, then connects a LiveKit Room using that token.
// //
// The JWT endpoint is defined in AppConfig.livekitJwtUrl and uses the Matrix // E2EE: Frame encryption is enabled via livekit_client's E2EEOptions.
// access token as Bearer auth. The LiveKit server URL is the same host as the // Encryption keys are exchanged with Element X via m.rtc.encryption_keys
// Matrix server (as configured on chat.m8chat.au). // to-device events (see call_e2ee.dart).
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
@@ -19,6 +19,7 @@ import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/auth_state.dart'; import '../../../core/auth/auth_state.dart';
import '../../../core/config/app_config.dart'; import '../../../core/config/app_config.dart';
import '../../../core/network/matrix_client.dart'; import '../../../core/network/matrix_client.dart';
import 'call_e2ee.dart';
part 'livekit_service.g.dart'; part 'livekit_service.g.dart';
@@ -67,6 +68,7 @@ class LiveKitService {
Room? _activeRoom; Room? _activeRoom;
String? _activeMatrixRoomId; String? _activeMatrixRoomId;
String? _membershipId; String? _membershipId;
CallE2EEManager? _e2eeManager;
Room? get activeRoom => _activeRoom; Room? get activeRoom => _activeRoom;
@@ -111,14 +113,37 @@ class LiveKitService {
_activeMatrixRoomId = matrixRoomId; _activeMatrixRoomId = matrixRoomId;
debugPrint('[LiveKit] Call member state event sent'); debugPrint('[LiveKit] Call member state event sent');
// Step 3 — connect to LiveKit // Step 3 — initialise E2EE key exchange
final room = Room(); _e2eeManager = CallE2EEManager(
client: client,
matrixRoomId: matrixRoomId,
);
final keyProvider = await _e2eeManager!.init();
// Step 4 — connect to LiveKit with frame encryption
// COOP/COEP headers on the server enable SharedArrayBuffer for the
// E2EE web worker. Without them the worker crashes.
final room = Room(
roomOptions: RoomOptions(
e2eeOptions: E2EEOptions(keyProvider: keyProvider),
),
);
try { try {
await room.connect(livekitUrl, jwt); await room.connect(livekitUrl, jwt);
_activeRoom = room; _activeRoom = room;
// Step 5 — set our local key and share with other participants
final localIdentity = room.localParticipant?.identity;
debugPrint('[LiveKit] Connected, local identity: $localIdentity');
if (localIdentity != null) {
await _e2eeManager!.setLocalKey(localIdentity);
}
await _e2eeManager!.sendKeyToParticipants();
return LiveKitConnected(room: room); return LiveKitConnected(room: room);
} on Exception catch (e) { } on Exception catch (e) {
// Clean up the state event on failure await _e2eeManager?.dispose();
_e2eeManager = null;
await _clearCallMemberEvent(client, matrixRoomId, userId); await _clearCallMemberEvent(client, matrixRoomId, userId);
await room.disconnect(); await room.disconnect();
await room.dispose(); await room.dispose();
@@ -136,6 +161,10 @@ class LiveKitService {
_activeMatrixRoomId = null; _activeMatrixRoomId = null;
_membershipId = null; _membershipId = null;
// Clean up E2EE key exchange
await _e2eeManager?.dispose();
_e2eeManager = null;
// Step 1: Leave LiveKit — Element X will see us disappear from the SFU // Step 1: Leave LiveKit — Element X will see us disappear from the SFU
if (room != null) { if (room != null) {
await room.disconnect(); await room.disconnect();
@@ -255,6 +284,7 @@ class LiveKitService {
} }
/// Clear the call.member state event (remove our membership). /// Clear the call.member state event (remove our membership).
/// Element X expects empty content `{}` — not `{'memberships': []}`.
Future<void> _clearCallMemberEvent( Future<void> _clearCallMemberEvent(
Client client, Client client,
String matrixRoomId, String matrixRoomId,
@@ -265,8 +295,9 @@ class LiveKitService {
matrixRoomId, matrixRoomId,
_kCallMemberEventType, _kCallMemberEventType,
userId, userId,
{'memberships': []}, {},
); );
debugPrint('[LiveKit] Call member event cleared for $userId');
} catch (e) { } catch (e) {
debugPrint('[LiveKit] Failed to clear call member event: $e'); debugPrint('[LiveKit] Failed to clear call member event: $e');
} }

View File

@@ -1,6 +1,9 @@
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-07-04
// MatrixRTC repository — handles outgoing call invites and detects incoming // MatrixRTC repository — handles outgoing call invites and detects incoming
// calls via m.call.invite events per MSC4143 (MatrixRTC spec). // calls. v1.3.0 adds MSC3401 call.member state-event detection: Element X
// (and app2 itself) signals group calls with org.matrix.msc3401.call.member,
// not legacy m.call.invite, so incoming calls from Element X were invisible.
// Both paths now guard against stale events replayed during initial sync.
// //
// Incoming calls are surfaced via the incomingCallStream so the // Incoming calls are surfaced via the incomingCallStream so the
// IncomingCallOverlay can react to them. // IncomingCallOverlay can react to them.
@@ -79,13 +82,39 @@ class MatrixRtcRepository {
}, type: EventTypes.CallInvite); }, type: EventTypes.CallInvite);
} }
void _onEvent(EventUpdate update) { /// MSC3401 MatrixRTC call membership state event (what Element X sends).
if (update.type != EventUpdateType.timeline) return; static const _kCallMemberEventType = 'org.matrix.msc3401.call.member';
if (update.content['type'] != EventTypes.CallInvite) return;
/// Events older than this are sync replays, not live calls.
static const _maxEventAge = Duration(seconds: 90);
/// Membership keys we have already alerted on (roomId|sender|membership).
final Set<String> _seenMembershipKeys = {};
void _onEvent(EventUpdate update) {
final eventType = update.content['type'] as String?;
if (eventType == EventTypes.CallInvite &&
update.type == EventUpdateType.timeline) {
_onCallInvite(update);
} else if (eventType == _kCallMemberEventType &&
(update.type == EventUpdateType.timeline ||
update.type == EventUpdateType.state)) {
_onCallMember(update);
}
}
bool _isStale(EventUpdate update) {
final ts = update.content['origin_server_ts'] as int?;
if (ts == null) return true;
final age = DateTime.now().millisecondsSinceEpoch - ts;
return age > _maxEventAge.inMilliseconds;
}
/// Legacy 1:1 call invites (app2 → app2).
void _onCallInvite(EventUpdate update) {
final senderId = update.content['sender'] as String?; final senderId = update.content['sender'] as String?;
// Ignore our own invites. // Ignore our own invites and stale events replayed on initial sync.
if (senderId == _myUserId) return; if (senderId == _myUserId || _isStale(update)) return;
final roomId = update.roomID; final roomId = update.roomID;
final content = update.content['content'] as Map<String, dynamic>?; final content = update.content['content'] as Map<String, dynamic>?;
@@ -93,21 +122,66 @@ class MatrixRtcRepository {
final callId = content['call_id'] as String?; final callId = content['call_id'] as String?;
if (callId == null) return; if (callId == null) return;
if (!_seenMembershipKeys.add('$roomId|$senderId|$callId')) return;
final room = _client.getRoomById(roomId); _emitIncomingCall(
final senderProfile = room?.unsafeGetUserFromMemoryOrFallback( roomId: roomId,
senderId ?? '', senderId: senderId ?? '',
callId: callId,
isVideo: content['offer'] != null,
); );
}
/// MSC3401 group-call membership: someone (e.g. Element X) joined a call.
void _onCallMember(EventUpdate update) {
final senderId = update.content['sender'] as String?;
if (senderId == null || senderId == _myUserId || _isStale(update)) return;
final content = update.content['content'] as Map<String, dynamic>?;
final memberships = content?['memberships'] as List<dynamic>?;
// Empty/cleared memberships means the caller hung up — nothing to show.
if (memberships == null || memberships.isEmpty) return;
final membership = memberships.first as Map<String, dynamic>;
if (membership['application'] != 'm.call') return;
final expiresTs = membership['expires_ts'] as int?;
if (expiresTs != null &&
expiresTs < DateTime.now().millisecondsSinceEpoch) {
return;
}
final membershipId =
(membership['membershipID'] ?? membership['call_id'] ?? '').toString();
final roomId = update.roomID;
if (!_seenMembershipKeys.add('$roomId|$senderId|$membershipId')) return;
_emitIncomingCall(
roomId: roomId,
senderId: senderId,
callId: membershipId.isEmpty ? 'matrixrtc' : membershipId,
isVideo: true,
);
}
void _emitIncomingCall({
required String roomId,
required String senderId,
required String callId,
required bool isVideo,
}) {
final room = _client.getRoomById(roomId);
final senderProfile = room?.unsafeGetUserFromMemoryOrFallback(senderId);
_incomingCallController.add( _incomingCallController.add(
IncomingCall( IncomingCall(
callId: callId, callId: callId,
roomId: roomId, roomId: roomId,
callerId: senderId ?? '', callerId: senderId,
callerDisplayName: callerDisplayName:
senderProfile?.displayName ?? senderId?.matrixLocalpart ?? '', senderProfile?.displayName ?? senderId.matrixLocalpart,
callerAvatarUrl: resolveMxcUrl(_client, senderProfile?.avatarUrl), callerAvatarUrl: resolveMxcUrl(_client, senderProfile?.avatarUrl),
isVideo: (content['offer'] != null), isVideo: isVideo,
), ),
); );
} }

View File

@@ -1,10 +1,10 @@
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-07-04
// IncomingCallOverlay — full-screen overlay shown when an m.call.invite // IncomingCallOverlay — full-screen overlay shown when an m.call.invite
// arrives. Displays caller name/avatar, and Accept / Decline buttons. // arrives. Displays caller name/avatar, and Accept / Decline buttons.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import '../../../app/router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../shared/widgets/matrix_avatar.dart'; import '../../../shared/widgets/matrix_avatar.dart';
@@ -58,20 +58,27 @@ class IncomingCallOverlayHost extends ConsumerWidget {
} }
} }
class _IncomingCallOverlay extends StatefulWidget { class _IncomingCallOverlay extends ConsumerStatefulWidget {
const _IncomingCallOverlay({required this.call}); const _IncomingCallOverlay({required this.call});
final IncomingCall call; final IncomingCall call;
@override @override
State<_IncomingCallOverlay> createState() => _IncomingCallOverlayState(); ConsumerState<_IncomingCallOverlay> createState() =>
_IncomingCallOverlayState();
} }
class _IncomingCallOverlayState extends State<_IncomingCallOverlay> { class _IncomingCallOverlayState extends ConsumerState<_IncomingCallOverlay> {
bool _dismissed = false; bool _dismissed = false;
String? _dismissedForCallId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// A new call resets the dismissal so the overlay can show again.
if (_dismissedForCallId != widget.call.callId) {
_dismissed = false;
_dismissedForCallId = widget.call.callId;
}
if (_dismissed) return const SizedBox.shrink(); if (_dismissed) return const SizedBox.shrink();
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -129,9 +136,13 @@ class _IncomingCallOverlayState extends State<_IncomingCallOverlay> {
label: 'Accept', label: 'Accept',
colour: Colors.green, colour: Colors.green,
onTap: () { onTap: () {
context.push( // The overlay lives above the router's navigator, so
'/calls/${Uri.encodeComponent(call.roomId)}', // context.push would not find GoRouter — use the
); // router instance directly, then dismiss the overlay.
setState(() => _dismissed = true);
ref
.read(routerProvider)
.push('/calls/${Uri.encodeComponent(call.roomId)}');
}, },
), ),
], ],

View File

@@ -1,6 +1,12 @@
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Chat repository — bridges Matrix SDK timeline to app domain models. // Chat repository — bridges Matrix SDK timeline to app domain models.
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support. // Phase 2 additions: sendFile, sendReaction, redactEvent, reply support.
// v1.5.0: keep one live Timeline per room so scroll-back history pagination
// (requestHistory) acts on the same instance the stream is watching, and
// re-yield on the timeline's own onUpdate (fires for both live events and
// history) rather than only on onSync (which does not fire for pagination).
import 'dart:async';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -22,6 +28,10 @@ class ChatRepository {
final Client _client; final Client _client;
/// Live timelines keyed by room id, so [loadMoreMessages] paginates the
/// same instance the active [watchTimeline] stream is mapping.
final Map<String, Timeline> _timelines = {};
Room? _getRoom(String roomId) => _client.getRoomById(roomId); Room? _getRoom(String roomId) => _client.getRoomById(roomId);
/// Returns a stream of message lists for [roomId]. /// Returns a stream of message lists for [roomId].
@@ -29,20 +39,32 @@ class ChatRepository {
final room = _getRoom(roomId); final room = _getRoom(roomId);
if (room == null) return; if (room == null) return;
final timeline = await room.getTimeline(); final signal = StreamController<void>();
final timeline = await room.getTimeline(
onUpdate: () {
if (!signal.isClosed) signal.add(null);
},
);
_timelines[roomId] = timeline;
yield await _mapTimeline(timeline, room); try {
yield await _mapTimeline(timeline, room);
await for (final update in _client.onSync.stream) { await for (final _ in signal.stream) {
final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false;
if (updatesThisRoom) {
yield await _mapTimeline(timeline, room); yield await _mapTimeline(timeline, room);
} }
} finally {
timeline.cancelSubscriptions();
if (identical(_timelines[roomId], timeline)) {
_timelines.remove(roomId);
}
await signal.close();
} }
timeline.cancelSubscriptions();
} }
/// Whether [roomId] has older messages that can still be paginated in.
bool canLoadMore(String roomId) =>
_timelines[roomId]?.canRequestHistory ?? false;
/// Sends a plain text message. Supports replies via [inReplyToEventId]. /// Sends a plain text message. Supports replies via [inReplyToEventId].
Future<void> sendTextMessage( Future<void> sendTextMessage(
String roomId, String roomId,
@@ -73,6 +95,17 @@ class ChatRepository {
await room.sendFileEvent(file); await room.sendFileEvent(file);
} }
/// Replaces the body of an existing text message (m.replace edit).
Future<void> editMessage(
String roomId,
String eventId,
String newText,
) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.sendTextEvent(newText, editEventId: eventId);
}
/// Sends an emoji reaction to [eventId]. /// Sends an emoji reaction to [eventId].
Future<void> sendReaction(String roomId, String eventId, String emoji) async { Future<void> sendReaction(String roomId, String eventId, String emoji) async {
final room = _getRoom(roomId); final room = _getRoom(roomId);
@@ -96,11 +129,12 @@ class ChatRepository {
await room.setReadMarker(lastEventId, mRead: lastEventId); await room.setReadMarker(lastEventId, mRead: lastEventId);
} }
/// Requests older messages (pagination). /// Requests older messages (pagination) on the active timeline for [roomId].
/// No-op if there is no live timeline or no more history to fetch.
Future<void> loadMoreMessages(String roomId) async { Future<void> loadMoreMessages(String roomId) async {
final room = _getRoom(roomId); final timeline = _timelines[roomId];
if (room == null) return; if (timeline == null || !timeline.canRequestHistory) return;
await room.requestHistory(); await timeline.requestHistory();
} }
/// Event types that should never appear in the chat timeline. /// Event types that should never appear in the chat timeline.
@@ -191,6 +225,7 @@ class ChatRepository {
MessageType _messageType(Event event) { MessageType _messageType(Event event) {
if (event.redacted) return MessageType.redacted; if (event.redacted) return MessageType.redacted;
if (event.type == EventTypes.Encrypted) return MessageType.encrypted;
return switch (event.messageType) { return switch (event.messageType) {
MessageTypes.Text => MessageType.text, MessageTypes.Text => MessageType.text,

View File

@@ -1,4 +1,4 @@
// Version: 1.0.0 | Created: 2026-04-01 // Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-10
// Immutable message model for the chat timeline. // Immutable message model for the chat timeline.
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
@@ -14,6 +14,7 @@ enum MessageType {
video, video,
sticker, sticker,
redacted, redacted,
encrypted,
unsupported, unsupported,
} }

View File

@@ -1,4 +1,4 @@
// Version: 1.1.0 | Created: 2026-04-01 // Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Riverpod providers for chat timeline, send, upload, react, reply. // Riverpod providers for chat timeline, send, upload, react, reply.
import 'package:matrix/matrix.dart' show MatrixFile; import 'package:matrix/matrix.dart' show MatrixFile;
@@ -84,6 +84,27 @@ class SendReaction extends _$SendReaction {
} }
} }
/// Edits an existing text message.
@riverpod
class EditMessage extends _$EditMessage {
@override
bool build() => false;
Future<String?> edit(String roomId, String eventId, String newText) async {
state = true;
try {
await ref
.read(chatRepositoryProvider)
.editMessage(roomId, eventId, newText);
return null;
} on Exception catch (e) {
return e.toString();
} finally {
state = false;
}
}
}
/// Deletes (redacts) a message by eventId. /// Deletes (redacts) a message by eventId.
@riverpod @riverpod
class DeleteMessage extends _$DeleteMessage { class DeleteMessage extends _$DeleteMessage {

View File

@@ -1,18 +1,23 @@
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Version: 1.7.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Full chat screen — timeline + input + typing indicators + read receipts // Full chat screen — timeline + input + typing indicators + read receipts
// + long-press context menu (reply, react, copy, delete). // + long-press context menu (quick reactions, reply, react, copy, edit,
// delete) + day separators + same-sender message grouping + room options.
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' show MatrixFile; import 'package:intl/intl.dart';
import 'package:matrix/matrix.dart' show MatrixFile, Room;
import '../../../app/theme.dart';
import '../../../core/network/matrix_client.dart'; import '../../../core/network/matrix_client.dart';
import '../../../shared/utils/matrix_id.dart'; import '../../../shared/utils/matrix_id.dart';
import '../../../shared/utils/mxc_url.dart'; import '../../../shared/utils/mxc_url.dart';
import '../../../shared/widgets/matrix_avatar.dart'; import '../../../shared/widgets/matrix_avatar.dart';
import '../../rooms/presentation/user_search_dialog.dart';
import '../data/chat_repository.dart';
import '../domain/message_model.dart'; import '../domain/message_model.dart';
import 'chat_controller.dart'; import 'chat_controller.dart';
import 'message_bubble.dart'; import 'message_bubble.dart';
@@ -34,6 +39,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
String get _decodedRoomId => Uri.decodeComponent(widget.roomId); String get _decodedRoomId => Uri.decodeComponent(widget.roomId);
void _showRoomOptions(BuildContext context, Room room) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _RoomOptionsSheet(room: room),
);
}
void _setReply(MessageModel message) { void _setReply(MessageModel message) {
setState(() { setState(() {
_replyToEventId = message.eventId; _replyToEventId = message.eventId;
@@ -87,7 +100,9 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
IconButton( IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
tooltip: 'Room options', tooltip: 'Room options',
onPressed: () {}, onPressed: room != null
? () => _showRoomOptions(context, room)
: null,
), ),
], ],
), ),
@@ -114,15 +129,82 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
// Timeline // Timeline
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class _Timeline extends ConsumerWidget { class _Timeline extends ConsumerStatefulWidget {
const _Timeline({required this.roomId, required this.onReply}); const _Timeline({required this.roomId, required this.onReply});
final String roomId; final String roomId;
final void Function(MessageModel) onReply; final void Function(MessageModel) onReply;
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<_Timeline> createState() => _TimelineState();
final timelineAsync = ref.watch(chatTimelineProvider(roomId)); }
class _TimelineState extends ConsumerState<_Timeline> {
final _scrollController = ScrollController();
/// The list is reverse:true, so the newest message sits at offset 0.
/// Show the jump-to-latest button once the user scrolls up past this.
static const _showButtonThreshold = 400.0;
/// How close to the oldest (top of a reverse list) before we prefetch more.
static const _loadMoreThreshold = 400.0;
bool _showScrollToLatest = false;
bool _loadingMore = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final pos = _scrollController.position;
final show = pos.pixels > _showButtonThreshold;
if (show != _showScrollToLatest) {
setState(() => _showScrollToLatest = show);
}
// Near the oldest end (top of a reverse list) → load older history.
if (pos.hasContentDimensions &&
pos.pixels >= pos.maxScrollExtent - _loadMoreThreshold) {
_loadOlder();
}
}
Future<void> _loadOlder() async {
if (_loadingMore) return;
final repo = ref.read(chatRepositoryProvider);
if (!repo.canLoadMore(widget.roomId)) return;
setState(() => _loadingMore = true);
try {
await repo.loadMoreMessages(widget.roomId);
} finally {
if (mounted) setState(() => _loadingMore = false);
}
}
void _scrollToLatest() {
if (!_scrollController.hasClients) return;
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
@override
Widget build(BuildContext context) {
final timelineAsync = ref.watch(chatTimelineProvider(widget.roomId));
return timelineAsync.when( return timelineAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
@@ -144,24 +226,138 @@ class _Timeline extends ConsumerWidget {
); );
} }
return ListView.builder( return Stack(
reverse: true, children: [
padding: const EdgeInsets.symmetric(vertical: 8), ListView.builder(
itemCount: messages.length, controller: _scrollController,
itemBuilder: (context, index) { reverse: true,
final message = messages[index]; padding: const EdgeInsets.symmetric(vertical: 8),
return _MessageWithGestures( itemCount: messages.length,
message: message, itemBuilder: (context, index) {
roomId: roomId, final message = messages[index];
onReply: () => onReply(message), // List is reverse:true, so the visually-older neighbour is at
); // index+1 and the visually-newer neighbour is at index-1.
}, final older = index < messages.length - 1
? messages[index + 1]
: null;
final newer = index > 0 ? messages[index - 1] : null;
const groupGap = Duration(minutes: 5);
final isFirstInGroup =
older == null ||
older.senderId != message.senderId ||
message.timestamp.difference(older.timestamp) > groupGap;
final isLastInGroup =
newer == null ||
newer.senderId != message.senderId ||
newer.timestamp.difference(message.timestamp) > groupGap;
// A day chip sits above the first message of each local day.
final showDay =
older == null ||
!_sameLocalDay(message.timestamp, older.timestamp);
final bubble = _MessageWithGestures(
message: message,
roomId: widget.roomId,
onReply: () => widget.onReply(message),
isFirstInGroup: isFirstInGroup,
isLastInGroup: isLastInGroup,
);
if (!showDay) return bubble;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DayChip(timestamp: message.timestamp),
bubble,
],
);
},
),
// Loading older history (top of a reverse list).
if (_loadingMore)
const Positioned(
top: 8,
left: 0,
right: 0,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
Positioned(
right: 12,
bottom: 12,
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
scale: _showScrollToLatest ? 1 : 0,
child: FloatingActionButton.small(
heroTag: 'scrollToLatest',
onPressed: _scrollToLatest,
tooltip: 'Jump to latest',
child: const Icon(Icons.arrow_downward),
),
),
),
],
); );
}, },
); );
} }
} }
/// True when two timestamps fall on the same day in the local timezone.
bool _sameLocalDay(DateTime a, DateTime b) {
final la = a.toLocal();
final lb = b.toLocal();
return la.year == lb.year && la.month == lb.month && la.day == lb.day;
}
/// Centred date pill shown above the first message of each local day.
class _DayChip extends StatelessWidget {
const _DayChip({required this.timestamp});
final DateTime timestamp;
String _label() {
final now = DateTime.now().toLocal();
final local = timestamp.toLocal();
final today = DateTime(now.year, now.month, now.day);
final that = DateTime(local.year, local.month, local.day);
final diff = today.difference(that).inDays;
if (diff == 0) return 'Today';
if (diff == 1) return 'Yesterday';
if (local.year == now.year) return DateFormat('d MMM').format(local);
return DateFormat('d MMM yyyy').format(local);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_label(),
style: theme.textTheme.labelSmall?.copyWith(
color: M8Colours.subtleText,
fontWeight: FontWeight.w500,
),
),
),
);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Long-press context menu // Long-press context menu
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -171,17 +367,25 @@ class _MessageWithGestures extends ConsumerWidget {
required this.message, required this.message,
required this.roomId, required this.roomId,
required this.onReply, required this.onReply,
this.isFirstInGroup = true,
this.isLastInGroup = true,
}); });
final MessageModel message; final MessageModel message;
final String roomId; final String roomId;
final VoidCallback onReply; final VoidCallback onReply;
final bool isFirstInGroup;
final bool isLastInGroup;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return GestureDetector( return GestureDetector(
onLongPress: () => _showContextMenu(context, ref), onLongPress: () => _showContextMenu(context, ref),
child: MessageBubble(message: message), child: MessageBubble(
message: message,
isFirstInGroup: isFirstInGroup,
isLastInGroup: isLastInGroup,
),
); );
} }
@@ -191,6 +395,12 @@ class _MessageWithGestures extends ConsumerWidget {
builder: (_) => _MessageContextMenu( builder: (_) => _MessageContextMenu(
message: message, message: message,
roomId: roomId, roomId: roomId,
onQuickReact: (emoji) {
Navigator.pop(context);
ref
.read(sendReactionProvider.notifier)
.react(roomId, message.eventId, emoji);
},
onReply: () { onReply: () {
Navigator.pop(context); Navigator.pop(context);
onReply(); onReply();
@@ -206,6 +416,12 @@ class _MessageWithGestures extends ConsumerWidget {
context, context,
).showSnackBar(const SnackBar(content: Text('Message copied.'))); ).showSnackBar(const SnackBar(content: Text('Message copied.')));
}, },
onEdit: message.isMine && message.body != null
? () {
Navigator.pop(context);
_showEditDialog(context, ref);
}
: null,
onDelete: message.isMine onDelete: message.isMine
? () async { ? () async {
Navigator.pop(context); Navigator.pop(context);
@@ -218,6 +434,44 @@ class _MessageWithGestures extends ConsumerWidget {
); );
} }
void _showEditDialog(BuildContext context, WidgetRef ref) {
final controller = TextEditingController(text: message.body ?? '');
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Edit message'),
content: TextField(
controller: controller,
autofocus: true,
maxLines: null,
decoration: const InputDecoration(border: OutlineInputBorder()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () async {
final newText = controller.text.trim();
Navigator.of(ctx).pop();
if (newText.isEmpty || newText == message.body) return;
final error = await ref
.read(editMessageProvider.notifier)
.edit(roomId, message.eventId, newText);
if (error != null && context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Edit failed: $error')));
}
},
child: const Text('Save'),
),
],
),
);
}
void _showEmojiPicker(BuildContext context, WidgetRef ref) { void _showEmojiPicker(BuildContext context, WidgetRef ref) {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
@@ -239,21 +493,203 @@ class _MessageWithGestures extends ConsumerWidget {
} }
} }
// ---------------------------------------------------------------------------
// Room options sheet
// ---------------------------------------------------------------------------
class _RoomOptionsSheet extends ConsumerStatefulWidget {
const _RoomOptionsSheet({required this.room});
final Room room;
@override
ConsumerState<_RoomOptionsSheet> createState() => _RoomOptionsSheetState();
}
class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
bool _leaving = false;
Future<void> _leaveRoom() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Leave room?'),
content: Text(
'You will leave "${widget.room.getLocalizedDisplayname()}". '
'You can rejoin if you have an invite.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Leave'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _leaving = true);
try {
await widget.room.leave();
if (mounted) {
Navigator.of(context).pop(); // close sheet
GoRouter.of(context).go('/rooms');
}
} on Exception catch (e) {
if (mounted) {
setState(() => _leaving = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Could not leave room: $e')));
}
}
}
@override
Widget build(BuildContext context) {
final client = ref.watch(matrixClientProvider);
final theme = Theme.of(context);
final room = widget.room;
final members = room.getParticipants();
final canInvite = room.canInvite;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 16, 4),
child: Row(
children: [
Expanded(
child: Text(
room.getLocalizedDisplayname(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Members section
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
child: Text(
'Members (${members.length})',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
final name = member.displayName ?? member.id.matrixLocalpart;
final avatarUrl = resolveMxcUrl(client, member.avatarUrl);
final isMe = member.id == client.userID;
return ListTile(
dense: true,
leading: MatrixAvatar(
name: name,
avatarUrl: avatarUrl,
radius: 16,
),
title: Text(
isMe ? '$name (you)' : name,
style: theme.textTheme.bodyMedium,
),
subtitle: Text(
member.id,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
);
},
),
),
const Divider(),
// Actions
if (canInvite)
ListTile(
leading: const Icon(Icons.person_add_outlined),
title: const Text('Invite someone'),
onTap: () {
Navigator.of(context).pop();
showUserSearchDialog(context);
},
),
ListTile(
leading: _leaving
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(Icons.exit_to_app, color: theme.colorScheme.error),
title: Text(
'Leave room',
style: TextStyle(color: theme.colorScheme.error),
),
enabled: !_leaving,
onTap: _leaveRoom,
),
],
),
),
);
}
}
class _MessageContextMenu extends StatelessWidget { class _MessageContextMenu extends StatelessWidget {
const _MessageContextMenu({ const _MessageContextMenu({
required this.message, required this.message,
required this.roomId, required this.roomId,
required this.onQuickReact,
required this.onReply, required this.onReply,
required this.onReact, required this.onReact,
required this.onCopy, required this.onCopy,
this.onEdit,
this.onDelete, this.onDelete,
}); });
/// Six one-tap presets shown across the top of the menu.
static const _quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
final MessageModel message; final MessageModel message;
final String roomId; final String roomId;
final void Function(String emoji) onQuickReact;
final VoidCallback onReply; final VoidCallback onReply;
final VoidCallback onReact; final VoidCallback onReact;
final VoidCallback onCopy; final VoidCallback onCopy;
final VoidCallback? onEdit;
final VoidCallback? onDelete; final VoidCallback? onDelete;
@override @override
@@ -262,6 +698,43 @@ class _MessageContextMenu extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Quick-reaction row: one-tap presets + open the full picker.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (final emoji in _quickReactions)
InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () => onQuickReact(emoji),
child: SizedBox(
width: 44,
height: 44,
child: Center(
child: Text(
emoji,
style: const TextStyle(fontSize: 24),
),
),
),
),
InkWell(
borderRadius: BorderRadius.circular(22),
onTap: onReact,
child: SizedBox(
width: 44,
height: 44,
child: Icon(
Icons.add,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
],
),
),
const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.reply), leading: const Icon(Icons.reply),
title: const Text('Reply'), title: const Text('Reply'),
@@ -278,6 +751,12 @@ class _MessageContextMenu extends StatelessWidget {
title: const Text('Copy text'), title: const Text('Copy text'),
onTap: onCopy, onTap: onCopy,
), ),
if (onEdit != null)
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Edit'),
onTap: onEdit,
),
if (onDelete != null) if (onDelete != null)
ListTile( ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red), leading: const Icon(Icons.delete_outline, color: Colors.red),

View File

@@ -1,18 +1,30 @@
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Message bubble widget. Handles text, images, files, redacted, replies. // Message bubble widget. Handles text, images, files, redacted, replies.
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../app/theme.dart';
import '../../../shared/widgets/matrix_avatar.dart'; import '../../../shared/widgets/matrix_avatar.dart';
import '../domain/message_model.dart'; import '../domain/message_model.dart';
class MessageBubble extends StatelessWidget { class MessageBubble extends StatelessWidget {
const MessageBubble({super.key, required this.message}); const MessageBubble({
super.key,
required this.message,
this.isFirstInGroup = true,
this.isLastInGroup = true,
});
final MessageModel message; final MessageModel message;
/// First message of a same-sender run: show the sender name, add top space.
final bool isFirstInGroup;
/// Last message of a same-sender run: show the avatar, keep the tail corner.
final bool isLastInGroup;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -20,8 +32,8 @@ class MessageBubble extends StatelessWidget {
return Padding( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: 2, top: isFirstInGroup ? 8 : 1,
bottom: 2, bottom: isLastInGroup ? 2 : 1,
left: isMine ? 48 : 8, left: isMine ? 48 : 8,
right: isMine ? 8 : 48, right: isMine ? 8 : 48,
), ),
@@ -32,11 +44,16 @@ class MessageBubble extends StatelessWidget {
: MainAxisAlignment.start, : MainAxisAlignment.start,
children: [ children: [
if (!isMine) ...[ if (!isMine) ...[
MatrixAvatar( // Only the last message of a run carries the avatar; earlier ones
name: message.senderDisplayName, // hold the gutter so the column stays aligned.
avatarUrl: message.senderAvatarUrl, if (isLastInGroup)
radius: 16, MatrixAvatar(
), name: message.senderDisplayName,
avatarUrl: message.senderAvatarUrl,
radius: 16,
)
else
const SizedBox(width: 32),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
Flexible( Flexible(
@@ -45,7 +62,7 @@ class MessageBubble extends StatelessWidget {
? CrossAxisAlignment.end ? CrossAxisAlignment.end
: CrossAxisAlignment.start, : CrossAxisAlignment.start,
children: [ children: [
if (!isMine) if (!isMine && isFirstInGroup)
Padding( Padding(
padding: const EdgeInsets.only(left: 4, bottom: 2), padding: const EdgeInsets.only(left: 4, bottom: 2),
child: Text( child: Text(
@@ -56,7 +73,11 @@ class MessageBubble extends StatelessWidget {
), ),
), ),
), ),
_BubbleContent(message: message, isMine: isMine), _BubbleContent(
message: message,
isMine: isMine,
isLastInGroup: isLastInGroup,
),
if (message.reactions.isNotEmpty) if (message.reactions.isNotEmpty)
_ReactionsRow(reactions: message.reactions), _ReactionsRow(reactions: message.reactions),
], ],
@@ -69,29 +90,46 @@ class MessageBubble extends StatelessWidget {
} }
class _BubbleContent extends StatelessWidget { class _BubbleContent extends StatelessWidget {
const _BubbleContent({required this.message, required this.isMine}); const _BubbleContent({
required this.message,
required this.isMine,
this.isLastInGroup = true,
});
final MessageModel message; final MessageModel message;
final bool isMine; final bool isMine;
final bool isLastInGroup;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final bgColour = isMine final bgColour = isMine
? theme.colorScheme.primary ? M8Colours.selfBubble
: theme.colorScheme.surfaceContainerHighest; : M8Colours.darkSurfaceVariant;
final textColour = isMine ? Colors.white : theme.colorScheme.onSurface; final textColour = isMine ? Colors.white : theme.colorScheme.onSurface;
final maxBubbleWidth =
(MediaQuery.sizeOf(context).width.clamp(0, 560) * 0.78).toDouble();
// Only the last bubble of a same-sender run gets the pointed tail corner;
// mid-run bubbles stay fully rounded so the run reads as one block.
final tail = isLastInGroup ? const Radius.circular(4) : null;
return Container( return Container(
constraints: const BoxConstraints(maxWidth: 320), constraints: BoxConstraints(maxWidth: maxBubbleWidth),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColour, color: bgColour,
border: isMine
? null
: Border.all(color: theme.colorScheme.outline.withAlpha(31)),
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16), topLeft: const Radius.circular(16),
topRight: const Radius.circular(16), topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMine ? 16 : 4), bottomLeft: isMine
bottomRight: Radius.circular(isMine ? 4 : 16), ? const Radius.circular(16)
: (tail ?? const Radius.circular(16)),
bottomRight: isMine
? (tail ?? const Radius.circular(16))
: const Radius.circular(16),
), ),
), ),
child: Padding( child: Padding(
@@ -138,6 +176,22 @@ class _MessageContentBody extends StatelessWidget {
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
), ),
), ),
MessageType.encrypted => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lock_outline, size: 14, color: textColour.withAlpha(153)),
const SizedBox(width: 6),
Flexible(
child: Text(
'Encrypted. Use Element to read',
style: TextStyle(
color: textColour.withAlpha(153),
fontStyle: FontStyle.italic,
),
),
),
],
),
_ => Text( _ => Text(
message.body ?? 'Unsupported message type', message.body ?? 'Unsupported message type',
style: TextStyle( style: TextStyle(
@@ -156,11 +210,15 @@ class _ImageContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (message.mediaUrl != null) { final url = message.mediaUrl;
return ClipRRect( if (url == null) return const Icon(Icons.image_not_supported);
return GestureDetector(
onTap: () => _openFullscreen(context, url, message.body),
child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: message.mediaUrl!, imageUrl: url,
width: 240, width: 240,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => const SizedBox( placeholder: (_, __) => const SizedBox(
@@ -172,9 +230,56 @@ class _ImageContent extends StatelessWidget {
child: Center(child: Icon(Icons.broken_image)), child: Center(child: Icon(Icons.broken_image)),
), ),
), ),
); ),
} );
return const Icon(Icons.image_not_supported); }
void _openFullscreen(BuildContext context, String url, String? caption) {
Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => _ImageViewer(url: url, caption: caption),
),
);
}
}
class _ImageViewer extends StatelessWidget {
const _ImageViewer({required this.url, this.caption});
final String url;
final String? caption;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: caption != null
? Text(
caption!,
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
)
: null,
),
body: Center(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 4,
child: CachedNetworkImage(
imageUrl: url,
fit: BoxFit.contain,
placeholder: (_, __) =>
const CircularProgressIndicator(color: Colors.white),
errorWidget: (_, __, ___) =>
const Icon(Icons.broken_image, color: Colors.white, size: 64),
),
),
),
);
} }
} }

View File

@@ -1,4 +1,4 @@
// Version: 1.1.0 | Created: 2026-04-01 // Version: 1.1.1 | Created: 2026-04-01 | Updated: 2026-07-04
// Message input bar — text, send, attach file, reply quote. // Message input bar — text, send, attach file, reply quote.
// File picker enabled in Phase 2 via file_picker ^8.0.0. // File picker enabled in Phase 2 via file_picker ^8.0.0.
@@ -195,7 +195,8 @@ class _MessageInputState extends State<MessageInput> {
), ),
textInputAction: TextInputAction.send, textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(), onSubmitted: (_) => _submit(),
maxLines: null, minLines: 1,
maxLines: 6,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
), ),
), ),

View File

@@ -0,0 +1,332 @@
// Version: 1.1.1 | Created: 2026-04-11 | Updated: 2026-07-03
// Help tab with in-app guidance for common tasks and troubleshooting.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:web/web.dart' as web;
import '../../../core/network/matrix_client.dart';
import '../../profile/presentation/security_setup_dialog.dart';
void _launchUrl(BuildContext context, String url) {
web.window.open(url, '_blank');
}
class HelpTab extends ConsumerWidget {
const HelpTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final client = ref.watch(matrixClientProvider);
return ListView(
padding: const EdgeInsets.all(16),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
'Help & Troubleshooting',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// --- Encrypted messages ---
_HelpCard(
icon: Icons.lock_outline,
title: 'I see "Encrypted. Use Element to read"',
children: [
const _HelpParagraph(
'This means your device does not have the keys needed to '
'read those messages. This happens when:',
),
const _HelpBullet(
'You are using M8Chat on a new device or browser.'),
const _HelpBullet(
'The messages were sent before you set up security.'),
const _HelpBullet(
'Your browser data was cleared.'),
const SizedBox(height: 12),
const _HelpSubheading('How to fix it'),
const _HelpParagraph(
'1. Go to the Profile tab.\n'
'2. Tap Security & Privacy to expand it.\n'
'3. Tap Security setup.\n'
'4. If you have set up security before, enter your '
'recovery key. If not, a new one will be created for you.\n'
'5. Save the recovery key somewhere safe.\n'
'6. After setup, go back to your rooms. Messages should '
'now be readable.',
),
const SizedBox(height: 12),
const _HelpSubheading('What is a recovery key?'),
const _HelpParagraph(
'A recovery key is a long code starting with "EsTc" that '
'unlocks your encrypted messages. It was shown to you when '
'you first set up security in Element or M8Chat. If you '
'saved it, you can use it to restore your messages on any '
'device.',
),
const SizedBox(height: 12),
Builder(
builder: (context) => OutlinedButton.icon(
onPressed: () =>
showSecuritySetupDialog(context, client),
icon: const Icon(Icons.shield_outlined),
label: const Text('Open Security Setup'),
),
),
],
),
// --- New to M8Chat ---
const _HelpCard(
icon: Icons.chat_bubble_outline,
title: 'Getting started with M8Chat',
children: [
_HelpParagraph(
'M8Chat is a secure messaging app built on the Matrix '
'protocol. All your messages are end-to-end encrypted, '
'meaning only you and the people you chat with can read '
'them.',
),
SizedBox(height: 12),
_HelpSubheading('First steps'),
_HelpParagraph(
'1. Set up security: go to Profile > Security & Privacy '
'> Security setup. This creates your encryption keys and '
'backs them up.\n'
'2. Save your recovery key. You will need it if you ever '
'log in on a new device.\n'
'3. Start chatting. Tap the pencil icon at the top right '
'to search for users and start a conversation.',
),
],
),
// --- Joining a conference ---
const _HelpCard(
icon: Icons.videocam_outlined,
title: 'Joining a video conference',
children: [
_HelpParagraph(
'M8Chat supports Jitsi video conferences. To join one:',
),
SizedBox(height: 8),
_HelpParagraph(
'1. Tap the Conference tab at the bottom.\n'
'2. Paste the meeting link (e.g. conf.m8chat.au/MyMeeting).\n'
'3. Tap Join Meeting.\n\n'
'You can also make voice and video calls directly from any '
'chat room using the phone and camera icons at the top of '
'the conversation.',
),
],
),
// --- Video call issues ---
const _HelpCard(
icon: Icons.videocam_off_outlined,
title: 'Video call problems',
children: [
_HelpSubheading('Scrambled or black video'),
_HelpParagraph(
'If video appears scrambled when calling someone on Element X, '
'this is an encryption compatibility issue that is being '
'worked on. Try calling from the same app on both ends.',
),
SizedBox(height: 8),
_HelpSubheading('No audio or robotic sound'),
_HelpParagraph(
'This is usually caused by browser permissions. Make sure '
'you have allowed microphone and camera access when '
'prompted. On some devices (especially Sailfish OS), the '
'audio codec support may be limited.',
),
],
),
// --- Account & privacy ---
const _HelpCard(
icon: Icons.security_outlined,
title: 'Account & privacy',
children: [
_HelpSubheading('Who can read my messages?'),
_HelpParagraph(
'Only you and the people in the conversation. M8Chat uses '
'end-to-end encryption. Not even the server operator can '
'read your messages.',
),
SizedBox(height: 8),
_HelpSubheading('Can I use multiple devices?'),
_HelpParagraph(
'Yes. Set up security on each device using the same '
'recovery key. Your messages will be available on all '
'devices where security is set up.',
),
SizedBox(height: 8),
_HelpSubheading('What happens if I lose my recovery key?'),
_HelpParagraph(
'You will not be able to read old messages on new devices. '
'New messages will still work. You can create a new '
'recovery key from the Security setup, but old messages '
'encrypted with the previous key will remain unreadable.',
),
],
),
// --- Account management ---
_HelpCard(
icon: Icons.manage_accounts_outlined,
title: 'Account management',
children: [
const _HelpParagraph(
'All account changes need to be made through the M8Chat '
'website. This includes:',
),
const _HelpBullet('Changing your password'),
const _HelpBullet('Membership settings and billing'),
const _HelpBullet('Updating your profile or email'),
const _HelpBullet('Deleting your account'),
const SizedBox(height: 12),
Builder(
builder: (context) => OutlinedButton.icon(
onPressed: () => _launchUrl(context, 'https://m8chat.au'),
icon: const Icon(Icons.open_in_new),
label: const Text('Go to m8chat.au'),
),
),
const SizedBox(height: 12),
const _HelpSubheading('Privacy'),
const _HelpParagraph(
'No account details or other privacy or user identifiers '
'are saved or stored on this application. All data stays '
'on the M8Chat server and is protected by end-to-end '
'encryption.',
),
const SizedBox(height: 12),
const _HelpParagraph(
'For more information about what happens if things go '
'wrong, including data recovery and account issues:',
),
const SizedBox(height: 8),
Builder(
builder: (context) => OutlinedButton.icon(
onPressed: () => _launchUrl(
context, 'https://m8chat.au/when-it-all-goes-wrong/'),
icon: const Icon(Icons.open_in_new),
label: const Text('When it all goes wrong'),
),
),
],
),
const SizedBox(height: 32),
Center(
child: Text(
'Need more help? Visit m8chat.au or contact the M8Chat team.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102),
),
),
),
const SizedBox(height: 16),
],
);
}
}
// --- Reusable help widgets ---
class _HelpCard extends StatelessWidget {
const _HelpCard({
required this.icon,
required this.title,
required this.children,
});
final IconData icon;
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ExpansionTile(
leading: Icon(icon, color: theme.colorScheme.primary),
title: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
childrenPadding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
);
}
}
class _HelpParagraph extends StatelessWidget {
const _HelpParagraph(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5),
);
}
}
class _HelpSubheading extends StatelessWidget {
const _HelpSubheading(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
text,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
);
}
}
class _HelpBullet extends StatelessWidget {
const _HelpBullet(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 8, top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('\u2022 '),
Expanded(
child: Text(
text,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(height: 1.5),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,198 @@
// Version: 1.2.0 | Created: 2026-04-05 | Updated: 2026-07-04
// Web implementation: uses JitsiMeetExternalAPI via dart:js_interop.
// This file is web-only — guarded by the Flutter web build.
// The external_api.js script is lazy-loaded on first use — not in index.html.
// This prevents privacy browsers (Brave, Mullvad) from blocking the app
// at startup due to a cross-origin script from conf.m8chat.au.
import 'dart:async';
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'dart:ui_web' as ui_web;
import 'package:web/web.dart' as web;
import '../../../core/config/app_config.dart';
/// Manages a single Jitsi iframe session via the JitsiMeetExternalAPI.
class JitsiWebService {
JitsiWebService._();
static final instance = JitsiWebService._();
JSObject? _api;
bool _viewRegistered = false;
bool _scriptLoaded = false;
bool _scriptLoading = false;
/// Unique view type for HtmlElementView.
static const String viewType = 'jitsi-meet-container';
/// Lazy-loads the JitsiMeetExternalAPI script from conf.m8chat.au.
/// Returns true if the script loaded successfully, false otherwise.
Future<bool> _ensureScriptLoaded() async {
if (_scriptLoaded) return true;
if (_scriptLoading) {
// Wait for in-flight load to finish.
for (var i = 0; i < 50; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
if (_scriptLoaded) return true;
}
return false;
}
_scriptLoading = true;
final completer = Completer<bool>();
final script =
web.document.createElement('script') as web.HTMLScriptElement;
script.src = 'https://${AppConfig.jitsiDomain}/external_api.js';
script.async = true;
script.onload = (web.Event event) {
_scriptLoaded = true;
_scriptLoading = false;
completer.complete(true);
}.toJS;
script.onerror = (web.Event event) {
_scriptLoading = false;
completer.complete(false);
}.toJS;
web.document.head?.append(script);
return completer.future;
}
/// Registers the platform view factory (once).
void ensureViewRegistered() {
if (_viewRegistered) return;
ui_web.platformViewRegistry.registerViewFactory(
viewType,
(int viewId, {Object? params}) {
final div = web.document.createElement('div') as web.HTMLDivElement;
div.id = 'jitsi-container-$viewId';
div.style
..width = '100%'
..height = '100%';
return div;
},
);
_viewRegistered = true;
}
/// Starts a Jitsi meeting inside the container div created by the platform view.
/// Call this AFTER the HtmlElementView has been scheduled for mounting
/// (e.g. in a post-frame callback). Lazy-loads the Jitsi script on first
/// call and waits for the platform-view container div to appear — the
/// post-frame callback can run before the HtmlElementView factory has
/// inserted the div, which previously made this method bail out silently
/// and leave the meeting screen blank.
/// Returns true if the meeting iframe was created.
Future<bool> joinMeeting({
required String roomName,
String? jwt,
String? displayName,
String? avatarUrl,
void Function()? onReadyToClose,
}) async {
dispose(); // tear down any previous meeting
// Lazy-load the external_api.js script if not already present.
final loaded = await _ensureScriptLoaded();
if (!loaded) {
web.console.warn('[Jitsi] external_api.js failed to load'.toJS);
return false;
}
// Wait for the container div — the platform view may not be in the DOM
// yet when the post-frame callback fires.
web.Node? parentNode;
for (var i = 0; i < 50; i++) {
final containers =
web.document.querySelectorAll('[id^="jitsi-container-"]');
if (containers.length > 0) {
parentNode = containers.item(containers.length - 1);
break;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
if (parentNode == null) {
web.console.warn('[Jitsi] container div never appeared'.toJS);
return false;
}
// Build the options as a real JS object. Passing a DOM node through
// Map.jsify() is not supported and threw at runtime in the dart2js build.
final configOverwrite = JSObject()
..setProperty('startAudioMuted'.toJS, 0.toJS)
..setProperty('startVideoMuted'.toJS, 0.toJS)
..setProperty('disableDeepLinking'.toJS, true.toJS)
..setProperty('prejoinPageEnabled'.toJS, true.toJS);
final interfaceConfigOverwrite = JSObject()
..setProperty('SHOW_CHROME_EXTENSION_BANNER'.toJS, false.toJS);
final options = JSObject()
..setProperty('roomName'.toJS, roomName.toJS)
..setProperty('parentNode'.toJS, parentNode as JSAny)
..setProperty('width'.toJS, '100%'.toJS)
..setProperty('height'.toJS, '100%'.toJS)
..setProperty('configOverwrite'.toJS, configOverwrite)
..setProperty('interfaceConfigOverwrite'.toJS, interfaceConfigOverwrite);
if (jwt != null && jwt.isNotEmpty) {
options.setProperty('jwt'.toJS, jwt.toJS);
}
if (displayName != null && displayName.isNotEmpty) {
final userInfo = JSObject()
..setProperty('displayName'.toJS, displayName.toJS);
if (avatarUrl != null) {
userInfo.setProperty('avatarUrl'.toJS, avatarUrl.toJS);
}
options.setProperty('userInfo'.toJS, userInfo);
}
try {
_api = _createJitsiApi(AppConfig.jitsiDomain.toJS, options);
} catch (e) {
web.console.error('[Jitsi] JitsiMeetExternalAPI threw: $e'.toJS);
return false;
}
if (onReadyToClose != null && _api != null) {
_addEventListener(_api!, 'readyToClose'.toJS, () {
onReadyToClose();
}.toJS);
}
return true;
}
/// Cleans up the Jitsi iframe.
void dispose() {
if (_api != null) {
_disposeApi(_api!);
_api = null;
}
}
}
/// Binding for the JitsiMeetExternalAPI ES6 class.
/// The external constructor compiles to `new JitsiMeetExternalAPI(...)`.
/// (A plain external function binding calls it WITHOUT `new`, which throws
/// "Class constructor cannot be invoked without 'new'" — the original cause
/// of the blank meeting screen.)
@JS('JitsiMeetExternalAPI')
extension type _JitsiApi._(JSObject _) implements JSObject {
external _JitsiApi(JSString domain, JSObject options);
external void addEventListener(JSString event, JSFunction callback);
external void dispose();
}
JSObject _createJitsiApi(JSString domain, JSObject options) =>
_JitsiApi(domain, options);
void _addEventListener(JSObject api, JSString event, JSFunction callback) {
(api as _JitsiApi).addEventListener(event, callback);
}
void _disposeApi(JSObject api) {
(api as _JitsiApi).dispose();
}

View File

@@ -0,0 +1,43 @@
// Version: 1.0.0 | Created: 2026-04-05
// Parses a Jitsi meeting link into room name and optional JWT.
/// Parsed Jitsi meeting link.
class JitsiLink {
const JitsiLink({required this.roomName, this.jwt});
final String roomName;
final String? jwt;
/// Parses a Jitsi link in common formats:
/// - `https://conf.m8chat.au/RoomName`
/// - `https://conf.m8chat.au/RoomName?jwt=abc123`
/// - `conf.m8chat.au/RoomName?jwt=abc123`
/// - `RoomName` (bare room name)
static JitsiLink? tryParse(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) return null;
// Try as a URL first.
var uriString = trimmed;
if (!uriString.contains('://') && uriString.contains('/')) {
uriString = 'https://$uriString';
}
final uri = Uri.tryParse(uriString);
if (uri != null && uri.pathSegments.isNotEmpty && uri.host.isNotEmpty) {
final roomName = uri.pathSegments
.where((s) => s.isNotEmpty)
.join('/');
if (roomName.isEmpty) return null;
final jwt = uri.queryParameters['jwt'];
return JitsiLink(roomName: roomName, jwt: jwt);
}
// Bare room name — no slashes, no protocol.
if (!trimmed.contains('/') && !trimmed.contains(' ')) {
return JitsiLink(roomName: trimmed);
}
return null;
}
}

View File

@@ -0,0 +1,96 @@
// Version: 1.0.0 | Created: 2026-04-10
// Embedded conference join widget for the bottom nav tab.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// Inline widget shown inside the RoomsScreen bottom nav.
/// Lets authenticated users paste a Jitsi meeting link and join.
class ConferenceTab extends StatefulWidget {
const ConferenceTab({super.key});
@override
State<ConferenceTab> createState() => _ConferenceTabState();
}
class _ConferenceTabState extends State<ConferenceTab> {
final _linkController = TextEditingController();
@override
void dispose() {
_linkController.dispose();
super.dispose();
}
void _joinConference() {
final link = _linkController.text.trim();
if (link.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please paste a meeting link.')),
);
return;
}
context.push('/jitsi', extra: link);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.videocam_outlined,
size: 64,
color: theme.colorScheme.primary.withAlpha(180),
),
const SizedBox(height: 16),
Text(
'Join a Conference',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Paste your meeting link below to join.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 32),
TextField(
controller: _linkController,
decoration: const InputDecoration(
hintText: 'conf.m8chat.au/YourMeeting',
prefixIcon: Icon(Icons.link),
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.go,
onSubmitted: (_) => _joinConference(),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _joinConference,
icon: const Icon(Icons.videocam),
label: const Text('Join Meeting'),
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,154 @@
// Version: 1.3.0 | Created: 2026-04-05 | Updated: 2026-07-04
// Full-screen Jitsi meeting embedded via HtmlElementView (web platform view).
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../data/jitsi_web_service.dart';
import '../domain/jitsi_link.dart';
class JitsiScreen extends StatefulWidget {
const JitsiScreen({super.key, required this.meetingUrl});
/// The raw meeting link or room name provided by the user.
final String meetingUrl;
@override
State<JitsiScreen> createState() => _JitsiScreenState();
}
class _JitsiScreenState extends State<JitsiScreen> {
late final JitsiLink? _link;
bool _meetingStarted = false;
bool _meetingEnded = false;
bool _meetingFailed = false;
@override
void initState() {
super.initState();
_link = JitsiLink.tryParse(widget.meetingUrl);
if (_link != null) {
JitsiWebService.instance.ensureViewRegistered();
}
}
@override
void dispose() {
JitsiWebService.instance.dispose();
super.dispose();
}
void _startMeeting() {
if (_link == null || _meetingStarted) return;
_meetingStarted = true;
// Post-frame so the HtmlElementView div is mounted in the DOM.
// joinMeeting itself waits for the container div to appear.
WidgetsBinding.instance.addPostFrameCallback((_) async {
final ok = await JitsiWebService.instance.joinMeeting(
roomName: _link.roomName,
jwt: _link.jwt,
onReadyToClose: () {
if (mounted) {
setState(() => _meetingEnded = true);
}
},
);
if (!ok && mounted) {
setState(() => _meetingFailed = true);
}
});
}
@override
Widget build(BuildContext context) {
if (_link == null) {
return Scaffold(
appBar: AppBar(title: const Text('Conference')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.link_off, size: 48),
const SizedBox(height: 16),
const Text('Could not parse the meeting link.'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/rooms'),
child: const Text('Back'),
),
],
),
),
),
);
}
if (_meetingEnded) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.videocam_off_outlined,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Meeting ended',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/rooms'),
child: const Text('Back to M8Chat'),
),
],
),
),
),
);
}
if (_meetingFailed) {
return Scaffold(
appBar: AppBar(title: const Text('Conference')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.videocam_off_outlined, size: 48),
const SizedBox(height: 16),
const Text(
'Could not start the meeting. '
'Check your connection and try again.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/rooms'),
child: const Text('Back'),
),
],
),
),
),
);
}
// Render the Jitsi iframe and kick off the meeting once mounted.
_startMeeting();
return Scaffold(
body: const HtmlElementView(viewType: JitsiWebService.viewType),
);
}
}

View File

@@ -0,0 +1,227 @@
// Version: 1.1.0 | Created: 2026-04-05 | Updated: 2026-07-04
// Welcome/landing screen — first thing unauthenticated users see.
// Two paths: join a Jitsi conference (no login) or sign in for Matrix chat.
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
class WelcomeScreen extends StatefulWidget {
const WelcomeScreen({super.key});
@override
State<WelcomeScreen> createState() => _WelcomeScreenState();
}
class _WelcomeScreenState extends State<WelcomeScreen> {
final _linkController = TextEditingController();
@override
void dispose() {
_linkController.dispose();
super.dispose();
}
void _joinConference() {
final link = _linkController.text.trim();
if (link.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please paste a meeting link.')),
);
return;
}
context.go('/jitsi', extra: link);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF0A0F2E), Color(0xFF131A42)],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// --- Logo & tagline ---
Column(
children: [
SvgPicture.asset(
'assets/images/m8logo.svg',
width: 80,
height: 80,
),
const SizedBox(height: 16),
Text(
'M8Chat',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
'Chat and conference, all in one place',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
],
),
const SizedBox(height: 48),
// --- Conference section ---
_SectionCard(
theme: theme,
isDark: isDark,
icon: Icons.videocam_outlined,
title: 'Join a Conference',
subtitle: 'Paste your meeting link to join instantly. No account needed.',
child: Column(
children: [
TextField(
controller: _linkController,
decoration: const InputDecoration(
hintText: 'conf.m8chat.au/YourMeeting',
prefixIcon: Icon(Icons.link),
),
textInputAction: TextInputAction.go,
onSubmitted: (_) => _joinConference(),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _joinConference,
icon: const Icon(Icons.videocam),
label: const Text('Join Meeting'),
),
],
),
),
const SizedBox(height: 20),
// --- Divider ---
Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'or',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102),
),
),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: 20),
// --- Sign in section ---
_SectionCard(
theme: theme,
isDark: isDark,
icon: Icons.chat_bubble_outline,
title: 'Sign In to Chat',
subtitle: 'Access your rooms, messages and video calls.',
child: OutlinedButton.icon(
onPressed: () => context.go('/login'),
icon: const Icon(Icons.login),
label: const Text('Sign In'),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(color: theme.colorScheme.primary),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
),
),
),
);
}
}
/// Styled card for each welcome section.
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.theme,
required this.isDark,
required this.icon,
required this.title,
required this.subtitle,
required this.child,
});
final ThemeData theme;
final bool isDark;
final IconData icon;
final String title;
final String subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: theme.colorScheme.primary, size: 28),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 8),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 16),
child,
],
),
),
);
}
}

View File

@@ -0,0 +1,142 @@
// Version: 1.0.0 | Created: 2026-07-04
// Panic button repository — config in Matrix account data plus the trigger
// action: send an alert text message and (when the browser grants it) an
// m.location pin to the configured room.
//
// The alert message is sent FIRST so a denied/slow geolocation permission can
// never block the emergency alert itself.
//
// NOTE web-only geolocation (dart:js_interop + package:web). When native
// Android/iOS builds happen, gate this behind a conditional import.
import 'dart:async';
import 'dart:js_interop';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web/web.dart' as web;
import '../../../core/network/matrix_client.dart';
part 'panic_repository.g.dart';
/// Account-data event type holding the panic configuration.
const kPanicConfigType = 'au.m8chat.panic';
/// Panic button configuration.
class PanicConfig {
const PanicConfig({required this.roomId, required this.message});
final String roomId;
final String message;
static PanicConfig? fromAccountData(Client client) {
final content = client.accountData[kPanicConfigType]?.content;
final roomId = content?['room_id'];
if (roomId is! String || roomId.isEmpty) return null;
final message = content?['message'];
return PanicConfig(
roomId: roomId,
message: message is String && message.isNotEmpty
? message
: 'EMERGENCY: I need help. This is an automated alert from M8Chat.',
);
}
}
class PanicRepository {
PanicRepository({required Client client}) : _client = client;
final Client _client;
PanicConfig? get config => PanicConfig.fromAccountData(_client);
Future<void> saveConfig({
required String roomId,
required String message,
}) async {
final userId = _client.userID;
if (userId == null) throw Exception('Not logged in');
await _client.setAccountData(userId, kPanicConfigType, {
'room_id': roomId,
'message': message,
});
}
Future<void> clearConfig() async {
final userId = _client.userID;
if (userId == null) return;
await _client.setAccountData(userId, kPanicConfigType, {});
}
/// Fires the panic alert. Returns an error string, or null on success.
Future<String?> trigger() async {
final cfg = config;
if (cfg == null) return 'Panic button is not configured.';
final room = _client.getRoomById(cfg.roomId);
if (room == null) return 'Configured room is no longer available.';
// 1. The alert text goes out first — never blocked by geolocation.
try {
await room.sendTextEvent('\u{1F6A8} ${cfg.message}');
} on Exception catch (e) {
return 'Could not send alert: $e';
}
// 2. Best-effort location pin.
final position = await _currentPosition();
if (position != null) {
final (lat, lon) = position;
final geoUri = 'geo:$lat,$lon';
try {
await room.sendEvent({
'msgtype': 'm.location',
'body': 'Emergency location: $geoUri',
'geo_uri': geoUri,
'org.matrix.msc3488.location': {'uri': geoUri},
'org.matrix.msc3488.asset': {'type': 'm.pin'},
}, type: 'm.room.message');
} on Exception catch (e) {
debugPrint('[Panic] location send failed: $e');
}
} else {
debugPrint('[Panic] no geolocation available — alert sent without it');
}
return null;
}
/// Browser geolocation with a hard 10s budget. Null when denied/unavailable.
Future<(double, double)?> _currentPosition() async {
final completer = Completer<(double, double)?>();
try {
web.window.navigator.geolocation.getCurrentPosition(
(web.GeolocationPosition pos) {
if (!completer.isCompleted) {
completer.complete((pos.coords.latitude, pos.coords.longitude));
}
}.toJS,
(web.GeolocationPositionError err) {
if (!completer.isCompleted) completer.complete(null);
}.toJS,
web.PositionOptions(
enableHighAccuracy: true,
timeout: 8000,
maximumAge: 60000,
),
);
} catch (e) {
debugPrint('[Panic] geolocation API unavailable: $e');
return null;
}
return completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () => null,
);
}
}
@Riverpod(keepAlive: true)
PanicRepository panicRepository(Ref ref) {
return PanicRepository(client: ref.watch(matrixClientProvider));
}

View File

@@ -0,0 +1,147 @@
// Version: 1.1.0 | Created: 2026-07-04 | Updated: 2026-07-04
// Panic trigger button for the rooms AppBar. Push-to-talk style: HOLD for
// 700ms to fire the alert, with a filling ring showing progress so a brush
// cannot fire it and the user sees exactly when it sends. A plain tap only
// shows the hint. Only rendered when a panic configuration exists.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/panic_repository.dart';
class PanicButton extends ConsumerStatefulWidget {
const PanicButton({super.key});
@override
ConsumerState<PanicButton> createState() => _PanicButtonState();
}
class _PanicButtonState extends ConsumerState<PanicButton>
with SingleTickerProviderStateMixin {
static const _holdDuration = Duration(milliseconds: 700);
late final AnimationController _holdController;
bool _sending = false;
@override
void initState() {
super.initState();
_holdController = AnimationController(vsync: this, duration: _holdDuration)
..addStatusListener(_onHoldStatus);
}
@override
void dispose() {
_holdController.dispose();
super.dispose();
}
void _onHoldStatus(AnimationStatus status) {
// The ring filled all the way: the hold completed, fire the alert.
if (status == AnimationStatus.completed) {
HapticFeedback.heavyImpact();
_trigger();
_holdController.reset();
}
}
void _onHoldStart() {
if (_sending) return;
_holdController.forward(from: 0);
}
void _onHoldCancelled() {
// Released before the ring filled: cancel silently.
if (_holdController.status == AnimationStatus.forward) {
_holdController.reset();
}
}
Future<void> _trigger() async {
if (_sending) return;
setState(() => _sending = true);
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(content: Text('Sending emergency alert…')),
);
final error = await ref.read(panicRepositoryProvider).trigger();
if (!mounted) return;
setState(() => _sending = false);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
backgroundColor: error == null ? Colors.green.shade800 : null,
content: Text(error ?? 'Emergency alert sent with your location.'),
),
);
}
void _showHint() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Hold the button to send the emergency alert.'),
),
);
}
@override
Widget build(BuildContext context) {
// Hide when not configured. Config lives in account data, which is
// available once sync has run; rebuilds happen with the parent screen.
final configured = ref.watch(panicRepositoryProvider).config != null;
if (!configured) return const SizedBox.shrink();
return GestureDetector(
onTap: _showHint,
onLongPressDown: (_) => _onHoldStart(),
onLongPressUp: _onHoldCancelled,
onLongPressCancel: _onHoldCancelled,
child: Tooltip(
message: 'Hold to send emergency alert',
child: SizedBox(
width: 48,
height: 48,
child: Center(
child: SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
// Progress ring, only painted while the hold is in progress.
AnimatedBuilder(
animation: _holdController,
builder: (context, _) {
if (_holdController.value == 0) {
return const SizedBox.shrink();
}
return SizedBox(
width: 36,
height: 36,
child: CircularProgressIndicator(
value: _holdController.value,
strokeWidth: 3,
color: Colors.red,
backgroundColor: Colors.red.withAlpha(40),
),
);
},
),
if (_sending)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
const Icon(Icons.emergency_share, color: Colors.red),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,166 @@
// Version: 1.0.0 | Created: 2026-07-04
// Panic button settings — choose the alert room and message.
// Opened from Profile > Emergency panic button.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import '../../../core/network/matrix_client.dart';
import '../data/panic_repository.dart';
Future<void> showPanicSettingsDialog(BuildContext context, WidgetRef ref) {
return showDialog<void>(
context: context,
builder: (_) => const _PanicSettingsDialog(),
);
}
class _PanicSettingsDialog extends ConsumerStatefulWidget {
const _PanicSettingsDialog();
@override
ConsumerState<_PanicSettingsDialog> createState() =>
_PanicSettingsDialogState();
}
class _PanicSettingsDialogState extends ConsumerState<_PanicSettingsDialog> {
String? _roomId;
late final TextEditingController _messageController;
bool _saving = false;
@override
void initState() {
super.initState();
final cfg = ref.read(panicRepositoryProvider).config;
_roomId = cfg?.roomId;
_messageController = TextEditingController(
text: cfg?.message ??
'EMERGENCY: I need help. This is an automated alert from M8Chat.',
);
}
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
Future<void> _save() async {
final roomId = _roomId;
if (roomId == null) return;
setState(() => _saving = true);
try {
await ref.read(panicRepositoryProvider).saveConfig(
roomId: roomId,
message: _messageController.text.trim(),
);
if (mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Panic button armed. Hold the alert icon '
'on the rooms screen to trigger it.'),
),
);
}
} on Exception catch (e) {
if (mounted) {
setState(() => _saving = false);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Could not save: $e')));
}
}
}
Future<void> _disable() async {
setState(() => _saving = true);
await ref.read(panicRepositoryProvider).clearConfig();
if (mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Panic button disabled.')),
);
}
}
@override
Widget build(BuildContext context) {
final client = ref.watch(matrixClientProvider);
final rooms = client.rooms
.where((r) => r.membership == Membership.join)
.toList()
..sort((a, b) => a
.getLocalizedDisplayname()
.toLowerCase()
.compareTo(b.getLocalizedDisplayname().toLowerCase()));
final hasConfig = ref.read(panicRepositoryProvider).config != null;
return AlertDialog(
title: const Text('Emergency panic button'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'When you hold the alert icon, M8Chat sends this message '
'and your current location to the room you choose here.',
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue:
rooms.any((r) => r.id == _roomId) ? _roomId : null,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Send alert to room',
border: OutlineInputBorder(),
),
items: [
for (final room in rooms)
DropdownMenuItem(
value: room.id,
child: Text(
room.getLocalizedDisplayname(),
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (v) => setState(() => _roomId = v),
),
const SizedBox(height: 16),
TextField(
controller: _messageController,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Alert message',
border: OutlineInputBorder(),
),
),
],
),
),
actions: [
if (hasConfig)
TextButton(
onPressed: _saving ? null : _disable,
child: const Text('Disable', style: TextStyle(color: Colors.red)),
),
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving || _roomId == null ? null : _save,
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
),
],
);
}
}

View File

@@ -0,0 +1,213 @@
// Version: 1.0.1 | Created: 2026-04-11 | Updated: 2026-07-03
// Dialog for entering a recovery key to restore encrypted message keys
// from the server-side key backup (SSSS / Secure Secret Storage).
import 'package:flutter/material.dart';
import 'package:matrix/encryption/ssss.dart';
import 'package:matrix/matrix.dart';
/// Shows a dialog that asks the user for their recovery key, unlocks SSSS,
/// and restores all Megolm session keys from the server backup.
///
/// Returns `true` if keys were restored successfully, `false` otherwise.
Future<bool> showKeyRestoreDialog(BuildContext context, Client client) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _KeyRestoreDialog(client: client),
);
return result ?? false;
}
class _KeyRestoreDialog extends StatefulWidget {
const _KeyRestoreDialog({required this.client});
final Client client;
@override
State<_KeyRestoreDialog> createState() => _KeyRestoreDialogState();
}
enum _RestorePhase { input, unlocking, downloading, done, error }
class _KeyRestoreDialogState extends State<_KeyRestoreDialog> {
final _controller = TextEditingController();
_RestorePhase _phase = _RestorePhase.input;
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _restore() async {
final key = _controller.text.trim();
if (key.isEmpty) return;
final enc = widget.client.encryption;
if (enc == null) {
setState(() {
_phase = _RestorePhase.error;
_error = 'Encryption is not available.';
});
return;
}
// Phase 1: Unlock SSSS with the recovery key
setState(() => _phase = _RestorePhase.unlocking);
try {
final ssssKey = enc.ssss.open();
await ssssKey.unlock(keyOrPassphrase: key);
debugPrint('[KeyRestore] SSSS unlocked successfully');
} on InvalidPassphraseException {
setState(() {
_phase = _RestorePhase.error;
_error = 'Invalid recovery key. Please check and try again.';
});
return;
} on Exception catch (e) {
setState(() {
_phase = _RestorePhase.error;
_error = 'Could not unlock: ${e.toString().split('\n').first}';
});
return;
}
// Phase 2: Download and decrypt all keys from backup
setState(() => _phase = _RestorePhase.downloading);
try {
await enc.keyManager.loadAllKeys();
debugPrint('[KeyRestore] All keys loaded from backup');
setState(() => _phase = _RestorePhase.done);
} on MatrixException catch (e) {
if (e.errcode == 'M_NOT_FOUND') {
setState(() {
_phase = _RestorePhase.error;
_error = 'No key backup found on the server for this account.';
});
} else {
setState(() {
_phase = _RestorePhase.error;
_error = 'Server error: ${e.errorMessage}';
});
}
} on Exception catch (e) {
setState(() {
_phase = _RestorePhase.error;
_error = 'Failed to download keys: ${e.toString().split('\n').first}';
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(switch (_phase) {
_RestorePhase.input => 'Restore Message Keys',
_RestorePhase.unlocking => 'Unlocking...',
_RestorePhase.downloading => 'Downloading Keys...',
_RestorePhase.done => 'Keys Restored',
_RestorePhase.error => 'Restore Failed',
}),
content: switch (_phase) {
_RestorePhase.input => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter your recovery key to decrypt old messages. '
'This is the key you were given when you set up '
'encryption in Element.',
),
const SizedBox(height: 16),
TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'EsTc oRgW rqHN...',
labelText: 'Recovery key',
border: OutlineInputBorder(),
),
maxLines: 3,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _restore(),
),
],
),
_RestorePhase.unlocking => const _ProgressContent(
message: 'Verifying recovery key...',
),
_RestorePhase.downloading => const _ProgressContent(
message: 'Downloading and decrypting message keys...\n'
'This may take a moment.',
),
_RestorePhase.done => const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: Colors.green, size: 48),
SizedBox(height: 16),
Text(
'Message keys have been restored. '
'Go back to your rooms. Previously encrypted '
'messages should now be readable.',
),
],
),
_RestorePhase.error => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(_error ?? 'An unknown error occurred.'),
],
),
},
actions: switch (_phase) {
_RestorePhase.input => [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _restore,
child: const Text('Restore'),
),
],
_RestorePhase.unlocking || _RestorePhase.downloading => [],
_RestorePhase.done => [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Done'),
),
],
_RestorePhase.error => [
TextButton(
onPressed: () =>
setState(() => _phase = _RestorePhase.input),
child: const Text('Try again'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Close'),
),
],
},
);
}
}
class _ProgressContent extends StatelessWidget {
const _ProgressContent({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(message, textAlign: TextAlign.center),
],
);
}
}

View File

@@ -0,0 +1,80 @@
// Version: 1.0.0 | Created: 2026-07-04
// Post-login prompt: if the account has secret storage (key backup) and this
// fresh device cannot read encrypted history yet, offer the recovery-key
// restore straight away instead of leaving it buried in Profile > Security.
//
// Shown at most once per login session. "Later" keeps the old behaviour
// (restore manually from the Profile tab).
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'key_restore_dialog.dart';
bool _promptedThisSession = false;
/// Reset when a new login happens (called from the login flow if needed).
void resetKeyRestorePrompt() => _promptedThisSession = false;
/// Checks whether the freshly logged-in session needs a key restore and,
/// if so, asks the user. Safe to call multiple times; only fires once.
Future<void> maybePromptKeyRestore(BuildContext context, Client client) async {
if (_promptedThisSession) return;
_promptedThisSession = true;
try {
// Wait for the first sync so account data (secret storage config) exists.
if (client.prevBatch == null) {
await client.onSync.stream.first.timeout(const Duration(seconds: 30));
}
} on TimeoutException {
debugPrint('[KeyRestorePrompt] first sync timed out — skipping');
return;
}
final enc = client.encryption;
if (enc == null) return;
// No secret storage on the account → nothing to restore from.
final hasSecretStorage =
client.accountData.containsKey('m.secret_storage.default_key');
if (!hasSecretStorage) return;
// Keys already cached (or session verified) → nothing to do.
var cached = false;
try {
cached = await enc.keyManager.isCached();
} on Exception catch (e) {
debugPrint('[KeyRestorePrompt] isCached failed: $e');
}
if (cached && !client.isUnknownSession) return;
if (!context.mounted) return;
final wantsRestore = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Restore encrypted messages?'),
content: const Text(
'This device cannot read your older encrypted messages yet. '
'Enter your recovery key now to unlock them, or do it later '
'from Profile > Security & Privacy.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Later'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Enter recovery key'),
),
],
),
);
if (wantsRestore != true || !context.mounted) return;
await showKeyRestoreDialog(context, client);
}

View File

@@ -1,4 +1,4 @@
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Profile screen. Shows current user info and logout button. // Profile screen. Shows current user info and logout button.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -10,6 +10,10 @@ import '../../../core/config/app_config.dart';
import '../../../core/network/matrix_client.dart'; import '../../../core/network/matrix_client.dart';
import '../../../shared/utils/matrix_id.dart'; import '../../../shared/utils/matrix_id.dart';
import '../../../shared/widgets/matrix_avatar.dart'; import '../../../shared/widgets/matrix_avatar.dart';
import '../../panic/data/panic_repository.dart';
import '../../panic/presentation/panic_settings_dialog.dart';
import 'key_restore_dialog.dart';
import 'security_setup_dialog.dart';
class ProfileScreen extends ConsumerWidget { class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key, this.embedded = false}); const ProfileScreen({super.key, this.embedded = false});
@@ -85,14 +89,57 @@ class ProfileScreen extends ConsumerWidget {
ListTile( ListTile(
leading: const Icon(Icons.lock_outline), leading: const Icon(Icons.lock_outline),
title: const Text('End-to-end encryption'), title: const Text('End-to-end encryption'),
subtitle: const Text('Setup coming in Phase 3'), subtitle: Text(
client.encryptionEnabled
? 'Active. Messages are encrypted'
: 'Not available in this browser',
),
enabled: false, enabled: false,
), ),
ListTile( ListTile(
leading: const Icon(Icons.verified_user_outlined), leading: const Icon(Icons.key_outlined),
title: const Text('Verify devices'), title: const Text('Restore message keys'),
subtitle: const Text('Cross-signing setup — coming in Phase 2'), subtitle: const Text(
enabled: false, 'Enter your recovery key to decrypt old messages',
),
enabled: client.encryptionEnabled,
onTap: client.encryptionEnabled
? () async {
final restored =
await showKeyRestoreDialog(context, client);
if (restored && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Keys restored. Reopen rooms '
'to see decrypted messages.'),
),
);
}
}
: null,
),
ListTile(
leading: const Icon(Icons.shield_outlined),
title: const Text('Security setup'),
subtitle: Text(
client.encryption?.crossSigning.enabled == true
? 'Cross-signing active. Device verified'
: 'Set up cross-signing and key backup',
),
enabled: client.encryptionEnabled,
onTap: client.encryptionEnabled
? () async {
final completed =
await showSecuritySetupDialog(context, client);
if (completed && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Security setup complete.'),
),
);
}
}
: null,
), ),
ListTile( ListTile(
leading: const Icon(Icons.password_outlined), leading: const Icon(Icons.password_outlined),
@@ -102,6 +149,16 @@ class ProfileScreen extends ConsumerWidget {
), ),
], ],
), ),
ListTile(
leading: const Icon(Icons.emergency_share, color: Colors.red),
title: const Text('Emergency panic button'),
subtitle: Text(
ref.watch(panicRepositoryProvider).config != null
? 'Armed. Hold the alert icon on the rooms screen to trigger.'
: 'Send your location and an alert to a room of your choice',
),
onTap: () => showPanicSettingsDialog(context, ref),
),
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(), const Divider(),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -0,0 +1,416 @@
// Version: 1.0.1 | Created: 2026-04-11 | Updated: 2026-07-03
// Security setup dialog: drives the Matrix SDK Bootstrap to set up
// SSSS, cross-signing, and online key backup in one flow.
// For new users: creates everything, shows recovery key.
// For existing users: unlocks with recovery key, restores message keys.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:matrix/encryption/ssss.dart';
import 'package:matrix/encryption/utils/bootstrap.dart';
import 'package:matrix/matrix.dart';
/// Shows the security setup dialog. Returns true if setup completed.
Future<bool> showSecuritySetupDialog(
BuildContext context, Client client) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _SecuritySetupDialog(client: client),
);
return result ?? false;
}
class _SecuritySetupDialog extends StatefulWidget {
const _SecuritySetupDialog({required this.client});
final Client client;
@override
State<_SecuritySetupDialog> createState() => _SecuritySetupDialogState();
}
enum _Phase {
checking,
needsRecoveryKey,
working,
showRecoveryKey,
restoring,
done,
error,
}
class _SecuritySetupDialogState extends State<_SecuritySetupDialog> {
final _keyController = TextEditingController();
_Phase _phase = _Phase.checking;
String? _error;
String? _recoveryKey;
String _statusMessage = 'Checking security setup...';
late Bootstrap _bootstrap;
@override
void initState() {
super.initState();
_start();
}
@override
void dispose() {
_keyController.dispose();
super.dispose();
}
Future<void> _start() async {
final enc = widget.client.encryption;
if (enc == null) {
_setError('Encryption is not available in this browser.');
return;
}
_bootstrap = enc.bootstrap(onUpdate: (_) {});
try {
await _driveBootstrap();
} catch (e) {
_setError(e.toString().split('\n').first);
}
}
Future<void> _driveBootstrap() async {
while (_bootstrap.state != BootstrapState.done &&
_bootstrap.state != BootstrapState.error) {
debugPrint('[Security] Bootstrap state: ${_bootstrap.state}');
switch (_bootstrap.state) {
case BootstrapState.loading:
setState(() {
_phase = _Phase.working;
_statusMessage = 'Setting up...';
});
// Wait for the bootstrap to advance via onUpdate
await Future.delayed(const Duration(milliseconds: 200));
case BootstrapState.askWipeSsss:
// Existing SSSS found — don't wipe, use it
setState(() {
_phase = _Phase.working;
_statusMessage = 'Found existing security setup...';
});
_bootstrap.wipeSsss(false);
case BootstrapState.askUseExistingSsss:
// Use the existing SSSS
_bootstrap.useExistingSsss(true);
case BootstrapState.openExistingSsss:
// Need the recovery key from the user
if (!_bootstrap.newSsssKey!.isUnlocked) {
setState(() => _phase = _Phase.needsRecoveryKey);
return; // Wait for user input
}
// Already unlocked, continue
setState(() {
_phase = _Phase.working;
_statusMessage = 'Verifying security keys...';
});
await _bootstrap.openExistingSsss();
case BootstrapState.askBadSsss:
// Ignore bad secrets and continue
_bootstrap.ignoreBadSecrets(true);
case BootstrapState.askUnlockSsss:
// Old keys need unlocking — this is a migration scenario
if (_bootstrap.oldSsssKeys != null) {
for (final key in _bootstrap.oldSsssKeys!.values) {
if (!key.isUnlocked) {
setState(() => _phase = _Phase.needsRecoveryKey);
return; // Wait for user input
}
}
}
_bootstrap.unlockedSsss();
case BootstrapState.askNewSsss:
// Create new SSSS (no passphrase — recovery key only)
setState(() {
_phase = _Phase.working;
_statusMessage = 'Creating security keys...';
});
await _bootstrap.newSsss();
case BootstrapState.askSetupCrossSigning:
setState(() => _statusMessage = 'Setting up cross-signing...');
await _bootstrap.askSetupCrossSigning(
setupMasterKey: true,
setupSelfSigningKey: true,
setupUserSigningKey: true,
);
case BootstrapState.askWipeCrossSigning:
// Don't wipe existing cross-signing
await _bootstrap.wipeCrossSigning(false);
case BootstrapState.askSetupOnlineKeyBackup:
setState(() => _statusMessage = 'Creating key backup...');
await _bootstrap.askSetupOnlineKeyBackup(true);
case BootstrapState.askWipeOnlineKeyBackup:
// Don't wipe existing backup
_bootstrap.wipeOnlineKeyBackup(false);
case BootstrapState.done:
case BootstrapState.error:
break; // Exit loop
}
}
if (_bootstrap.state == BootstrapState.error) {
_setError('Setup failed. Please try again.');
return;
}
// Success — show recovery key if we created new SSSS
_recoveryKey = _bootstrap.newSsssKey?.recoveryKey;
if (_recoveryKey != null && _phase != _Phase.needsRecoveryKey) {
// New setup — show the recovery key
setState(() => _phase = _Phase.showRecoveryKey);
} else {
// Existing setup — restore keys from backup
await _restoreKeys();
}
}
/// Called when the user enters their recovery key for existing SSSS.
Future<void> _unlockWithRecoveryKey() async {
final key = _keyController.text.trim();
if (key.isEmpty) return;
setState(() {
_phase = _Phase.working;
_statusMessage = 'Verifying recovery key...';
});
try {
// Unlock whichever SSSS key is waiting
if (_bootstrap.newSsssKey != null && !_bootstrap.newSsssKey!.isUnlocked) {
await _bootstrap.newSsssKey!.unlock(keyOrPassphrase: key);
}
if (_bootstrap.oldSsssKeys != null) {
for (final ssssKey in _bootstrap.oldSsssKeys!.values) {
if (!ssssKey.isUnlocked) {
await ssssKey.unlock(keyOrPassphrase: key);
}
}
}
} on InvalidPassphraseException {
_setError('Invalid recovery key. Please check and try again.');
return;
} on Exception catch (e) {
_setError('Could not unlock: ${e.toString().split('\n').first}');
return;
}
// Continue the bootstrap
try {
await _driveBootstrap();
} catch (e) {
_setError(e.toString().split('\n').first);
}
}
/// Download all keys from the online key backup after SSSS is unlocked.
Future<void> _restoreKeys() async {
setState(() {
_phase = _Phase.restoring;
_statusMessage = 'Downloading message keys from backup...';
});
try {
await widget.client.encryption?.keyManager.loadAllKeys();
debugPrint('[Security] All keys loaded from backup');
} on MatrixException catch (e) {
if (e.errcode != 'M_NOT_FOUND') {
debugPrint('[Security] Key restore error: ${e.errorMessage}');
}
// M_NOT_FOUND just means no backup exists yet — not an error
} on Exception catch (e) {
debugPrint('[Security] Key restore error: $e');
}
setState(() => _phase = _Phase.done);
}
void _setError(String message) {
setState(() {
_phase = _Phase.error;
_error = message;
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(switch (_phase) {
_Phase.checking => 'Security Setup',
_Phase.needsRecoveryKey => 'Enter Recovery Key',
_Phase.working || _Phase.restoring => 'Setting Up...',
_Phase.showRecoveryKey => 'Save Your Recovery Key',
_Phase.done => 'Security Setup Complete',
_Phase.error => 'Setup Error',
}),
content: SizedBox(
width: 400,
child: switch (_phase) {
_Phase.checking || _Phase.working || _Phase.restoring => Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(_statusMessage, textAlign: TextAlign.center),
],
),
_Phase.needsRecoveryKey => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter your recovery key to unlock your encrypted '
'messages. This is the key shown when you first set '
'up encryption in Element.',
),
const SizedBox(height: 16),
TextField(
controller: _keyController,
decoration: const InputDecoration(
hintText: 'EsTc oRgW rqHN...',
labelText: 'Recovery key or passphrase',
border: OutlineInputBorder(),
),
maxLines: 3,
onSubmitted: (_) => _unlockWithRecoveryKey(),
),
],
),
_Phase.showRecoveryKey => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Your recovery key has been created. Save it somewhere '
'safe. You will need it to restore your messages on '
'new devices.',
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
child: SelectableText(
_recoveryKey ?? '',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 14,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 12),
Row(
children: [
const Icon(Icons.warning_amber, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
'If you lose this key, you will not be able to '
'read your encrypted messages on new devices.',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
),
_Phase.done => const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.verified, color: Colors.green, size: 48),
SizedBox(height: 16),
Text(
'Security is set up. Cross-signing is active and '
'your message keys are backed up. Previously '
'encrypted messages should now be readable.',
),
],
),
_Phase.error => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(_error ?? 'An unknown error occurred.'),
],
),
},
),
actions: switch (_phase) {
_Phase.checking || _Phase.working || _Phase.restoring => [],
_Phase.needsRecoveryKey => [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _unlockWithRecoveryKey,
child: const Text('Unlock'),
),
],
_Phase.showRecoveryKey => [
TextButton(
onPressed: () {
Clipboard.setData(
ClipboardData(text: _recoveryKey ?? ''));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Recovery key copied to clipboard')),
);
},
child: const Text('Copy'),
),
ElevatedButton(
onPressed: () async {
await _restoreKeys();
},
child: const Text('I saved it, continue'),
),
],
_Phase.done => [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Done'),
),
],
_Phase.error => [
TextButton(
onPressed: () {
setState(() => _phase = _Phase.needsRecoveryKey);
},
child: const Text('Try again'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Close'),
),
],
},
);
}
}

View File

@@ -1,4 +1,4 @@
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-27
// Rooms repository. Reads room list from the Matrix SDK client. // Rooms repository. Reads room list from the Matrix SDK client.
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';

View File

@@ -0,0 +1,225 @@
// Version: 1.0.0 | Created: 2026-04-27
// Public room browser — search Matrix room directory and join rooms.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix_api_lite.dart'
show PublicRoomQueryFilter, PublicRoomsChunk;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/network/matrix_client.dart';
import '../../../shared/widgets/matrix_avatar.dart';
part 'public_rooms_dialog.g.dart';
@riverpod
Future<List<PublicRoomsChunk>> publicRooms(
Ref ref, String searchTerm) async {
final client = ref.watch(matrixClientProvider);
final filter = searchTerm.trim().isEmpty
? null
: PublicRoomQueryFilter(genericSearchTerm: searchTerm.trim());
final response = await client.queryPublicRooms(
filter: filter,
limit: 30,
);
return response.chunk;
}
Future<void> showPublicRoomsDialog(BuildContext context) {
return showDialog<void>(
context: context,
builder: (_) => const _PublicRoomsDialog(),
);
}
class _PublicRoomsDialog extends ConsumerStatefulWidget {
const _PublicRoomsDialog();
@override
ConsumerState<_PublicRoomsDialog> createState() =>
_PublicRoomsDialogState();
}
class _PublicRoomsDialogState extends ConsumerState<_PublicRoomsDialog> {
final _controller = TextEditingController();
String _searchTerm = '';
Timer? _debounce;
String? _joiningRoomId;
@override
void dispose() {
_debounce?.cancel();
_controller.dispose();
super.dispose();
}
void _onSearchChanged(String val) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 450), () {
if (mounted) setState(() => _searchTerm = val);
});
}
Future<void> _join(PublicRoomsChunk room) async {
if (_joiningRoomId != null) return;
setState(() => _joiningRoomId = room.roomId);
try {
final client = ref.read(matrixClientProvider);
await client.joinRoom(room.canonicalAlias ?? room.roomId);
if (!mounted) return;
Navigator.of(context).pop();
context.push('/rooms/${Uri.encodeComponent(room.roomId)}');
} on Exception catch (e) {
if (!mounted) return;
setState(() => _joiningRoomId = null);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not join room: $e')),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final roomsAsync = ref.watch(publicRoomsProvider(_searchTerm));
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 600),
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 4, 0),
child: Row(
children: [
Text(
'Find rooms',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Search
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: _controller,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search public rooms...',
prefixIcon: Icon(Icons.search),
),
onChanged: _onSearchChanged,
),
),
// Results
Flexible(
child: roomsAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (err, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('Search failed: $err'),
),
data: (rooms) {
if (rooms.isEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Text(
_searchTerm.isEmpty
? 'No public rooms found.'
: 'No rooms match "$_searchTerm".',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102),
),
),
);
}
return ListView.separated(
shrinkWrap: true,
itemCount: rooms.length,
separatorBuilder: (_, __) =>
const Divider(height: 1, indent: 72),
itemBuilder: (context, index) {
final room = rooms[index];
final name = room.name ??
room.canonicalAlias ??
room.roomId;
final isJoining = _joiningRoomId == room.roomId;
return ListTile(
leading: MatrixAvatar(
name: name,
avatarUrl: room.avatarUrl?.toString(),
radius: 20,
),
title: Text(
name,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (room.topic != null)
Text(
room.topic!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
Text(
'${room.numJoinedMembers} member'
'${room.numJoinedMembers == 1 ? '' : 's'}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface
.withAlpha(120),
),
),
],
),
isThreeLine: room.topic != null,
trailing: isJoining
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: OutlinedButton(
onPressed: _joiningRoomId != null
? null
: () => _join(room),
child: const Text('Join'),
),
);
},
);
},
),
),
],
),
),
);
}
}

View File

@@ -1,9 +1,10 @@
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Individual room list tile. Kept under 100 lines. // Individual room list tile. Kept under 120 lines.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:timeago/timeago.dart' as timeago; import 'package:timeago/timeago.dart' as timeago;
import '../../../app/theme.dart';
import '../../../shared/widgets/matrix_avatar.dart'; import '../../../shared/widgets/matrix_avatar.dart';
import '../domain/room_model.dart'; import '../domain/room_model.dart';
@@ -19,6 +20,8 @@ class RoomTile extends StatelessWidget {
final hasUnread = room.unreadCount > 0; final hasUnread = room.unreadCount > 0;
return ListTile( return ListTile(
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: MatrixAvatar( leading: MatrixAvatar(
name: room.displayName, name: room.displayName,
avatarUrl: room.avatarUrl, avatarUrl: room.avatarUrl,
@@ -38,12 +41,15 @@ class RoomTile extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153), color: hasUnread
? theme.colorScheme.onSurface.withAlpha(230)
: M8Colours.subtleText,
fontWeight: hasUnread ? FontWeight.w500 : FontWeight.normal,
), ),
) )
: null, : null,
trailing: Column( trailing: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (room.lastActivityAt != null) if (room.lastActivityAt != null)
@@ -52,13 +58,14 @@ class RoomTile extends StatelessWidget {
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: hasUnread color: hasUnread
? theme.colorScheme.primary ? theme.colorScheme.primary
: theme.colorScheme.onSurface.withAlpha(102), : M8Colours.subtleText,
), ),
), ),
if (hasUnread) ...[ if (hasUnread) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
_UnreadBadge(count: room.unreadCount), _UnreadBadge(count: room.unreadCount),
], ] else
const SizedBox(height: 8),
], ],
), ),
onTap: onTap, onTap: onTap,

View File

@@ -1,12 +1,21 @@
// Version: 1.1.0 | Created: 2026-04-01 // Version: 1.7.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Main rooms list screen with bottom navigation. // Main rooms list screen with bottom navigation.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'dart:async';
import '../../../app/theme.dart';
import '../../../core/network/matrix_client.dart';
import '../../help/presentation/help_tab.dart';
import '../../panic/presentation/panic_button.dart';
import '../../jitsi/presentation/conference_tab.dart';
import '../../profile/presentation/key_restore_prompt.dart';
import '../../profile/presentation/profile_screen.dart'; import '../../profile/presentation/profile_screen.dart';
import '../../spaces/presentation/spaces_screen.dart'; import '../../spaces/presentation/spaces_screen.dart';
import 'public_rooms_dialog.dart';
import 'room_tile.dart'; import 'room_tile.dart';
import 'rooms_controller.dart'; import 'rooms_controller.dart';
import 'user_search_dialog.dart'; import 'user_search_dialog.dart';
@@ -20,6 +29,36 @@ class RoomsScreen extends ConsumerStatefulWidget {
class _RoomsScreenState extends ConsumerState<RoomsScreen> { class _RoomsScreenState extends ConsumerState<RoomsScreen> {
int _selectedIndex = 0; int _selectedIndex = 0;
bool _searchActive = false;
String _searchQuery = '';
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
// Offer encrypted-history restore right after login (fires once).
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final client = ref.read(matrixClientProvider);
unawaited(maybePromptKeyRestore(context, client));
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _toggleSearch() {
setState(() {
_searchActive = !_searchActive;
if (!_searchActive) {
_searchQuery = '';
_searchController.clear();
}
});
}
static const _destinations = [ static const _destinations = [
NavigationDestination( NavigationDestination(
@@ -37,13 +76,25 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
selectedIcon: Icon(Icons.person), selectedIcon: Icon(Icons.person),
label: 'Profile', label: 'Profile',
), ),
NavigationDestination(
icon: Icon(Icons.videocam_outlined),
selectedIcon: Icon(Icons.videocam),
label: 'Conference',
),
NavigationDestination(
icon: Icon(Icons.help_outline),
selectedIcon: Icon(Icons.help),
label: 'Help',
),
]; ];
Widget _buildBody() { Widget _buildBody() {
return switch (_selectedIndex) { return switch (_selectedIndex) {
0 => const _RoomListBody(), 0 => _RoomListBody(searchQuery: _searchQuery),
1 => const SpacesScreen(embedded: true), 1 => const SpacesScreen(embedded: true),
2 => const ProfileScreen(embedded: true), 2 => const ProfileScreen(embedded: true),
3 => const ConferenceTab(),
4 => const HelpTab(),
_ => const _RoomListBody(), _ => const _RoomListBody(),
}; };
} }
@@ -53,24 +104,42 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
return Scaffold( return Scaffold(
appBar: _selectedIndex == 0 appBar: _selectedIndex == 0
? AppBar( ? AppBar(
title: const Text('M8Chat'), title: _searchActive
? TextField(
controller: _searchController,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search rooms...',
border: InputBorder.none,
),
onChanged: (v) => setState(() => _searchQuery = v),
)
: const Text('M8Chat'),
actions: [ actions: [
const PanicButton(),
IconButton( IconButton(
icon: const Icon(Icons.search), icon: Icon(_searchActive ? Icons.close : Icons.search),
tooltip: 'Search rooms', tooltip: _searchActive ? 'Cancel search' : 'Search rooms',
onPressed: () { onPressed: _toggleSearch,
// Phase 2: room search
},
),
IconButton(
icon: const Icon(Icons.edit_square),
tooltip: 'New message',
onPressed: () => showUserSearchDialog(context),
), ),
if (!_searchActive)
IconButton(
icon: const Icon(Icons.explore_outlined),
tooltip: 'Find rooms',
onPressed: () => showPublicRoomsDialog(context),
),
], ],
) )
: null, : null,
body: _buildBody(), body: _buildBody(),
floatingActionButton: _selectedIndex == 0 && !_searchActive
? FloatingActionButton.extended(
onPressed: () => showUserSearchDialog(context),
tooltip: 'New message',
icon: const Icon(Icons.edit_square),
label: const Text('New message'),
)
: null,
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex, selectedIndex: _selectedIndex,
onDestinationSelected: (index) => onDestinationSelected: (index) =>
@@ -82,7 +151,9 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
} }
class _RoomListBody extends ConsumerWidget { class _RoomListBody extends ConsumerWidget {
const _RoomListBody(); const _RoomListBody({this.searchQuery = ''});
final String searchQuery;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -118,14 +189,39 @@ class _RoomListBody extends ConsumerWidget {
), ),
), ),
data: (rooms) { data: (rooms) {
if (rooms.isEmpty) { final filtered = searchQuery.trim().isEmpty
return const _EmptyRoomsState(); ? rooms
: rooms
.where((r) => r.displayName
.toLowerCase()
.contains(searchQuery.trim().toLowerCase()))
.toList();
if (filtered.isEmpty) {
return searchQuery.isNotEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
'No rooms match "$searchQuery".',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(153),
),
),
),
)
: const _EmptyRoomsState();
} }
return ListView.separated( return ListView.separated(
itemCount: rooms.length, itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(height: 1, indent: 72), separatorBuilder: (_, __) => const Divider(height: 1, indent: 72),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final room = rooms[index]; final room = filtered[index];
return RoomTile( return RoomTile(
room: room, room: room,
onTap: () => onTap: () =>
@@ -159,16 +255,24 @@ class _EmptyRoomsState extends StatelessWidget {
Text( Text(
'No rooms yet', 'No rooms yet',
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153), color: M8Colours.subtleText,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Rooms you join will appear here.', 'Tap the New message button to message someone, or find '
'a public room to join.',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102), color: M8Colours.subtleText,
), ),
), ),
const SizedBox(height: 20),
OutlinedButton.icon(
onPressed: () => showPublicRoomsDialog(context),
icon: const Icon(Icons.explore_outlined),
label: const Text('Find rooms'),
),
], ],
), ),
), ),

View File

@@ -1,15 +1,33 @@
// Version: 1.0.1 | Created: 2026-04-01 // Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-27
// Application entry point. // Application entry point.
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:olm/olm.dart' as olm;
import 'app/app.dart'; import 'app/app.dart';
import 'core/push/web_push_service.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Check Olm availability — this prints to the browser console.
try {
await olm.init();
debugPrint('[M8Chat] Olm initialized successfully: ${olm.get_library_version()}');
} catch (e) {
debugPrint('[M8Chat] Olm NOT available: $e');
}
// Register the push service worker at startup so the browser knows about it.
// The actual push subscription happens after login (in auth_notifier.dart).
if (kIsWeb) {
unawaited(registerPushServiceWorker());
}
// Catch Flutter framework errors (widget build, rendering) without crashing. // Catch Flutter framework errors (widget build, rendering) without crashing.
FlutterError.onError = (details) { FlutterError.onError = (details) {
debugPrint('[M8Chat] Flutter error: ${details.exceptionAsString()}'); debugPrint('[M8Chat] Flutter error: ${details.exceptionAsString()}');

View File

@@ -1,28 +1,32 @@
// Version: 1.0.0 | Created: 2026-04-02 // Version: 2.0.0 | Created: 2026-04-02 | Updated: 2026-04-11
// Synchronous MXC URI to HTTP URL resolution. // Synchronous MXC URI to HTTP URL resolution.
// Avatars and thumbnails in the room list / sync service need a resolved HTTP
// URL. The Matrix SDK's getDownloadUri() is async (checks authenticated media
// support), but for avatar display we need a synchronous result.
// //
// This helper builds the legacy v3 media URL which works on all homeservers. // Synapse 1.120+ requires authenticated media downloads. The old
// /_matrix/media/v3/download/ endpoint is frozen and returns 404.
// We use /_matrix/client/v1/media/download/ with the access token.
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
/// Resolves an `mxc://` [Uri] to an HTTP download URL using the client's /// Resolves an `mxc://` [Uri] to an authenticated HTTP download URL.
/// homeserver. Returns `null` if the URI is not an mxc scheme or the client /// Returns `null` if the URI is not mxc:// or the client is not connected.
/// has no homeserver set.
///
/// This is synchronous — suitable for use in non-async model mapping.
String? resolveMxcUrl(Client client, Uri? mxcUri) { String? resolveMxcUrl(Client client, Uri? mxcUri) {
if (mxcUri == null || !mxcUri.isScheme('mxc')) return null; if (mxcUri == null || !mxcUri.isScheme('mxc')) return null;
final homeserver = client.homeserver; final homeserver = client.homeserver;
if (homeserver == null) return null; if (homeserver == null) return null;
// Build the media download path per the Matrix spec.
final serverName = mxcUri.host; final serverName = mxcUri.host;
final port = mxcUri.hasPort ? ':${mxcUri.port}' : ''; final port = mxcUri.hasPort ? ':${mxcUri.port}' : '';
final mediaId = mxcUri.path; // includes leading / final mediaId = mxcUri.path; // includes leading /
return homeserver
.resolve('_matrix/media/v3/download/$serverName$port$mediaId') // Use the authenticated media endpoint (MSC3916).
final base = homeserver
.resolve('_matrix/client/v1/media/download/$serverName$port$mediaId')
.toString(); .toString();
// Append the access token so CachedNetworkImage / Image.network can
// fetch without custom headers. The token is already in the browser's
// JS memory so this doesn't expand the attack surface.
final token = client.accessToken;
if (token == null) return base;
return '$base?access_token=$token';
} }

View File

@@ -1,4 +1,4 @@
// Version: 1.0.0 | Created: 2026-04-02 // Version: 1.1.1 | Created: 2026-04-02 | Updated: 2026-07-03
// Shared utility for generating a last-message preview string from a Room. // Shared utility for generating a last-message preview string from a Room.
// Used by both RoomsRepository and SyncPersistenceService to avoid duplication. // Used by both RoomsRepository and SyncPersistenceService to avoid duplication.
@@ -12,7 +12,7 @@ String? lastMessagePreview(Room room) {
return switch (lastEvent.type) { return switch (lastEvent.type) {
EventTypes.Message => lastEvent.body, EventTypes.Message => lastEvent.body,
EventTypes.Encrypted => 'Encrypted message', EventTypes.Encrypted => '\u{1F512} Encrypted. Use Element to read',
EventTypes.Sticker => 'Sticker', EventTypes.Sticker => 'Sticker',
_ => null, _ => null,
}; };

View File

@@ -1,7 +1,8 @@
// Version: 1.0.0 | Created: 2026-04-02 // Version: 1.1.0 | Created: 2026-04-02 | Updated: 2026-07-04
// Shared avatar widget used throughout the app. Displays a cached network // Shared avatar widget used throughout the app. Displays a cached network
// image when an HTTP avatar URL is available, or falls back to a coloured // image when an HTTP avatar URL is available, or falls back to a circle
// circle with the first letter of the display name. // coloured deterministically from the display name (so each person gets a
// stable, distinct disc) with the first letter of the display name.
// //
// The [avatarUrl] MUST be a resolved HTTP URL — never pass an mxc:// URI. // The [avatarUrl] MUST be a resolved HTTP URL — never pass an mxc:// URI.
@@ -25,6 +26,21 @@ class MatrixAvatar extends StatelessWidget {
/// Radius of the [CircleAvatar]. /// Radius of the [CircleAvatar].
final double radius; final double radius;
/// Brand-compatible hues for initials avatars. All dark enough for white
/// initials to remain legible.
static const List<Color> _palette = [
Color(0xFF1265ED), // brand blue
Color(0xFF2E7DE0), // sky
Color(0xFF1E88A8), // teal
Color(0xFF3949AB), // indigo
Color(0xFF6A4CC0), // violet
Color(0xFF00897B), // green-teal
Color(0xFF5C6BC0), // periwinkle
Color(0xFFB1631F), // warm amber
];
Color get _discColour => _palette[name.hashCode.abs() % _palette.length];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -40,13 +56,13 @@ class MatrixAvatar extends StatelessWidget {
return CircleAvatar( return CircleAvatar(
radius: radius, radius: radius,
backgroundColor: theme.colorScheme.primary.withAlpha(51), backgroundColor: _discColour,
child: Text( child: Text(
initials, initials,
style: TextStyle( style: TextStyle(
fontSize: radius * 0.7, fontSize: radius * 0.7,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: theme.colorScheme.primary, color: Colors.white,
), ),
), ),
); );

View File

@@ -1,7 +1,7 @@
name: m8chat_app name: m8chat_app
description: "M8Chat — Matrix chat client for Android, iOS, and Web." description: "M8Chat — Matrix chat client for Android, iOS, and Web."
publish_to: 'none' publish_to: 'none'
version: 1.1.0+2 version: 1.11.0+15
environment: environment:
sdk: '>=3.11.0 <4.0.0' sdk: '>=3.11.0 <4.0.0'
@@ -12,6 +12,7 @@ dependencies:
# Matrix SDK # Matrix SDK
matrix: ^0.33.0 matrix: ^0.33.0
olm: ^2.0.4
# State management # State management
flutter_riverpod: ^3.0.0 flutter_riverpod: ^3.0.0
@@ -42,6 +43,9 @@ dependencies:
# Emoji reactions # Emoji reactions
emoji_picker_flutter: ^4.0.0 emoji_picker_flutter: ^4.0.0
# Web platform (required for dart:js_interop DOM access)
web: ^1.1.0
# UI # UI
cached_network_image: ^3.4.0 cached_network_image: ^3.4.0
timeago: ^3.7.0 timeago: ^3.7.0

View File

@@ -0,0 +1,104 @@
// Version: 1.0.0 | Created: 2026-07-04
// Verifies the chat timeline requests older history when scrolled near the
// top (oldest end) of the reverse list. Driven through the real widget tree
// with a fake repository, since Playwright cannot scroll the CanvasKit surface.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:m8chat_app/core/network/matrix_client.dart';
import 'package:m8chat_app/features/chat/data/chat_repository.dart';
import 'package:m8chat_app/features/chat/domain/message_model.dart';
import 'package:m8chat_app/features/chat/presentation/chat_controller.dart';
import 'package:m8chat_app/features/chat/presentation/chat_screen.dart';
class _FakeChatRepository extends ChatRepository {
_FakeChatRepository() : super(client: Client('test-client'));
int loadMoreCalls = 0;
bool _hasMore = true;
@override
bool canLoadMore(String roomId) => _hasMore;
@override
Future<void> loadMoreMessages(String roomId) async {
loadMoreCalls++;
_hasMore = false; // one page then exhausted
}
}
void main() {
testWidgets('scrolling to the top requests older history once', (
tester,
) async {
const roomId = '!paginate:server';
final base = DateTime(2026, 7, 4, 8);
final messages = [
for (var i = 40; i >= 1; i--)
MessageModel(
eventId: '\$e$i',
roomId: roomId,
senderId: '@me:server',
senderDisplayName: 'Me',
timestamp: base.add(Duration(seconds: i)),
type: MessageType.text,
body: 'Message $i',
isMine: true,
),
];
final repo = _FakeChatRepository();
await tester.pumpWidget(
ProviderScope(
overrides: [
matrixClientProvider.overrideWithValue(Client('test-client')),
chatRepositoryProvider.overrideWithValue(repo),
chatTimelineProvider(
roomId,
).overrideWith((ref) => Stream.value(messages)),
],
child: const MaterialApp(home: ChatScreen(roomId: roomId)),
),
);
await tester.pumpAndSettle();
expect(repo.loadMoreCalls, 0);
// Confirm the override is actually in effect.
final container = ProviderScope.containerOf(
tester.element(find.byType(ChatScreen)),
);
expect(identical(container.read(chatRepositoryProvider), repo), isTrue);
double offset() => tester
.state<ScrollableState>(find.byType(Scrollable).first)
.position
.pixels;
double maxExtent() => tester
.state<ScrollableState>(find.byType(Scrollable).first)
.position
.maxScrollExtent;
// Scroll up toward the oldest end (reverse list: drag content down).
await tester.drag(find.byType(ListView), const Offset(0, 3000));
await tester.pumpAndSettle();
expect(
offset(),
greaterThan(maxExtent() - 400),
reason:
'drag should have reached the oldest end (offset=${offset()}, '
'max=${maxExtent()})',
);
expect(
repo.loadMoreCalls,
greaterThan(0),
reason: 'reaching the top should request older history',
);
});
}

View File

@@ -0,0 +1,80 @@
// Version: 1.0.0 | Created: 2026-07-04
// Deterministic test for the chat timeline's scroll-to-latest button.
// Playwright cannot drive scrolling on the Flutter CanvasKit surface, so the
// jump-to-latest behaviour is verified here against the real widget tree.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:m8chat_app/core/network/matrix_client.dart';
import 'package:m8chat_app/features/chat/domain/message_model.dart';
import 'package:m8chat_app/features/chat/presentation/chat_controller.dart';
import 'package:m8chat_app/features/chat/presentation/chat_screen.dart';
void main() {
testWidgets(
'jump-to-latest button appears when scrolled up and returns to bottom',
(tester) async {
const roomId = '!scrolltest:server';
final base = DateTime(2026, 7, 4, 8);
// Index 0 is the newest message (the timeline stream is newest-first).
final messages = [
for (var i = 40; i >= 1; i--)
MessageModel(
eventId: '\$e$i',
roomId: roomId,
senderId: '@me:server',
senderDisplayName: 'Me',
timestamp: base.add(Duration(seconds: i)),
type: MessageType.text,
body: 'Message $i',
isMine: true,
),
];
await tester.pumpWidget(
ProviderScope(
overrides: [
matrixClientProvider.overrideWithValue(Client('test-client')),
chatTimelineProvider(
roomId,
).overrideWith((ref) => Stream.value(messages)),
],
child: const MaterialApp(home: ChatScreen(roomId: roomId)),
),
);
await tester.pumpAndSettle();
final listView = find.byType(ListView);
expect(listView, findsOneWidget);
double offset() =>
tester.state<ScrollableState>(find.byType(Scrollable).first)
.position
.pixels;
// Starts pinned to the newest message (offset 0 in a reverse list).
expect(offset(), 0);
// Scroll up toward older messages (reverse list: drag content down).
await tester.drag(listView, const Offset(0, 900));
await tester.pumpAndSettle();
expect(
offset(),
greaterThan(400),
reason: 'should have scrolled well past the button threshold',
);
// The jump-to-latest button is now shown; tap it.
final jumpButton = find.byIcon(Icons.arrow_downward);
expect(jumpButton, findsOneWidget);
await tester.tap(jumpButton);
await tester.pumpAndSettle();
// Back at the newest message.
expect(offset(), 0);
},
);
}

15
web/.htaccess Normal file
View File

@@ -0,0 +1,15 @@
# Required for SharedArrayBuffer — needed by LiveKit E2EE frame encryption.
# Without COOP+COEP, the LiveKit E2EE web worker cannot be created.
<IfModule mod_headers.c>
Header set Cross-Origin-Opener-Policy "same-origin"
Header set Cross-Origin-Embedder-Policy "require-corp"
</IfModule>
# Flutter HTML5 routing — all unknown paths serve index.html
Options -Indexes
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.html [L,QSA]

View File

@@ -18,12 +18,12 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project."> <meta name="description" content="M8Chat — secure chat and conferencing.">
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="m8chat_app"> <meta name="apple-mobile-web-app-title" content="M8Chat">
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon --> <!-- Favicon -->
@@ -49,12 +49,43 @@
} }
</script> </script>
<!-- <!--
Olm (E2EE) disabled for Phase 1 web builds. The Matrix SDK gracefully Olm (E2EE): load olm.js synchronously, await Olm.init() for WASM, THEN
skips encryption when Olm is not loaded — unencrypted rooms work fine. inject flutter_bootstrap.js so the Matrix SDK finds global Olm ready.
E2EE requires persistent Olm account storage (IndexedDB) to avoid If Olm fails to load (e.g. WASM blocked by browser), Flutter boots anyway
device key conflicts on /keys/upload (400). Deferred to Phase 3. without encryption — unencrypted rooms still work.
The olm.js and olm.wasm files remain deployed for future use.
--> -->
<script src="flutter_bootstrap.js" async></script> <script src="olm.js"></script>
<script>
(function() {
function bootFlutter() {
var s = document.createElement('script');
s.src = 'flutter_bootstrap.js';
s.async = true;
document.body.appendChild(s);
}
// Ensure Olm is accessible on both window and globalThis.
// dart2js may resolve bare 'Olm' via different global references.
if (typeof Olm !== 'undefined') {
window.Olm = Olm;
if (typeof globalThis !== 'undefined') globalThis.Olm = Olm;
if (typeof self !== 'undefined') self.Olm = Olm;
}
if (typeof Olm !== 'undefined' && Olm.init) {
console.log('[M8Chat] Olm.js loaded, calling Olm.init()...');
Olm.init().then(function() {
console.log('[M8Chat] Olm.init() SUCCESS — E2EE available');
console.log('[M8Chat] window.Olm =', window.Olm);
console.log('[M8Chat] typeof Olm.Account =', typeof Olm.Account);
bootFlutter();
}).catch(function(e) {
console.warn('[M8Chat] Olm init FAILED, booting without E2EE:', e);
bootFlutter();
});
} else {
console.warn('[M8Chat] olm.js did NOT load — typeof Olm =', typeof Olm);
bootFlutter();
}
})();
</script>
</body> </body>
</html> </html>

View File

@@ -1,11 +1,11 @@
{ {
"name": "m8chat_app", "name": "M8Chat",
"short_name": "m8chat_app", "short_name": "M8Chat",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#0A0F2E",
"theme_color": "#0175C2", "theme_color": "#1265ED",
"description": "A new Flutter project.", "description": "M8Chat — secure chat and conferencing.",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,
"icons": [ "icons": [

64
web/push_sw.js Normal file
View File

@@ -0,0 +1,64 @@
// Version: 1.0.0 | Created: 2026-04-27
// Web Push service worker for M8Chat.
// Receives push events from the browser and shows system notifications.
// This script is registered separately from flutter_service_worker.js.
'use strict';
self.addEventListener('push', function (event) {
if (!event.data) return;
let data = {};
try {
data = event.data.json();
} catch (_) {
data = { notification: { body: event.data.text() } };
}
// Sygnal delivers: { notification: { content: { body, msgtype, ... }, room_name, sender_display_name, ... } }
const notif = data.notification || {};
const content = notif.content || {};
const sender = notif.sender_display_name || notif.sender || 'M8Chat';
const room = notif.room_name || notif.room_alias || '';
const body = content.body || notif.body || 'New message';
const title = room ? `${sender} in ${room}` : sender;
const options = {
body: body,
icon: '/icons/Icon-192.png',
badge: '/icons/Icon-192.png',
tag: notif.room_id || 'm8chat',
data: {
roomId: notif.room_id,
url: self.location.origin + (notif.room_id
? '/rooms/' + encodeURIComponent(notif.room_id)
: '/rooms'),
},
requireInteraction: false,
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', function (event) {
event.notification.close();
const url = (event.notification.data && event.notification.data.url)
? event.notification.data.url
: self.location.origin + '/rooms';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) {
for (const client of clientList) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});