Compare commits

..

7 Commits

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-10
// Chat repository — bridges Matrix SDK timeline to app domain models.
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support.
@@ -191,6 +191,7 @@ class ChatRepository {
MessageType _messageType(Event event) {
if (event.redacted) return MessageType.redacted;
if (event.type == EventTypes.Encrypted) return MessageType.encrypted;
return switch (event.messageType) {
MessageTypes.Text => MessageType.text,

View File

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

View File

@@ -1,18 +1,19 @@
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-27
// Full chat screen — timeline + input + typing indicators + read receipts
// + long-press context menu (reply, react, copy, delete).
// + long-press context menu (reply, react, copy, delete) + room options sheet.
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' show MatrixFile;
import 'package:matrix/matrix.dart' show MatrixFile, Room;
import '../../../core/network/matrix_client.dart';
import '../../../shared/utils/matrix_id.dart';
import '../../../shared/utils/mxc_url.dart';
import '../../../shared/widgets/matrix_avatar.dart';
import '../../rooms/presentation/user_search_dialog.dart';
import '../domain/message_model.dart';
import 'chat_controller.dart';
import 'message_bubble.dart';
@@ -34,6 +35,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
String get _decodedRoomId => Uri.decodeComponent(widget.roomId);
void _showRoomOptions(BuildContext context, Room room) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _RoomOptionsSheet(room: room),
);
}
void _setReply(MessageModel message) {
setState(() {
_replyToEventId = message.eventId;
@@ -87,7 +96,9 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
IconButton(
icon: const Icon(Icons.more_vert),
tooltip: 'Room options',
onPressed: () {},
onPressed: room != null
? () => _showRoomOptions(context, room)
: null,
),
],
),
@@ -239,6 +250,186 @@ class _MessageWithGestures extends ConsumerWidget {
}
}
// ---------------------------------------------------------------------------
// Room options sheet
// ---------------------------------------------------------------------------
class _RoomOptionsSheet extends ConsumerStatefulWidget {
const _RoomOptionsSheet({required this.room});
final Room room;
@override
ConsumerState<_RoomOptionsSheet> createState() => _RoomOptionsSheetState();
}
class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
bool _leaving = false;
Future<void> _leaveRoom(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Leave room?'),
content: Text(
'You will leave "${widget.room.getLocalizedDisplayname()}". '
'You can rejoin if you have an invite.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Leave'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _leaving = true);
try {
await widget.room.leave();
if (mounted) {
Navigator.of(context).pop(); // close sheet
GoRouter.of(context).go('/rooms');
}
} on Exception catch (e) {
if (mounted) {
setState(() => _leaving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not leave room: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final client = ref.watch(matrixClientProvider);
final theme = Theme.of(context);
final room = widget.room;
final members = room.getParticipants();
final canInvite = room.canInvite;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 16, 4),
child: Row(
children: [
Expanded(
child: Text(
room.getLocalizedDisplayname(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Members section
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
child: Text(
'Members (${members.length})',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
final name =
member.displayName ?? member.id.matrixLocalpart;
final avatarUrl =
resolveMxcUrl(client, member.avatarUrl);
final isMe = member.id == client.userID;
return ListTile(
dense: true,
leading: MatrixAvatar(
name: name,
avatarUrl: avatarUrl,
radius: 16,
),
title: Text(
isMe ? '$name (you)' : name,
style: theme.textTheme.bodyMedium,
),
subtitle: Text(
member.id,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
);
},
),
),
const Divider(),
// Actions
if (canInvite)
ListTile(
leading: const Icon(Icons.person_add_outlined),
title: const Text('Invite someone'),
onTap: () {
Navigator.of(context).pop();
showUserSearchDialog(context);
},
),
ListTile(
leading: _leaving
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
Icons.exit_to_app,
color: theme.colorScheme.error,
),
title: Text(
'Leave room',
style: TextStyle(color: theme.colorScheme.error),
),
enabled: !_leaving,
onTap: () => _leaveRoom(context),
),
],
),
),
);
}
}
class _MessageContextMenu extends StatelessWidget {
const _MessageContextMenu({
required this.message,

View File

@@ -1,4 +1,4 @@
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-27
// Message bubble widget. Handles text, images, files, redacted, replies.
import 'package:cached_network_image/cached_network_image.dart';
@@ -138,6 +138,22 @@ class _MessageContentBody extends StatelessWidget {
fontStyle: FontStyle.italic,
),
),
MessageType.encrypted => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lock_outline, size: 14, color: textColour.withAlpha(153)),
const SizedBox(width: 6),
Flexible(
child: Text(
'Encrypted — use Element to read',
style: TextStyle(
color: textColour.withAlpha(153),
fontStyle: FontStyle.italic,
),
),
),
],
),
_ => Text(
message.body ?? 'Unsupported message type',
style: TextStyle(
@@ -156,11 +172,15 @@ class _ImageContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (message.mediaUrl != null) {
return ClipRRect(
final url = message.mediaUrl;
if (url == null) return const Icon(Icons.image_not_supported);
return GestureDetector(
onTap: () => _openFullscreen(context, url, message.body),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: message.mediaUrl!,
imageUrl: url,
width: 240,
fit: BoxFit.cover,
placeholder: (_, __) => const SizedBox(
@@ -172,9 +192,60 @@ class _ImageContent extends StatelessWidget {
child: Center(child: Icon(Icons.broken_image)),
),
),
),
);
}
return const Icon(Icons.image_not_supported);
void _openFullscreen(BuildContext context, String url, String? caption) {
Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => _ImageViewer(url: url, caption: caption),
),
);
}
}
class _ImageViewer extends StatelessWidget {
const _ImageViewer({required this.url, this.caption});
final String url;
final String? caption;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: caption != null
? Text(
caption!,
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
)
: null,
),
body: Center(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 4,
child: CachedNetworkImage(
imageUrl: url,
fit: BoxFit.contain,
placeholder: (_, __) => const CircularProgressIndicator(
color: Colors.white,
),
errorWidget: (_, __, ___) => const Icon(
Icons.broken_image,
color: Colors.white,
size: 64,
),
),
),
),
);
}
}

