Compare commits

..

7 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
32 changed files with 1607 additions and 221 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,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,4 +1,4 @@
// Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-04-11 // 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.
abstract final class AppConfig { abstract final class AppConfig {
@@ -11,7 +11,7 @@ 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.3.0'; static const String appVersion = '1.11.0';
// Jitsi conferencing // Jitsi conferencing
static const String jitsiDomain = 'conf.m8chat.au'; static const String jitsiDomain = 'conf.m8chat.au';

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

@@ -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.3.0 | Created: 2026-04-01 | Updated: 2026-04-10 // 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.

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,19 +1,23 @@
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-27 // 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) + room options sheet. // + 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:intl/intl.dart';
import 'package:matrix/matrix.dart' show MatrixFile, Room; 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 '../../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';
@@ -125,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()),
@@ -155,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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -182,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,
),
); );
} }
@@ -202,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();
@@ -217,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);
@@ -229,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,
@@ -265,7 +508,7 @@ class _RoomOptionsSheet extends ConsumerStatefulWidget {
class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> { class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
bool _leaving = false; bool _leaving = false;
Future<void> _leaveRoom(BuildContext context) async { Future<void> _leaveRoom() async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (_) => AlertDialog( builder: (_) => AlertDialog(
@@ -302,9 +545,9 @@ class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
} on Exception catch (e) { } on Exception catch (e) {
if (mounted) { if (mounted) {
setState(() => _leaving = false); setState(() => _leaving = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Could not leave room: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Could not leave room: $e')));
} }
} }
} }
@@ -365,10 +608,8 @@ class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
itemCount: members.length, itemCount: members.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final member = members[index]; final member = members[index];
final name = final name = member.displayName ?? member.id.matrixLocalpart;
member.displayName ?? member.id.matrixLocalpart; final avatarUrl = resolveMxcUrl(client, member.avatarUrl);
final avatarUrl =
resolveMxcUrl(client, member.avatarUrl);
final isMe = member.id == client.userID; final isMe = member.id == client.userID;
return ListTile( return ListTile(
@@ -412,16 +653,13 @@ class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
height: 24, height: 24,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: Icon( : Icon(Icons.exit_to_app, color: theme.colorScheme.error),
Icons.exit_to_app,
color: theme.colorScheme.error,
),
title: Text( title: Text(
'Leave room', 'Leave room',
style: TextStyle(color: theme.colorScheme.error), style: TextStyle(color: theme.colorScheme.error),
), ),
enabled: !_leaving, enabled: !_leaving,
onTap: () => _leaveRoom(context), onTap: _leaveRoom,
), ),
], ],
), ),
@@ -434,17 +672,24 @@ 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
@@ -453,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'),
@@ -469,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.3.0 | Created: 2026-04-01 | Updated: 2026-04-27 // 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(
@@ -145,7 +183,7 @@ class _MessageContentBody extends StatelessWidget {
const SizedBox(width: 6), const SizedBox(width: 6),
Flexible( Flexible(
child: Text( child: Text(
'Encrypted — use Element to read', 'Encrypted. Use Element to read',
style: TextStyle( style: TextStyle(
color: textColour.withAlpha(153), color: textColour.withAlpha(153),
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
@@ -234,14 +272,10 @@ class _ImageViewer extends StatelessWidget {
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: url, imageUrl: url,
fit: BoxFit.contain, fit: BoxFit.contain,
placeholder: (_, __) => const CircularProgressIndicator( placeholder: (_, __) =>
color: Colors.white, const CircularProgressIndicator(color: Colors.white),
), errorWidget: (_, __, ___) =>
errorWidget: (_, __, ___) => const Icon( const Icon(Icons.broken_image, color: Colors.white, size: 64),
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

@@ -1,4 +1,4 @@
// Version: 1.1.0 | Created: 2026-04-11 | Updated: 2026-04-11 // Version: 1.1.1 | Created: 2026-04-11 | Updated: 2026-07-03
// Help tab with in-app guidance for common tasks and troubleshooting. // Help tab with in-app guidance for common tasks and troubleshooting.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -36,7 +36,7 @@ class HelpTab extends ConsumerWidget {
// --- Encrypted messages --- // --- Encrypted messages ---
_HelpCard( _HelpCard(
icon: Icons.lock_outline, icon: Icons.lock_outline,
title: 'I see "Encrypted — use Element to read"', title: 'I see "Encrypted. Use Element to read"',
children: [ children: [
const _HelpParagraph( const _HelpParagraph(
'This means your device does not have the keys needed to ' 'This means your device does not have the keys needed to '
@@ -98,7 +98,7 @@ class HelpTab extends ConsumerWidget {
'1. Set up security: go to Profile > Security & Privacy ' '1. Set up security: go to Profile > Security & Privacy '
'> Security setup. This creates your encryption keys and ' '> Security setup. This creates your encryption keys and '
'backs them up.\n' 'backs them up.\n'
'2. Save your recovery key — you will need it if you ever ' '2. Save your recovery key. You will need it if you ever '
'log in on a new device.\n' 'log in on a new device.\n'
'3. Start chatting. Tap the pencil icon at the top right ' '3. Start chatting. Tap the pencil icon at the top right '
'to search for users and start a conversation.', 'to search for users and start a conversation.',
@@ -156,7 +156,7 @@ class HelpTab extends ConsumerWidget {
_HelpSubheading('Who can read my messages?'), _HelpSubheading('Who can read my messages?'),
_HelpParagraph( _HelpParagraph(
'Only you and the people in the conversation. M8Chat uses ' 'Only you and the people in the conversation. M8Chat uses '
'end-to-end encryption — not even the server operator can ' 'end-to-end encryption. Not even the server operator can '
'read your messages.', 'read your messages.',
), ),
SizedBox(height: 8), SizedBox(height: 8),

View File

@@ -1,4 +1,4 @@
// Version: 1.1.0 | Created: 2026-04-05 | Updated: 2026-04-10 // Version: 1.2.0 | Created: 2026-04-05 | Updated: 2026-07-04
// Web implementation: uses JitsiMeetExternalAPI via dart:js_interop. // Web implementation: uses JitsiMeetExternalAPI via dart:js_interop.
// This file is web-only — guarded by the Flutter web build. // 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. // The external_api.js script is lazy-loaded on first use — not in index.html.
@@ -7,6 +7,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:js_interop'; import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'dart:ui_web' as ui_web; import 'dart:ui_web' as ui_web;
import 'package:web/web.dart' as web; import 'package:web/web.dart' as web;
@@ -77,9 +78,14 @@ class JitsiWebService {
} }
/// Starts a Jitsi meeting inside the container div created by the platform view. /// Starts a Jitsi meeting inside the container div created by the platform view.
/// Call this AFTER the HtmlElementView has been mounted (e.g. in a post-frame callback). /// Call this AFTER the HtmlElementView has been scheduled for mounting
/// Lazy-loads the Jitsi script on first call. /// (e.g. in a post-frame callback). Lazy-loads the Jitsi script on first
Future<void> joinMeeting({ /// 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, required String roomName,
String? jwt, String? jwt,
String? displayName, String? displayName,
@@ -90,53 +96,73 @@ class JitsiWebService {
// Lazy-load the external_api.js script if not already present. // Lazy-load the external_api.js script if not already present.
final loaded = await _ensureScriptLoaded(); final loaded = await _ensureScriptLoaded();
if (!loaded) return; if (!loaded) {
web.console.warn('[Jitsi] external_api.js failed to load'.toJS);
return false;
}
// Find the container div — there should be exactly one with our prefix. // Wait for the container div — the platform view may not be in the DOM
final containers = web.document.querySelectorAll('[id^="jitsi-container-"]'); // yet when the post-frame callback fires.
if (containers.length == 0) return; web.Node? parentNode;
final parentNode = containers.item(containers.length - 1); for (var i = 0; i < 50; i++) {
if (parentNode == null) return; 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;
}
final configOverwrite = <String, Object?>{ // Build the options as a real JS object. Passing a DOM node through
'startAudioMuted': 0, // Map.jsify() is not supported and threw at runtime in the dart2js build.
'startVideoMuted': 0, final configOverwrite = JSObject()
'disableDeepLinking': true, ..setProperty('startAudioMuted'.toJS, 0.toJS)
'prejoinPageEnabled': true, ..setProperty('startVideoMuted'.toJS, 0.toJS)
}.jsify(); ..setProperty('disableDeepLinking'.toJS, true.toJS)
..setProperty('prejoinPageEnabled'.toJS, true.toJS);
final interfaceConfigOverwrite = <String, Object?>{ final interfaceConfigOverwrite = JSObject()
'SHOW_CHROME_EXTENSION_BANNER': false, ..setProperty('SHOW_CHROME_EXTENSION_BANNER'.toJS, false.toJS);
}.jsify();
final options = <String, Object?>{ final options = JSObject()
'roomName': roomName, ..setProperty('roomName'.toJS, roomName.toJS)
'parentNode': parentNode, ..setProperty('parentNode'.toJS, parentNode as JSAny)
'width': '100%', ..setProperty('width'.toJS, '100%'.toJS)
'height': '100%', ..setProperty('height'.toJS, '100%'.toJS)
'configOverwrite': configOverwrite, ..setProperty('configOverwrite'.toJS, configOverwrite)
'interfaceConfigOverwrite': interfaceConfigOverwrite, ..setProperty('interfaceConfigOverwrite'.toJS, interfaceConfigOverwrite);
};
if (jwt != null && jwt.isNotEmpty) { if (jwt != null && jwt.isNotEmpty) {
options['jwt'] = jwt; options.setProperty('jwt'.toJS, jwt.toJS);
} }
if (displayName != null && displayName.isNotEmpty) { if (displayName != null && displayName.isNotEmpty) {
options['userInfo'] = <String, Object?>{ final userInfo = JSObject()
'displayName': displayName, ..setProperty('displayName'.toJS, displayName.toJS);
if (avatarUrl != null) 'avatarUrl': avatarUrl, if (avatarUrl != null) {
}.jsify(); userInfo.setProperty('avatarUrl'.toJS, avatarUrl.toJS);
}
options.setProperty('userInfo'.toJS, userInfo);
} }
final jsOptions = options.jsify(); try {
_api = _createJitsiApi(AppConfig.jitsiDomain.toJS, jsOptions as JSObject); _api = _createJitsiApi(AppConfig.jitsiDomain.toJS, options);
} catch (e) {
web.console.error('[Jitsi] JitsiMeetExternalAPI threw: $e'.toJS);
return false;
}
if (onReadyToClose != null && _api != null) { if (onReadyToClose != null && _api != null) {
_addEventListener(_api!, 'readyToClose'.toJS, () { _addEventListener(_api!, 'readyToClose'.toJS, () {
onReadyToClose(); onReadyToClose();
}.toJS); }.toJS);
} }
return true;
} }
/// Cleans up the Jitsi iframe. /// Cleans up the Jitsi iframe.
@@ -148,17 +174,21 @@ class JitsiWebService {
} }
} }
/// Calls `new JitsiMeetExternalAPI(domain, options)`. /// 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') @JS('JitsiMeetExternalAPI')
external JSObject _createJitsiApi(JSString domain, JSObject options); extension type _JitsiApi._(JSObject _) implements JSObject {
external _JitsiApi(JSString domain, JSObject options);
/// Calls `api.addEventListener(event, callback)`.
@JS()
extension type _JitsiApi(JSObject _) implements JSObject {
external void addEventListener(JSString event, JSFunction callback); external void addEventListener(JSString event, JSFunction callback);
external void dispose(); external void dispose();
} }
JSObject _createJitsiApi(JSString domain, JSObject options) =>
_JitsiApi(domain, options);
void _addEventListener(JSObject api, JSString event, JSFunction callback) { void _addEventListener(JSObject api, JSString event, JSFunction callback) {
(api as _JitsiApi).addEventListener(event, callback); (api as _JitsiApi).addEventListener(event, callback);
} }

View File

@@ -1,4 +1,4 @@
// Version: 1.2.0 | Created: 2026-04-05 | Updated: 2026-04-10 // Version: 1.3.0 | Created: 2026-04-05 | Updated: 2026-07-04
// Full-screen Jitsi meeting embedded via HtmlElementView (web platform view). // Full-screen Jitsi meeting embedded via HtmlElementView (web platform view).
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -21,6 +21,7 @@ class _JitsiScreenState extends State<JitsiScreen> {
late final JitsiLink? _link; late final JitsiLink? _link;
bool _meetingStarted = false; bool _meetingStarted = false;
bool _meetingEnded = false; bool _meetingEnded = false;
bool _meetingFailed = false;
@override @override
void initState() { void initState() {
@@ -42,8 +43,9 @@ class _JitsiScreenState extends State<JitsiScreen> {
_meetingStarted = true; _meetingStarted = true;
// Post-frame so the HtmlElementView div is mounted in the DOM. // Post-frame so the HtmlElementView div is mounted in the DOM.
WidgetsBinding.instance.addPostFrameCallback((_) { // joinMeeting itself waits for the container div to appear.
JitsiWebService.instance.joinMeeting( WidgetsBinding.instance.addPostFrameCallback((_) async {
final ok = await JitsiWebService.instance.joinMeeting(
roomName: _link.roomName, roomName: _link.roomName,
jwt: _link.jwt, jwt: _link.jwt,
onReadyToClose: () { onReadyToClose: () {
@@ -52,6 +54,9 @@ class _JitsiScreenState extends State<JitsiScreen> {
} }
}, },
); );
if (!ok && mounted) {
setState(() => _meetingFailed = true);
}
}); });
} }
@@ -111,6 +116,34 @@ class _JitsiScreenState extends State<JitsiScreen> {
); );
} }
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. // Render the Jitsi iframe and kick off the meeting once mounted.
_startMeeting(); _startMeeting();

View File

@@ -1,4 +1,4 @@
// Version: 1.0.0 | Created: 2026-04-05 // Version: 1.1.0 | Created: 2026-04-05 | Updated: 2026-07-04
// Welcome/landing screen — first thing unauthenticated users see. // Welcome/landing screen — first thing unauthenticated users see.
// Two paths: join a Jitsi conference (no login) or sign in for Matrix chat. // Two paths: join a Jitsi conference (no login) or sign in for Matrix chat.
@@ -39,7 +39,15 @@ class _WelcomeScreenState extends State<WelcomeScreen> {
final isDark = theme.brightness == Brightness.dark; final isDark = theme.brightness == Brightness.dark;
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),
@@ -67,7 +75,7 @@ class _WelcomeScreenState extends State<WelcomeScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Chat and conference all in one place', 'Chat and conference, all in one place',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153), color: theme.colorScheme.onSurface.withAlpha(153),
@@ -84,7 +92,7 @@ class _WelcomeScreenState extends State<WelcomeScreen> {
isDark: isDark, isDark: isDark,
icon: Icons.videocam_outlined, icon: Icons.videocam_outlined,
title: 'Join a Conference', title: 'Join a Conference',
subtitle: 'Paste your meeting link to join instantly — no account needed.', subtitle: 'Paste your meeting link to join instantly. No account needed.',
child: Column( child: Column(
children: [ children: [
TextField( TextField(
@@ -156,6 +164,7 @@ class _WelcomeScreenState extends State<WelcomeScreen> {
), ),
), ),
), ),
),
), ),
); );
} }

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

@@ -1,4 +1,4 @@
// Version: 1.0.0 | Created: 2026-04-11 // Version: 1.0.1 | Created: 2026-04-11 | Updated: 2026-07-03
// Dialog for entering a recovery key to restore encrypted message keys // Dialog for entering a recovery key to restore encrypted message keys
// from the server-side key backup (SSSS / Secure Secret Storage). // from the server-side key backup (SSSS / Secure Secret Storage).
@@ -147,7 +147,7 @@ class _KeyRestoreDialogState extends State<_KeyRestoreDialog> {
SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
'Message keys have been restored. ' 'Message keys have been restored. '
'Go back to your rooms — previously encrypted ' 'Go back to your rooms. Previously encrypted '
'messages should now be readable.', 'messages should now be readable.',
), ),
], ],

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.4.0 | Created: 2026-04-01 | Updated: 2026-04-11 // 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,8 @@ 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 'key_restore_dialog.dart';
import 'security_setup_dialog.dart'; import 'security_setup_dialog.dart';
@@ -89,7 +91,7 @@ class ProfileScreen extends ConsumerWidget {
title: const Text('End-to-end encryption'), title: const Text('End-to-end encryption'),
subtitle: Text( subtitle: Text(
client.encryptionEnabled client.encryptionEnabled
? 'Active — messages are encrypted' ? 'Active. Messages are encrypted'
: 'Not available in this browser', : 'Not available in this browser',
), ),
enabled: false, enabled: false,
@@ -108,7 +110,7 @@ class ProfileScreen extends ConsumerWidget {
if (restored && context.mounted) { if (restored && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Keys restored — reopen rooms ' content: Text('Keys restored. Reopen rooms '
'to see decrypted messages.'), 'to see decrypted messages.'),
), ),
); );
@@ -121,7 +123,7 @@ class ProfileScreen extends ConsumerWidget {
title: const Text('Security setup'), title: const Text('Security setup'),
subtitle: Text( subtitle: Text(
client.encryption?.crossSigning.enabled == true client.encryption?.crossSigning.enabled == true
? 'Cross-signing active — device verified' ? 'Cross-signing active. Device verified'
: 'Set up cross-signing and key backup', : 'Set up cross-signing and key backup',
), ),
enabled: client.encryptionEnabled, enabled: client.encryptionEnabled,
@@ -147,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

@@ -1,4 +1,4 @@
// Version: 1.0.0 | Created: 2026-04-11 // Version: 1.0.1 | Created: 2026-04-11 | Updated: 2026-07-03
// Security setup dialog: drives the Matrix SDK Bootstrap to set up // Security setup dialog: drives the Matrix SDK Bootstrap to set up
// SSSS, cross-signing, and online key backup in one flow. // SSSS, cross-signing, and online key backup in one flow.
// For new users: creates everything, shows recovery key. // For new users: creates everything, shows recovery key.
@@ -298,7 +298,7 @@ class _SecuritySetupDialogState extends State<_SecuritySetupDialog> {
children: [ children: [
const Text( const Text(
'Your recovery key has been created. Save it somewhere ' 'Your recovery key has been created. Save it somewhere '
'safe — you will need it to restore your messages on ' 'safe. You will need it to restore your messages on '
'new devices.', 'new devices.',
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -389,7 +389,7 @@ class _SecuritySetupDialogState extends State<_SecuritySetupDialog> {
onPressed: () async { onPressed: () async {
await _restoreKeys(); await _restoreKeys();
}, },
child: const Text('I saved it continue'), child: const Text('I saved it, continue'),
), ),
], ],
_Phase.done => [ _Phase.done => [

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,18 @@
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-04-27 // 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 '../../help/presentation/help_tab.dart';
import '../../panic/presentation/panic_button.dart';
import '../../jitsi/presentation/conference_tab.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 'public_rooms_dialog.dart';
@@ -27,6 +33,17 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
String _searchQuery = ''; String _searchQuery = '';
final _searchController = TextEditingController(); 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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@@ -99,6 +116,7 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
) )
: const Text('M8Chat'), : const Text('M8Chat'),
actions: [ actions: [
const PanicButton(),
IconButton( IconButton(
icon: Icon(_searchActive ? Icons.close : Icons.search), icon: Icon(_searchActive ? Icons.close : Icons.search),
tooltip: _searchActive ? 'Cancel search' : 'Search rooms', tooltip: _searchActive ? 'Cancel search' : 'Search rooms',
@@ -106,19 +124,20 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
), ),
if (!_searchActive) if (!_searchActive)
IconButton( IconButton(
icon: const Icon(Icons.edit_square), icon: const Icon(Icons.explore_outlined),
tooltip: 'New message', tooltip: 'Find rooms',
onPressed: () => showUserSearchDialog(context), onPressed: () => showPublicRoomsDialog(context),
), ),
], ],
) )
: null, : null,
body: _buildBody(), body: _buildBody(),
floatingActionButton: _selectedIndex == 0 && !_searchActive floatingActionButton: _selectedIndex == 0 && !_searchActive
? FloatingActionButton( ? FloatingActionButton.extended(
onPressed: () => showPublicRoomsDialog(context), onPressed: () => showUserSearchDialog(context),
tooltip: 'Find rooms', tooltip: 'New message',
child: const Icon(Icons.explore_outlined), icon: const Icon(Icons.edit_square),
label: const Text('New message'),
) )
: null, : null,
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
@@ -236,16 +255,16 @@ 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(
'Use the pencil icon above to message someone, or find ' 'Tap the New message button to message someone, or find '
'a public room to join.', 'a public room to join.',
textAlign: TextAlign.center, 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), const SizedBox(height: 20),

View File

@@ -1,4 +1,4 @@
// Version: 1.1.0 | Created: 2026-04-02 | Updated: 2026-04-10 // 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 => '\u{1F512} Encrypted — use Element to read', 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.5.0+7 version: 1.11.0+15
environment: environment:
sdk: '>=3.11.0 <4.0.0' sdk: '>=3.11.0 <4.0.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);
},
);
}

View File

@@ -3,8 +3,8 @@
"short_name": "M8Chat", "short_name": "M8Chat",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0F0F1A", "background_color": "#0A0F2E",
"theme_color": "#5C35C9", "theme_color": "#1265ED",
"description": "M8Chat — secure chat and conferencing.", "description": "M8Chat — secure chat and conferencing.",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,