View File

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

View File

@@ -0,0 +1,168 @@
// Version: 1.1.0 | Created: 2026-04-05 | Updated: 2026-04-10
// Web implementation: uses JitsiMeetExternalAPI via dart:js_interop.
// This file is web-only — guarded by the Flutter web build.
// The external_api.js script is lazy-loaded on first use — not in index.html.
// This prevents privacy browsers (Brave, Mullvad) from blocking the app
// at startup due to a cross-origin script from conf.m8chat.au.
import 'dart:async';
import 'dart:js_interop';
import 'dart:ui_web' as ui_web;
import 'package:web/web.dart' as web;
import '../../../core/config/app_config.dart';
/// Manages a single Jitsi iframe session via the JitsiMeetExternalAPI.
class JitsiWebService {
JitsiWebService._();
static final instance = JitsiWebService._();
JSObject? _api;
bool _viewRegistered = false;
bool _scriptLoaded = false;
bool _scriptLoading = false;
/// Unique view type for HtmlElementView.
static const String viewType = 'jitsi-meet-container';
/// Lazy-loads the JitsiMeetExternalAPI script from conf.m8chat.au.
/// Returns true if the script loaded successfully, false otherwise.
Future<bool> _ensureScriptLoaded() async {
if (_scriptLoaded) return true;
if (_scriptLoading) {
// Wait for in-flight load to finish.
for (var i = 0; i < 50; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
if (_scriptLoaded) return true;
}
return false;
}
_scriptLoading = true;
final completer = Completer<bool>();
final script =
web.document.createElement('script') as web.HTMLScriptElement;
script.src = 'https://${AppConfig.jitsiDomain}/external_api.js';
script.async = true;
script.onload = (web.Event event) {
_scriptLoaded = true;
_scriptLoading = false;
completer.complete(true);
}.toJS;
script.onerror = (web.Event event) {
_scriptLoading = false;
completer.complete(false);
}.toJS;
web.document.head?.append(script);
return completer.future;
}
/// Registers the platform view factory (once).
void ensureViewRegistered() {
if (_viewRegistered) return;
ui_web.platformViewRegistry.registerViewFactory(
viewType,
(int viewId, {Object? params}) {
final div = web.document.createElement('div') as web.HTMLDivElement;
div.id = 'jitsi-container-$viewId';
div.style
..width = '100%'
..height = '100%';
return div;
},
);
_viewRegistered = true;
}
/// Starts a Jitsi meeting inside the container div created by the platform view.
/// Call this AFTER the HtmlElementView has been mounted (e.g. in a post-frame callback).
/// Lazy-loads the Jitsi script on first call.
Future<void> joinMeeting({
required String roomName,
String? jwt,
String? displayName,
String? avatarUrl,
void Function()? onReadyToClose,
}) async {
dispose(); // tear down any previous meeting
// Lazy-load the external_api.js script if not already present.
final loaded = await _ensureScriptLoaded();
if (!loaded) return;
// Find the container div — there should be exactly one with our prefix.
final containers = web.document.querySelectorAll('[id^="jitsi-container-"]');
if (containers.length == 0) return;
final parentNode = containers.item(containers.length - 1);
if (parentNode == null) return;
final configOverwrite = <String, Object?>{
'startAudioMuted': 0,
'startVideoMuted': 0,
'disableDeepLinking': true,
'prejoinPageEnabled': true,
}.jsify();
final interfaceConfigOverwrite = <String, Object?>{
'SHOW_CHROME_EXTENSION_BANNER': false,
}.jsify();
final options = <String, Object?>{
'roomName': roomName,
'parentNode': parentNode,
'width': '100%',
'height': '100%',
'configOverwrite': configOverwrite,
'interfaceConfigOverwrite': interfaceConfigOverwrite,
};
if (jwt != null && jwt.isNotEmpty) {
options['jwt'] = jwt;
}
if (displayName != null && displayName.isNotEmpty) {
options['userInfo'] = <String, Object?>{
'displayName': displayName,
if (avatarUrl != null) 'avatarUrl': avatarUrl,
}.jsify();
}
final jsOptions = options.jsify();
_api = _createJitsiApi(AppConfig.jitsiDomain.toJS, jsOptions as JSObject);
if (onReadyToClose != null && _api != null) {
_addEventListener(_api!, 'readyToClose'.toJS, () {
onReadyToClose();
}.toJS);
}
}
/// Cleans up the Jitsi iframe.
void dispose() {
if (_api != null) {
_disposeApi(_api!);
_api = null;
}
}
}
/// Calls `new JitsiMeetExternalAPI(domain, options)`.
@JS('JitsiMeetExternalAPI')
external JSObject _createJitsiApi(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 dispose();
}
void _addEventListener(JSObject api, JSString event, JSFunction callback) {
(api as _JitsiApi).addEventListener(event, callback);
}
void _disposeApi(JSObject api) {
(api as _JitsiApi).dispose();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-04-11
// Profile screen. Shows current user info and logout button.
import 'package:flutter/material.dart';
@@ -10,6 +10,8 @@ import '../../../core/config/app_config.dart';
import '../../../core/network/matrix_client.dart';
import '../../../shared/utils/matrix_id.dart';
import '../../../shared/widgets/matrix_avatar.dart';
import 'key_restore_dialog.dart';
import 'security_setup_dialog.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key, this.embedded = false});
@@ -85,14 +87,57 @@ class ProfileScreen extends ConsumerWidget {
ListTile(
leading: const Icon(Icons.lock_outline),
title: const Text('End-to-end encryption'),
subtitle: const Text('Setup coming in Phase 3'),
subtitle: Text(
client.encryptionEnabled
? 'Active — messages are encrypted'
: 'Not available in this browser',
),
enabled: false,
),
ListTile(
leading: const Icon(Icons.verified_user_outlined),
title: const Text('Verify devices'),
subtitle: const Text('Cross-signing setup — coming in Phase 2'),
enabled: false,
leading: const Icon(Icons.key_outlined),
title: const Text('Restore message keys'),
subtitle: const Text(
'Enter your recovery key to decrypt old messages',
),
enabled: client.encryptionEnabled,
onTap: client.encryptionEnabled
? () async {
final restored =
await showKeyRestoreDialog(context, client);
if (restored && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Keys restored — reopen rooms '
'to see decrypted messages.'),
),
);
}
}
: null,
),
ListTile(
leading: const Icon(Icons.shield_outlined),
title: const Text('Security setup'),
subtitle: Text(
client.encryption?.crossSigning.enabled == true
? 'Cross-signing active — device verified'
: 'Set up cross-signing and key backup',
),
enabled: client.encryptionEnabled,
onTap: client.encryptionEnabled
? () async {
final completed =
await showSecuritySetupDialog(context, client);
if (completed && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Security setup complete.'),
),
);
}
}
: null,
),
ListTile(
leading: const Icon(Icons.password_outlined),

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
// Version: 1.1.0 | Created: 2026-04-01
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-04-27
// Main rooms list screen with bottom navigation.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../help/presentation/help_tab.dart';
import '../../jitsi/presentation/conference_tab.dart';
import '../../profile/presentation/profile_screen.dart';
import '../../spaces/presentation/spaces_screen.dart';
import 'public_rooms_dialog.dart';
import 'room_tile.dart';
import 'rooms_controller.dart';
import 'user_search_dialog.dart';
@@ -20,6 +23,25 @@ class RoomsScreen extends ConsumerStatefulWidget {
class _RoomsScreenState extends ConsumerState<RoomsScreen> {
int _selectedIndex = 0;
bool _searchActive = false;
String _searchQuery = '';
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _toggleSearch() {
setState(() {
_searchActive = !_searchActive;
if (!_searchActive) {
_searchQuery = '';
_searchController.clear();
}
});
}
static const _destinations = [
NavigationDestination(
@@ -37,13 +59,25 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
selectedIcon: Icon(Icons.person),
label: 'Profile',
),
NavigationDestination(
icon: Icon(Icons.videocam_outlined),
selectedIcon: Icon(Icons.videocam),
label: 'Conference',
),
NavigationDestination(
icon: Icon(Icons.help_outline),
selectedIcon: Icon(Icons.help),
label: 'Help',
),
];
Widget _buildBody() {
return switch (_selectedIndex) {
0 => const _RoomListBody(),
0 => _RoomListBody(searchQuery: _searchQuery),
1 => const SpacesScreen(embedded: true),
2 => const ProfileScreen(embedded: true),
3 => const ConferenceTab(),
4 => const HelpTab(),
_ => const _RoomListBody(),
};
}
@@ -53,15 +87,24 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
return Scaffold(
appBar: _selectedIndex == 0
? AppBar(
title: const Text('M8Chat'),
title: _searchActive
? TextField(
controller: _searchController,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search rooms...',
border: InputBorder.none,
),
onChanged: (v) => setState(() => _searchQuery = v),
)
: const Text('M8Chat'),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search rooms',
onPressed: () {
// Phase 2: room search
},
icon: Icon(_searchActive ? Icons.close : Icons.search),
tooltip: _searchActive ? 'Cancel search' : 'Search rooms',
onPressed: _toggleSearch,
),
if (!_searchActive)
IconButton(
icon: const Icon(Icons.edit_square),
tooltip: 'New message',
@@ -71,6 +114,13 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
)
: null,
body: _buildBody(),
floatingActionButton: _selectedIndex == 0 && !_searchActive
? FloatingActionButton(
onPressed: () => showPublicRoomsDialog(context),
tooltip: 'Find rooms',
child: const Icon(Icons.explore_outlined),
)
: null,
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) =>
@@ -82,7 +132,9 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
}
class _RoomListBody extends ConsumerWidget {
const _RoomListBody();
const _RoomListBody({this.searchQuery = ''});
final String searchQuery;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -118,14 +170,39 @@ class _RoomListBody extends ConsumerWidget {
),
),
data: (rooms) {
if (rooms.isEmpty) {
return const _EmptyRoomsState();
final filtered = searchQuery.trim().isEmpty
? rooms
: rooms
.where((r) => r.displayName
.toLowerCase()
.contains(searchQuery.trim().toLowerCase()))
.toList();
if (filtered.isEmpty) {
return searchQuery.isNotEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
'No rooms match "$searchQuery".',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(153),
),
),
),
)
: const _EmptyRoomsState();
}
return ListView.separated(
itemCount: rooms.length,
itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(height: 1, indent: 72),
itemBuilder: (context, index) {
final room = rooms[index];
final room = filtered[index];
return RoomTile(
room: room,
onTap: () =>
@@ -164,11 +241,19 @@ class _EmptyRoomsState extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'Rooms you join will appear here.',
'Use the pencil icon above to message someone, or find '
'a public room to join.',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102),
),
),
const SizedBox(height: 20),
OutlinedButton.icon(
onPressed: () => showPublicRoomsDialog(context),
icon: const Icon(Icons.explore_outlined),
label: const Text('Find rooms'),
),
],
),
),

View File

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

View File

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

View File

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

View File

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

15
web/.htaccess Normal file
View File

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

View File

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

View File

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

64
web/push_sw.js Normal file
View File

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