diff --git a/apps/matrix/apps/mobile/.gitignore b/apps/matrix/apps/mobile/.gitignore
new file mode 100644
index 000000000..9391d962d
--- /dev/null
+++ b/apps/matrix/apps/mobile/.gitignore
@@ -0,0 +1,15 @@
+node_modules/
+.expo/
+dist/
+build/
+ios/
+android/
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+*.orig.*
+web-build/
+.env
+.env.local
diff --git a/apps/matrix/apps/mobile/app.json b/apps/matrix/apps/mobile/app.json
new file mode 100644
index 000000000..16387e7a3
--- /dev/null
+++ b/apps/matrix/apps/mobile/app.json
@@ -0,0 +1,74 @@
+{
+ "expo": {
+ "name": "Manalink",
+ "slug": "manalink",
+ "version": "1.0.0",
+ "scheme": "manalink",
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "userInterfaceStyle": "automatic",
+ "splash": {
+ "image": "./assets/splash.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#0f0f0f"
+ },
+ "assetBundlePatterns": ["**/*"],
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "how.mana.manalink",
+ "infoPlist": {
+ "ITSAppUsesNonExemptEncryption": false
+ }
+ },
+ "android": {
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/adaptive-icon.png",
+ "backgroundColor": "#0f0f0f"
+ },
+ "package": "how.mana.manalink"
+ },
+ "web": {
+ "bundler": "metro",
+ "output": "static",
+ "favicon": "./assets/favicon.png"
+ },
+ "plugins": [
+ "expo-router",
+ "expo-secure-store",
+ [
+ "expo-image-picker",
+ {
+ "photosPermission": "Allow Manalink to select photos for sharing.",
+ "cameraPermission": "Allow Manalink to take photos for sharing."
+ }
+ ],
+ [
+ "expo-media-library",
+ {
+ "photosPermission": "Allow Manalink to save images to your library.",
+ "savePhotosPermission": "Allow Manalink to save images."
+ }
+ ],
+ [
+ "expo-notifications",
+ {
+ "icon": "./assets/notification-icon.png",
+ "color": "#7c6bff",
+ "sounds": []
+ }
+ ]
+ ],
+ "experiments": {
+ "typedRoutes": true,
+ "tsconfigPaths": true
+ },
+ "extra": {
+ "router": {
+ "origin": false
+ },
+ "eas": {
+ "projectId": ""
+ }
+ }
+ }
+}
diff --git a/apps/matrix/apps/mobile/app/(app)/_layout.tsx b/apps/matrix/apps/mobile/app/(app)/_layout.tsx
new file mode 100644
index 000000000..e030b0517
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/(app)/_layout.tsx
@@ -0,0 +1,85 @@
+import { View, Text } from 'react-native';
+import { Tabs } from 'expo-router';
+import { ChatCircle, User, Bell, GearSix } from 'phosphor-react-native';
+import { useMatrixStore } from '~/src/matrix/store';
+
+const BG = '#0f0f0f';
+const BORDER = '#2a2a2a';
+const ACTIVE = '#7c6bff';
+const INACTIVE = '#6b7280';
+const SIZE = 22;
+
+function InviteBadge({ count }: { count: number }) {
+ if (count === 0) return null;
+ return (
+
+
+ {count > 9 ? '9+' : count}
+
+
+ );
+}
+
+export default function AppLayout() {
+ const invites = useMatrixStore((s) => s.invites);
+
+ return (
+
+ (
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ (
+
+
+
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/(app)/dms.tsx b/apps/matrix/apps/mobile/app/(app)/dms.tsx
new file mode 100644
index 000000000..1a71f5ced
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/(app)/dms.tsx
@@ -0,0 +1,98 @@
+import { useState, useMemo } from 'react';
+import { View, Text, FlatList, Pressable, ActivityIndicator, TextInput } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useRouter } from 'expo-router';
+import { Plus, MagnifyingGlass } from 'phosphor-react-native';
+import { useMatrixStore } from '~/src/matrix/store';
+import RoomListItem from '~/src/components/RoomListItem';
+import SyncStatusBar from '~/src/components/SyncStatusBar';
+
+export default function DMsScreen() {
+ const { rooms, syncState, isReady, selectRoom } = useMatrixStore();
+ const router = useRouter();
+ const [search, setSearch] = useState('');
+
+ const dmRooms = useMemo(() => {
+ const base = rooms.filter((r) => r.isDirect && r.membership === 'join');
+ if (!search.trim()) return base;
+ const q = search.toLowerCase();
+ return base.filter((r) => r.name.toLowerCase().includes(q));
+ }, [rooms, search]);
+
+ const dmInvites = useMemo(
+ () => rooms.filter((r) => r.membership === 'invite' && r.isDirect),
+ [rooms],
+ );
+
+ const handleRoomPress = (roomId: string) => {
+ selectRoom(roomId);
+ router.push(`/room/${roomId}`);
+ };
+
+ return (
+
+
+
+
+ Direct Messages
+ router.push('/room/new')}
+ className={({ pressed }) =>
+ `w-9 h-9 bg-primary rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
+ }
+ >
+
+
+
+
+ {(dmRooms.length > 0 || search.length > 0) && (
+
+
+
+
+ )}
+
+ {!isReady && syncState === 'STOPPED' ? (
+
+
+
+ ) : (
+ item.id}
+ renderItem={({ item }) => (
+ handleRoomPress(item.id)} />
+ )}
+ contentContainerClassName="pb-4"
+ ListHeaderComponent={
+ dmInvites.length > 0 ? (
+
+
+ {dmInvites.length} pending invite{dmInvites.length !== 1 ? 's' : ''}
+
+
+ ) : null
+ }
+ ListEmptyComponent={
+
+
+ {search ? 'No people found' : 'No direct messages'}
+
+ {!search && (
+
+ Tap + to start a conversation
+
+ )}
+
+ }
+ />
+ )}
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/(app)/index.tsx b/apps/matrix/apps/mobile/app/(app)/index.tsx
new file mode 100644
index 000000000..08a8d57ab
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/(app)/index.tsx
@@ -0,0 +1,113 @@
+import { useState, useMemo } from 'react';
+import { View, Text, FlatList, Pressable, ActivityIndicator, TextInput } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useRouter } from 'expo-router';
+import { Plus, MagnifyingGlass, Compass } from 'phosphor-react-native';
+import { useMatrixStore } from '~/src/matrix/store';
+import RoomListItem from '~/src/components/RoomListItem';
+import SyncStatusBar from '~/src/components/SyncStatusBar';
+
+export default function ChatsScreen() {
+ const { rooms, syncState, isReady, selectRoom } = useMatrixStore();
+ const router = useRouter();
+ const [search, setSearch] = useState('');
+
+ const groupRooms = useMemo(() => {
+ const base = rooms.filter((r) => !r.isDirect && r.membership === 'join');
+ if (!search.trim()) return base;
+ const q = search.toLowerCase();
+ return base.filter((r) => r.name.toLowerCase().includes(q));
+ }, [rooms, search]);
+
+ // Pending invites
+ const invites = useMemo(
+ () => rooms.filter((r) => r.membership === 'invite' && !r.isDirect),
+ [rooms],
+ );
+
+ const handleRoomPress = (roomId: string) => {
+ selectRoom(roomId);
+ router.push(`/room/${roomId}`);
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+ Chats
+
+ router.push('/search')}
+ className={({ pressed }) =>
+ `w-9 h-9 bg-surface border border-border rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
+ }
+ >
+
+
+ router.push('/room/new')}
+ className={({ pressed }) =>
+ `w-9 h-9 bg-primary rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
+ }
+ >
+
+
+
+
+
+ {/* Search */}
+ {(groupRooms.length > 0 || search.length > 0) && (
+
+
+
+
+ )}
+
+ {/* Loading state */}
+ {!isReady && syncState === 'STOPPED' ? (
+
+
+ Connecting...
+
+ ) : (
+ item.id}
+ renderItem={({ item }) => (
+ handleRoomPress(item.id)} />
+ )}
+ contentContainerClassName="pb-4"
+ ListHeaderComponent={
+ invites.length > 0 ? (
+
+
+ {invites.length} pending invite{invites.length !== 1 ? 's' : ''}
+
+
+ ) : null
+ }
+ ListEmptyComponent={
+
+
+ {search ? 'No rooms found' : 'No group chats yet'}
+
+ {!search && (
+
+ Tap + to create or join a room
+
+ )}
+
+ }
+ />
+ )}
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/(app)/invites.tsx b/apps/matrix/apps/mobile/app/(app)/invites.tsx
new file mode 100644
index 000000000..c42fc3c5a
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/(app)/invites.tsx
@@ -0,0 +1,131 @@
+import { View, Text, FlatList, Pressable, ActivityIndicator, Alert } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { Image } from 'expo-image';
+import { useMatrixStore } from '~/src/matrix/store';
+import type { SimpleRoom } from '~/src/matrix/types';
+
+function InviteCard({ room, onAccept, onDecline }: { room: SimpleRoom; onAccept: () => void; onDecline: () => void }) {
+ return (
+
+
+ {/* Avatar */}
+
+ {room.avatar ? (
+
+ ) : (
+
+ {(room.name ?? '?')[0].toUpperCase()}
+
+ )}
+
+
+ {/* Info */}
+
+
+ {room.name}
+
+ {room.topic && (
+
+ {room.topic}
+
+ )}
+ {room.inviter && (
+
+ Invited by {room.inviter}
+
+ )}
+
+
+ {room.isDirect ? 'Direct message' : `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`}
+
+ {room.isEncrypted && (
+ · 🔒 Encrypted
+ )}
+
+
+
+
+ {/* Actions */}
+
+
+ `flex-1 py-3 items-center border-r border-border ${pressed ? 'bg-surface' : ''}`
+ }
+ >
+ Decline
+
+ `flex-1 py-3 items-center ${pressed ? 'bg-primary/80' : 'bg-primary'}`}
+ >
+ Accept
+
+
+
+ );
+}
+
+export default function InvitesScreen() {
+ const { invites, acceptInvite, declineInvite, isReady } = useMatrixStore();
+
+ const handleAccept = async (roomId: string) => {
+ try {
+ await acceptInvite(roomId);
+ } catch (err) {
+ Alert.alert('Error', err instanceof Error ? err.message : 'Could not join room');
+ }
+ };
+
+ const handleDecline = (roomId: string, roomName: string) => {
+ Alert.alert(`Decline invite`, `Decline invite to "${roomName}"?`, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Decline',
+ style: 'destructive',
+ onPress: () => declineInvite(roomId).catch(() => {}),
+ },
+ ]);
+ };
+
+ return (
+
+
+ Invites
+ {invites.length > 0 && (
+
+ {invites.length}
+
+ )}
+
+
+ {!isReady ? (
+
+
+
+ ) : (
+ item.id}
+ renderItem={({ item }) => (
+ handleAccept(item.id)}
+ onDecline={() => handleDecline(item.id, item.name)}
+ />
+ )}
+ contentContainerClassName="pt-2 pb-6"
+ ListEmptyComponent={
+
+ ✉️
+ No pending invites
+
+ Room invites will appear here
+
+
+ }
+ />
+ )}
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/(app)/settings.tsx b/apps/matrix/apps/mobile/app/(app)/settings.tsx
new file mode 100644
index 000000000..973b4948a
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/(app)/settings.tsx
@@ -0,0 +1,236 @@
+import { useState } from 'react';
+import {
+ View,
+ Text,
+ Pressable,
+ Alert,
+ ScrollView,
+ TextInput,
+ ActivityIndicator,
+ Modal,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { Image } from 'expo-image';
+import * as ImagePicker from 'expo-image-picker';
+import { PencilSimple, X } from 'phosphor-react-native';
+import { useMatrixStore } from '~/src/matrix/store';
+import { uploadMedia, getMimetypeFromFilename } from '~/src/matrix/upload';
+
+function ProfileAvatar({ displayName, avatarUrl }: { displayName: string; avatarUrl?: string }) {
+ const initial = displayName[0]?.toUpperCase() ?? '?';
+ return (
+
+ {avatarUrl ? (
+
+ ) : (
+ {initial}
+ )}
+
+ );
+}
+
+export default function SettingsScreen() {
+ const { client, syncState, credentials, logout } = useMatrixStore();
+
+ const userId = client?.getUserId() ?? credentials?.userId ?? '';
+ const homeserver = client?.baseUrl ?? credentials?.homeserver ?? '';
+
+ const [editingName, setEditingName] = useState(false);
+ const [newDisplayName, setNewDisplayName] = useState('');
+ const [savingName, setSavingName] = useState(false);
+ const [uploadingAvatar, setUploadingAvatar] = useState(false);
+
+ // Get current profile from client
+ const profileInfo = client ? (() => {
+ try {
+ const user = client.getUser(userId);
+ return {
+ displayName: user?.displayName ?? userId.split(':')[0].slice(1),
+ avatarUrl: user?.avatarUrl ?? undefined,
+ };
+ } catch {
+ return { displayName: userId.split(':')[0].slice(1), avatarUrl: undefined };
+ }
+ })() : { displayName: '', avatarUrl: undefined };
+
+ const handleEditName = () => {
+ setNewDisplayName(profileInfo.displayName);
+ setEditingName(true);
+ };
+
+ const handleSaveName = async () => {
+ if (!client || !newDisplayName.trim()) return;
+ setSavingName(true);
+ try {
+ await client.setDisplayName(newDisplayName.trim());
+ setEditingName(false);
+ } catch (err) {
+ Alert.alert('Error', err instanceof Error ? err.message : 'Could not update name');
+ } finally {
+ setSavingName(false);
+ }
+ };
+
+ const handleChangeAvatar = async () => {
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ImagePicker.MediaTypeOptions.Images,
+ quality: 0.8,
+ allowsEditing: true,
+ aspect: [1, 1],
+ });
+ if (result.canceled || !result.assets[0] || !client) return;
+ const asset = result.assets[0];
+ const filename = asset.fileName ?? `avatar_${Date.now()}.jpg`;
+ const mimetype = asset.mimeType ?? getMimetypeFromFilename(filename);
+
+ setUploadingAvatar(true);
+ try {
+ const uploaded = await uploadMedia(client, asset.uri, filename, mimetype);
+ await client.setAvatarUrl(uploaded.mxcUrl);
+ } catch (err) {
+ Alert.alert('Error', err instanceof Error ? err.message : 'Could not update avatar');
+ } finally {
+ setUploadingAvatar(false);
+ }
+ };
+
+ const handleLogout = () => {
+ Alert.alert('Sign out', 'Are you sure you want to sign out?', [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Sign out', style: 'destructive', onPress: logout },
+ ]);
+ };
+
+ return (
+
+
+ Settings
+
+
+
+ {/* Profile card */}
+
+ {/* Avatar */}
+
+
+
+ {uploadingAvatar ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Display name */}
+
+
+
+ {profileInfo.displayName}
+
+
+
+
+
+
+ {userId}
+
+
+
+
+ {/* Connection info */}
+
+
+ Connection
+
+
+
+ Homeserver
+ {homeserver || '—'}
+
+
+ Sync status
+
+
+ {syncState.toLowerCase()}
+
+
+
+
+
+ {/* About */}
+
+
+ About
+
+
+
+ App
+ Manalink
+
+
+ Version
+ 1.0.0
+
+
+ Protocol
+ Matrix
+
+
+
+
+ {/* Sign out */}
+
+ `bg-destructive/10 border border-destructive/30 rounded-2xl p-4 items-center ${pressed ? 'opacity-60' : ''}`
+ }
+ >
+ Sign out
+
+
+
+ {/* Edit display name modal */}
+ setEditingName(false)}>
+
+
+
+ Display name
+ setEditingName(false)}>
+
+
+
+
+
+ `bg-primary rounded-xl py-3 items-center ${pressed || savingName || !newDisplayName.trim() ? 'opacity-60' : ''}`
+ }
+ >
+ {savingName ? (
+
+ ) : (
+ Save
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/(auth)/_layout.tsx b/apps/matrix/apps/mobile/app/(auth)/_layout.tsx
new file mode 100644
index 000000000..819279f22
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/(auth)/_layout.tsx
@@ -0,0 +1,9 @@
+import { Stack } from 'expo-router';
+
+export default function AuthLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/(auth)/login.tsx b/apps/matrix/apps/mobile/app/(auth)/login.tsx
new file mode 100644
index 000000000..9378ef684
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/(auth)/login.tsx
@@ -0,0 +1,231 @@
+import { useState } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ Pressable,
+ ScrollView,
+ KeyboardAvoidingView,
+ Platform,
+ ActivityIndicator,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import * as WebBrowser from 'expo-web-browser';
+import * as Linking from 'expo-linking';
+import { loginWithPassword, loginWithToken, checkHomeserver } from '~/src/matrix/client';
+import { useMatrixStore } from '~/src/matrix/store';
+
+WebBrowser.maybeCompleteAuthSession();
+
+export default function LoginScreen() {
+ const [homeserver, setHomeserver] = useState('matrix.mana.how');
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [ssoLoading, setSsoLoading] = useState(false);
+ const [checkingServer, setCheckingServer] = useState(false);
+ const [serverOk, setServerOk] = useState(null);
+
+ const { initialize } = useMatrixStore();
+
+ const normalizeHs = (hs: string) => {
+ let url = hs.trim();
+ if (!url.startsWith('http://') && !url.startsWith('https://')) url = `https://${url}`;
+ return url.replace(/\/$/, '');
+ };
+
+ const handleCheckServer = async () => {
+ setCheckingServer(true);
+ setServerOk(null);
+ const result = await checkHomeserver(homeserver);
+ setServerOk(result.ok);
+ setError(result.ok ? null : (result.error ?? 'Server not reachable'));
+ setCheckingServer(false);
+ };
+
+ const handleLogin = async () => {
+ if (!homeserver.trim() || !username.trim() || !password.trim()) {
+ setError('Please fill in all fields');
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ const result = await loginWithPassword(homeserver, username, password);
+ if (result.success && result.credentials) {
+ await initialize(result.credentials);
+ } else {
+ setError(result.error ?? 'Login failed');
+ setLoading(false);
+ }
+ };
+
+ const handleSSO = async () => {
+ setSsoLoading(true);
+ setError(null);
+ try {
+ const base = normalizeHs(homeserver);
+ const redirectUri = Linking.createURL('sso-callback');
+ const ssoUrl = `${base}/_matrix/client/v3/login/sso/redirect?redirectUrl=${encodeURIComponent(redirectUri)}`;
+
+ const result = await WebBrowser.openAuthSessionAsync(ssoUrl, redirectUri);
+
+ if (result.type === 'success') {
+ const url = result.url;
+ const parsed = new URL(url);
+ const loginToken = parsed.searchParams.get('loginToken');
+ if (!loginToken) {
+ setError('SSO login failed: no token received');
+ return;
+ }
+
+ // Exchange token for credentials
+ await import('~/src/matrix/polyfills');
+ const { createClient } = await import('matrix-js-sdk');
+ const tempClient = createClient({ baseUrl: base });
+ const response = await tempClient.login('m.login.token', {
+ token: loginToken,
+ initial_device_display_name: 'Manalink Mobile',
+ });
+
+ const loginResult = await loginWithToken(
+ base,
+ response.access_token,
+ response.user_id,
+ response.device_id,
+ );
+ if (loginResult.success && loginResult.credentials) {
+ await initialize(loginResult.credentials);
+ }
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'SSO failed');
+ } finally {
+ setSsoLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {/* Logo */}
+
+
+ ⬡
+
+ Manalink
+ Secure Matrix messaging
+
+
+
+ {/* Homeserver */}
+
+
+ Homeserver
+
+
+ { setHomeserver(v); setServerOk(null); }}
+ autoCapitalize="none"
+ autoCorrect={false}
+ keyboardType="url"
+ placeholder="matrix.example.com"
+ placeholderTextColor="#6b7280"
+ onBlur={handleCheckServer}
+ />
+ {checkingServer && }
+ {serverOk === true && ✓}
+ {serverOk === false && ✗}
+
+
+
+ {/* Username */}
+
+
+ Username
+
+
+
+
+ {/* Password */}
+
+
+ Password
+
+
+
+
+ {error && {error}}
+
+ {/* Password login */}
+
+ `bg-primary rounded-xl py-4 items-center mt-1 ${pressed || loading ? 'opacity-70' : ''}`
+ }
+ >
+ {loading ? (
+
+ ) : (
+ Sign in
+ )}
+
+
+ {/* Divider */}
+
+
+ or
+
+
+
+ {/* SSO */}
+
+ `bg-surface border border-border rounded-xl py-4 items-center ${
+ pressed || ssoLoading ? 'opacity-70' : ''
+ }`
+ }
+ >
+ {ssoLoading ? (
+
+ ) : (
+
+ Sign in with SSO
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/+not-found.tsx b/apps/matrix/apps/mobile/app/+not-found.tsx
new file mode 100644
index 000000000..7ed3a4281
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/+not-found.tsx
@@ -0,0 +1,13 @@
+import { View, Text } from 'react-native';
+import { Link } from 'expo-router';
+
+export default function NotFound() {
+ return (
+
+ Screen not found
+
+ Go home
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/_layout.tsx b/apps/matrix/apps/mobile/app/_layout.tsx
new file mode 100644
index 000000000..a9f058e0a
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/_layout.tsx
@@ -0,0 +1,71 @@
+import '../global.css';
+import { useEffect, useState } from 'react';
+import { Stack, useRouter, useSegments } from 'expo-router';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import { StatusBar } from 'expo-status-bar';
+import { useMatrixStore } from '~/src/matrix/store';
+import {
+ requestNotificationPermissions,
+ setupNotificationNavigation,
+} from '~/src/notifications';
+
+function AuthGuard({ children }: { children: React.ReactNode }) {
+ const [checking, setChecking] = useState(true);
+ const segments = useSegments();
+ const router = useRouter();
+ const { isReady, restoreSession } = useMatrixStore();
+
+ useEffect(() => {
+ restoreSession().finally(() => setChecking(false));
+ }, []);
+
+ useEffect(() => {
+ if (checking) return;
+ const inAuthGroup = segments[0] === '(auth)';
+ if (!isReady && !inAuthGroup) router.replace('/(auth)/login');
+ else if (isReady && inAuthGroup) router.replace('/(app)');
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isReady, checking, segments]);
+
+ if (checking) return null;
+ return <>{children}>;
+}
+
+export default function RootLayout() {
+ useEffect(() => {
+ // Request notification permissions (non-blocking)
+ requestNotificationPermissions().catch(() => {});
+ // Set up navigation from notification taps
+ const cleanup = setupNotificationNavigation();
+ return cleanup;
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/room/[id].tsx b/apps/matrix/apps/mobile/app/room/[id].tsx
new file mode 100644
index 000000000..63e069cd6
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/room/[id].tsx
@@ -0,0 +1,374 @@
+import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
+import {
+ View,
+ FlatList,
+ Text,
+ Pressable,
+ ActivityIndicator,
+ Modal,
+ ScrollView,
+ Alert,
+ ActionSheetIOS,
+ Platform,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import { ArrowLeft, Lock, DotsThreeVertical, X } from 'phosphor-react-native';
+import { Image } from 'expo-image';
+import * as ImagePicker from 'expo-image-picker';
+import * as DocumentPicker from 'expo-document-picker';
+import { useMatrixStore } from '~/src/matrix/store';
+import MessageBubble from '~/src/components/MessageBubble';
+import MessageInput from '~/src/components/MessageInput';
+import TypingIndicator from '~/src/components/TypingIndicator';
+import DateSeparator from '~/src/components/DateSeparator';
+import ImageViewer from '~/src/components/ImageViewer';
+import UserProfileModal from '~/src/components/UserProfileModal';
+import VoiceRecorder from '~/src/components/VoiceRecorder';
+import UnreadSeparator from '~/src/components/UnreadSeparator';
+import { getMimetypeFromFilename } from '~/src/matrix/upload';
+import type { SimpleMessage, RoomMember } from '~/src/matrix/types';
+
+type ListItem =
+ | { type: 'message'; data: SimpleMessage }
+ | { type: 'date'; timestamp: number; key: string }
+ | { type: 'unread'; key: string };
+
+function isSameDay(a: number, b: number) {
+ const da = new Date(a), db = new Date(b);
+ return da.getFullYear() === db.getFullYear() &&
+ da.getMonth() === db.getMonth() &&
+ da.getDate() === db.getDate();
+}
+
+function buildListItems(messages: SimpleMessage[], firstUnreadEventId: string | null): ListItem[] {
+ const items: ListItem[] = [];
+ let unreadInserted = false;
+ for (let i = 0; i < messages.length; i++) {
+ const msg = messages[i];
+ if (!messages[i - 1] || !isSameDay(messages[i - 1].timestamp, msg.timestamp)) {
+ items.push({ type: 'date', timestamp: msg.timestamp, key: `date_${msg.timestamp}_${i}` });
+ }
+ if (!unreadInserted && firstUnreadEventId && msg.id === firstUnreadEventId) {
+ items.push({ type: 'unread', key: 'unread_separator' });
+ unreadInserted = true;
+ }
+ items.push({ type: 'message', data: msg });
+ }
+ return items;
+}
+
+function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => void }) {
+ const [showProfile, setShowProfile] = useState(false);
+ return (
+ <>
+ setShowProfile(true)}
+ className={({ pressed }) => `flex-row items-center gap-3 px-4 py-3 ${pressed ? 'bg-surface/60' : ''}`}
+ >
+
+ {member.avatarUrl ? (
+
+ ) : (
+
+ {member.displayName[0]?.toUpperCase() ?? '?'}
+
+ )}
+
+
+ {member.displayName}
+ {member.userId}
+
+ {member.powerLevel >= 100 && (
+
+ Admin
+
+ )}
+ {member.powerLevel >= 50 && member.powerLevel < 100 && (
+
+ Mod
+
+ )}
+
+ { setShowProfile(false); onClose(); }}
+ />
+ >
+ );
+}
+
+export default function RoomScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const listRef = useRef>(null);
+
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [showVoiceRecorder, setShowVoiceRecorder] = useState(false);
+ const [replyTo, setReplyTo] = useState(null);
+ const [editingMessage, setEditingMessage] = useState(null);
+ const [showMembers, setShowMembers] = useState(false);
+ const [viewingImage, setViewingImage] = useState(null);
+ const [profileUserId, setProfileUserId] = useState(null);
+
+ const {
+ rooms, messages, firstUnreadEventId, typingUsers, roomMembers, client, credentials,
+ selectRoom, loadRoomMembers, sendMessage, editMessage,
+ sendReaction, redactMessage, sendTyping,
+ sendImage, sendFile, sendVoice, leaveRoom,
+ } = useMatrixStore();
+
+ const room = rooms.find((r) => r.id === id);
+ const isAdmin = useMemo(() => {
+ if (!client || !id) return false;
+ const matrixRoom = client.getRoom(id);
+ const userId = client.getUserId() ?? '';
+ return (matrixRoom?.getMember(userId)?.powerLevel ?? 0) >= 100;
+ }, [client, id]);
+
+ useEffect(() => { if (id) selectRoom(id); }, [id]);
+
+ const listItems = useMemo(() => buildListItems(messages, firstUnreadEventId), [messages, firstUnreadEventId]);
+
+ // Scroll to first unread message on initial load
+ useEffect(() => {
+ if (!firstUnreadEventId || listItems.length === 0) return;
+ const unreadIndex = listItems.findIndex((item) => item.type === 'unread');
+ if (unreadIndex > 0) {
+ setTimeout(() => {
+ listRef.current?.scrollToIndex({ index: unreadIndex, animated: true, viewPosition: 0 });
+ }, 300);
+ }
+ }, [firstUnreadEventId]);
+
+ const handleLoadMore = async () => {
+ if (!client || !id || loadingMore) return;
+ const matrixRoom = client.getRoom(id);
+ if (!matrixRoom) return;
+ setLoadingMore(true);
+ try { await client.scrollback(matrixRoom, 30); }
+ finally { setLoadingMore(false); }
+ };
+
+ const handleRoomOptions = () => {
+ const options = ['Cancel', 'Members', ...(isAdmin ? ['Room settings'] : []), 'Leave room'];
+ const destructiveIndex = options.length - 1;
+
+ if (Platform.OS === 'ios') {
+ ActionSheetIOS.showActionSheetWithOptions(
+ { options, cancelButtonIndex: 0, destructiveButtonIndex: destructiveIndex },
+ (index) => {
+ if (index === 1) { loadRoomMembers(id!); setShowMembers(true); }
+ if (isAdmin && index === 2) { router.push({ pathname: '/room/settings', params: { id } }); }
+ if (index === options.length - 1) handleLeave();
+ },
+ );
+ } else {
+ Alert.alert(room?.name ?? 'Room', undefined, [
+ { text: 'Members', onPress: () => { loadRoomMembers(id!); setShowMembers(true); } },
+ ...(isAdmin ? [{ text: 'Room settings', onPress: () => router.push({ pathname: '/room/settings', params: { id } }) }] : []),
+ { text: 'Leave room', style: 'destructive' as const, onPress: handleLeave },
+ { text: 'Cancel', style: 'cancel' as const },
+ ]);
+ }
+ };
+
+ const handleLeave = () => {
+ Alert.alert('Leave room', `Leave "${room?.name ?? id}"?`, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Leave',
+ style: 'destructive',
+ onPress: async () => {
+ await leaveRoom(id!);
+ router.replace('/(app)');
+ },
+ },
+ ]);
+ };
+
+ const handleAttach = () => {
+ if (Platform.OS === 'ios') {
+ ActionSheetIOS.showActionSheetWithOptions(
+ { options: ['Cancel', 'Photo Library', 'Camera', 'File'], cancelButtonIndex: 0 },
+ (index) => {
+ if (index === 1) pickImage('library');
+ if (index === 2) pickImage('camera');
+ if (index === 3) pickDocument();
+ },
+ );
+ } else {
+ Alert.alert('Attach', undefined, [
+ { text: 'Photo Library', onPress: () => pickImage('library') },
+ { text: 'Camera', onPress: () => pickImage('camera') },
+ { text: 'File', onPress: pickDocument },
+ { text: 'Cancel', style: 'cancel' },
+ ]);
+ }
+ };
+
+ const pickImage = async (source: 'library' | 'camera') => {
+ const fn = source === 'camera' ? ImagePicker.launchCameraAsync : ImagePicker.launchImageLibraryAsync;
+ const result = await fn({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.85 });
+ if (result.canceled || !result.assets[0]) return;
+ const asset = result.assets[0];
+ const filename = asset.fileName ?? `image_${Date.now()}.jpg`;
+ setUploading(true);
+ try {
+ await sendImage(asset.uri, filename, asset.mimeType ?? getMimetypeFromFilename(filename), asset.width, asset.height);
+ } catch (err) {
+ Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error');
+ } finally { setUploading(false); }
+ };
+
+ const pickDocument = async () => {
+ const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true });
+ if (result.canceled || !result.assets[0]) return;
+ const asset = result.assets[0];
+ setUploading(true);
+ try {
+ await sendFile(asset.uri, asset.name, asset.mimeType ?? getMimetypeFromFilename(asset.name));
+ } catch (err) {
+ Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error');
+ } finally { setUploading(false); }
+ };
+
+ const handleEdit = useCallback((msg: SimpleMessage) => {
+ setReplyTo(null);
+ setEditingMessage(msg);
+ }, []);
+
+ const handleSend = useCallback(async (body: string, replyToEventId?: string) => {
+ await sendMessage(body, replyToEventId);
+ }, [sendMessage]);
+
+ const handleEditSave = useCallback(async (eventId: string, newBody: string) => {
+ await editMessage(eventId, newBody);
+ }, [editMessage]);
+
+ const renderItem = ({ item, index }: { item: ListItem; index: number }) => {
+ if (item.type === 'date') return ;
+ if (item.type === 'unread') return ;
+ const msgIndex = messages.indexOf(item.data);
+ return (
+ { setEditingMessage(null); setReplyTo(msg); }}
+ onEdit={handleEdit}
+ onReact={sendReaction}
+ onDelete={redactMessage}
+ onImagePress={setViewingImage}
+ onAvatarPress={setProfileUserId}
+ />
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+ router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
+
+
+
+
+
+ {room?.name ?? id}
+
+ {room?.isEncrypted && }
+
+ {room?.topic ? (
+ {room.topic}
+ ) : room?.memberCount != null ? (
+
+ {room.memberCount} member{room.memberCount !== 1 ? 's' : ''}
+
+ ) : null}
+
+ `p-1 ${pressed ? 'opacity-50' : ''}`}>
+
+
+
+
+ {(loadingMore || uploading) && (
+
+
+ {uploading ? 'Uploading...' : 'Loading...'}
+
+ )}
+
+ item.type === 'date' ? item.key : item.data.id}
+ renderItem={renderItem}
+ contentContainerClassName="px-0 py-2"
+ onEndReached={handleLoadMore}
+ onEndReachedThreshold={0.15}
+ onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
+ maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
+ keyboardDismissMode="interactive"
+ ListEmptyComponent={
+
+ No messages yet
+
+ }
+ />
+
+ {typingUsers.length > 0 && }
+
+ {showVoiceRecorder ? (
+ {
+ setUploading(true);
+ try { await sendVoice(uri, durationMs); }
+ catch (err) { Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); }
+ finally { setUploading(false); setShowVoiceRecorder(false); }
+ }}
+ onCancel={() => setShowVoiceRecorder(false)}
+ />
+ ) : (
+ setShowVoiceRecorder(true)}
+ replyTo={replyTo}
+ onCancelReply={() => setReplyTo(null)}
+ editingMessage={editingMessage}
+ onCancelEdit={() => setEditingMessage(null)}
+ />
+ )}
+
+ {/* Members modal */}
+ setShowMembers(false)}>
+
+
+
+ Members{room?.memberCount != null ? ` (${room.memberCount})` : ''}
+
+ setShowMembers(false)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
+
+
+
+
+ {roomMembers.length === 0 ? (
+
+ ) : (
+ roomMembers.map((member) => (
+ setShowMembers(false)} />
+ ))
+ )}
+
+
+
+
+ setViewingImage(null)} />
+
+ setProfileUserId(null)} />
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/room/new.tsx b/apps/matrix/apps/mobile/app/room/new.tsx
new file mode 100644
index 000000000..e83dda99a
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/room/new.tsx
@@ -0,0 +1,221 @@
+import { useState } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ Pressable,
+ ScrollView,
+ KeyboardAvoidingView,
+ Platform,
+ ActivityIndicator,
+ Switch,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useRouter } from 'expo-router';
+import { ArrowLeft, Users, ChatCircle } from 'phosphor-react-native';
+import { useMatrixStore } from '~/src/matrix/store';
+
+type Mode = 'room' | 'dm';
+
+export default function NewRoomScreen() {
+ const router = useRouter();
+ const { client, selectRoom } = useMatrixStore();
+
+ const [mode, setMode] = useState('room');
+ const [name, setName] = useState('');
+ const [topic, setTopic] = useState('');
+ const [dmTarget, setDmTarget] = useState('');
+ const [isPrivate, setIsPrivate] = useState(true);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleCreate = async () => {
+ if (!client) return;
+ setError(null);
+ setLoading(true);
+
+ try {
+ if (mode === 'dm') {
+ let userId = dmTarget.trim();
+ if (!userId) {
+ setError('Enter a Matrix user ID');
+ return;
+ }
+ // Ensure proper format
+ if (!userId.startsWith('@')) userId = `@${userId}`;
+ if (!userId.includes(':')) {
+ const hs = new URL(client.baseUrl).hostname;
+ userId = `${userId}:${hs}`;
+ }
+
+ const room = await client.createRoom({
+ is_direct: true,
+ invite: [userId],
+ preset: 'trusted_private_chat' as any,
+ initial_state: [
+ {
+ type: 'm.room.encryption',
+ state_key: '',
+ content: { algorithm: 'm.megolm' },
+ },
+ ],
+ });
+
+ selectRoom(room.room_id);
+ router.replace(`/room/${room.room_id}`);
+ } else {
+ if (!name.trim()) {
+ setError('Enter a room name');
+ return;
+ }
+
+ const room = await client.createRoom({
+ name: name.trim(),
+ topic: topic.trim() || undefined,
+ preset: isPrivate ? ('private_chat' as any) : ('public_chat' as any),
+ visibility: isPrivate ? ('private' as any) : ('public' as any),
+ });
+
+ selectRoom(room.room_id);
+ router.replace(`/room/${room.room_id}`);
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to create room');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+ router.back()}
+ className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
+ >
+
+
+ New conversation
+
+
+
+
+ {/* Mode toggle */}
+
+ {(['dm', 'room'] as Mode[]).map((m) => (
+ {
+ setMode(m);
+ setError(null);
+ }}
+ className={`flex-1 flex-row items-center justify-center gap-2 py-2.5 rounded-xl ${mode === m ? 'bg-primary' : ''}`}
+ >
+ {m === 'dm' ? (
+
+ ) : (
+
+ )}
+
+ {m === 'dm' ? 'Direct message' : 'Group room'}
+
+
+ ))}
+
+
+ {/* DM form */}
+ {mode === 'dm' && (
+
+
+
+ User ID
+
+
+
+
+ )}
+
+ {/* Room form */}
+ {mode === 'room' && (
+
+
+
+ Room name
+
+
+
+
+
+ Topic (optional)
+
+
+
+
+
+ Private room
+
+ Only invited members can join
+
+
+
+
+
+ )}
+
+ {/* Error */}
+ {error && {error}}
+
+ {/* Create button */}
+
+ `bg-primary rounded-xl py-4 items-center ${pressed || loading ? 'opacity-70' : ''}`
+ }
+ >
+ {loading ? (
+
+ ) : (
+
+ {mode === 'dm' ? 'Start conversation' : 'Create room'}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/room/settings.tsx b/apps/matrix/apps/mobile/app/room/settings.tsx
new file mode 100644
index 000000000..631fb9a5a
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/room/settings.tsx
@@ -0,0 +1,174 @@
+import { useState, useEffect } from 'react';
+import { View, Text, TextInput, Pressable, ScrollView, Alert, ActivityIndicator } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import { ArrowLeft, Camera } from 'phosphor-react-native';
+import { Image } from 'expo-image';
+import * as ImagePicker from 'expo-image-picker';
+import { useMatrixStore } from '~/src/matrix/store';
+import { uploadMedia } from '~/src/matrix/upload';
+import { resolveMxcThumbnail } from '~/src/matrix/media';
+
+export default function RoomSettingsScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const { client, credentials, rooms } = useMatrixStore();
+
+ const room = rooms.find((r) => r.id === id);
+
+ const [name, setName] = useState(room?.name ?? '');
+ const [topic, setTopic] = useState(room?.topic ?? '');
+ const [avatarUri, setAvatarUri] = useState(room?.avatar ?? null);
+ const [saving, setSaving] = useState(false);
+ const [uploadingAvatar, setUploadingAvatar] = useState(false);
+ const [newAvatarMxc, setNewAvatarMxc] = useState(null);
+
+ useEffect(() => {
+ if (room) {
+ setName(room.name);
+ setTopic(room.topic ?? '');
+ setAvatarUri(room.avatar ?? null);
+ }
+ }, [room?.id]);
+
+ const handlePickAvatar = async () => {
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ImagePicker.MediaTypeOptions.Images,
+ allowsEditing: true,
+ aspect: [1, 1],
+ quality: 0.85,
+ });
+ if (result.canceled || !result.assets[0] || !client) return;
+ const asset = result.assets[0];
+ setUploadingAvatar(true);
+ try {
+ const filename = `avatar_${Date.now()}.jpg`;
+ const uploaded = await uploadMedia(client, asset.uri, filename, 'image/jpeg');
+ setNewAvatarMxc(uploaded.mxcUrl);
+ setAvatarUri(
+ credentials ? (resolveMxcThumbnail(uploaded.mxcUrl, credentials.homeserver, 128, 128) ?? asset.uri) : asset.uri,
+ );
+ } catch (err) {
+ Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setUploadingAvatar(false);
+ }
+ };
+
+ const handleSave = async () => {
+ if (!client || !id) return;
+ setSaving(true);
+ try {
+ const trimmedName = name.trim();
+ const trimmedTopic = topic.trim();
+
+ if (trimmedName && trimmedName !== room?.name) {
+ await client.setRoomName(id, trimmedName);
+ }
+ if (trimmedTopic !== (room?.topic ?? '')) {
+ await (client as any).sendStateEvent(id, 'm.room.topic', { topic: trimmedTopic }, '');
+ }
+ if (newAvatarMxc) {
+ await (client as any).sendStateEvent(id, 'm.room.avatar', { url: newAvatarMxc }, '');
+ }
+ router.back();
+ } catch (err) {
+ Alert.alert('Save failed', err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const hasChanges =
+ name.trim() !== room?.name ||
+ topic.trim() !== (room?.topic ?? '') ||
+ newAvatarMxc !== null;
+
+ return (
+
+ {/* Header */}
+
+ router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
+
+
+ Room Settings
+
+ `px-4 py-1.5 rounded-full ${hasChanges && !saving ? 'bg-primary' : 'bg-surface border border-border'} ${pressed ? 'opacity-60' : ''}`
+ }
+ >
+ {saving ? (
+
+ ) : (
+
+ Save
+
+ )}
+
+
+
+
+ {/* Avatar */}
+
+
+
+ {uploadingAvatar ? (
+
+ ) : avatarUri ? (
+
+ ) : (
+
+ {room?.name?.[0]?.toUpperCase() ?? '#'}
+
+ )}
+
+
+
+
+
+ Tap to change room avatar
+
+
+ {/* Name */}
+
+ Room name
+
+
+
+ {/* Topic */}
+
+ Topic
+
+
+
+ {/* Room ID info */}
+
+ Room ID
+
+ {id}
+
+
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/app/search.tsx b/apps/matrix/apps/mobile/app/search.tsx
new file mode 100644
index 000000000..3a05f90ed
--- /dev/null
+++ b/apps/matrix/apps/mobile/app/search.tsx
@@ -0,0 +1,200 @@
+import { useState, useCallback } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ FlatList,
+ Pressable,
+ ActivityIndicator,
+ Alert,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useRouter } from 'expo-router';
+import { ArrowLeft, MagnifyingGlass, Lock, Users } from 'phosphor-react-native';
+import { Image } from 'expo-image';
+import { useMatrixStore } from '~/src/matrix/store';
+
+interface PublicRoom {
+ room_id: string;
+ name?: string;
+ topic?: string;
+ avatar_url?: string;
+ num_joined_members: number;
+ world_readable: boolean;
+ guest_can_join: boolean;
+ join_rule?: string;
+}
+
+export default function SearchScreen() {
+ const router = useRouter();
+ const { client, credentials, selectRoom, acceptInvite } = useMatrixStore();
+
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [joiningId, setJoiningId] = useState(null);
+ const [nextBatch, setNextBatch] = useState();
+ const [hasMore, setHasMore] = useState(false);
+
+ const search = useCallback(
+ async (q: string, since?: string) => {
+ if (!client || !credentials) return;
+ setLoading(true);
+ try {
+ const response = await (client as any).publicRooms({
+ limit: 20,
+ filter: { generic_search_term: q },
+ since,
+ server: new URL(credentials.homeserver).hostname,
+ });
+ const rooms: PublicRoom[] = response.chunk ?? [];
+ setResults((prev) => (since ? [...prev, ...rooms] : rooms));
+ setNextBatch(response.next_batch);
+ setHasMore(!!response.next_batch);
+ } catch (err) {
+ Alert.alert('Search failed', err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setLoading(false);
+ }
+ },
+ [client, credentials],
+ );
+
+ const handleSearch = (text: string) => {
+ setQuery(text);
+ setNextBatch(undefined);
+ if (text.length >= 2 || text.length === 0) {
+ search(text);
+ }
+ };
+
+ const handleLoadMore = () => {
+ if (hasMore && nextBatch && !loading) {
+ search(query, nextBatch);
+ }
+ };
+
+ const handleJoin = async (room: PublicRoom) => {
+ if (!client) return;
+ setJoiningId(room.room_id);
+ try {
+ await client.joinRoom(room.room_id);
+ selectRoom(room.room_id);
+ router.replace(`/room/${room.room_id}`);
+ } catch (err) {
+ Alert.alert('Could not join', err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setJoiningId(null);
+ }
+ };
+
+ const renderRoom = ({ item }: { item: PublicRoom }) => {
+ const name = item.name ?? item.room_id;
+ const initial = name[0]?.toUpperCase() ?? '#';
+ const isJoining = joiningId === item.room_id;
+
+ return (
+
+ {/* Avatar */}
+
+ {item.avatar_url ? (
+
+ ) : (
+ {initial}
+ )}
+
+
+ {/* Info */}
+
+
+
+ {name}
+
+ {item.join_rule === 'public' ? null : (
+
+ )}
+
+ {item.topic && (
+
+ {item.topic}
+
+ )}
+
+
+ {item.num_joined_members}
+
+
+
+ {/* Join button */}
+ handleJoin(item)}
+ disabled={isJoining}
+ className={({ pressed }) =>
+ `bg-primary rounded-lg px-3 py-1.5 shrink-0 ${pressed || isJoining ? 'opacity-60' : ''}`
+ }
+ >
+ {isJoining ? (
+
+ ) : (
+ Join
+ )}
+
+
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+ router.back()}
+ className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
+ >
+
+
+ Explore rooms
+
+
+ {/* Search bar */}
+
+
+
+ {loading && }
+
+
+ item.room_id}
+ renderItem={renderRoom}
+ onEndReached={handleLoadMore}
+ onEndReachedThreshold={0.2}
+ ListEmptyComponent={
+ !loading ? (
+
+
+ {query.length > 0 ? 'No rooms found' : 'Search for public rooms'}
+
+
+ ) : null
+ }
+ ListFooterComponent={
+ hasMore && !loading ? (
+
+ Load more
+
+ ) : null
+ }
+ />
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/babel.config.js b/apps/matrix/apps/mobile/babel.config.js
new file mode 100644
index 000000000..d830ae815
--- /dev/null
+++ b/apps/matrix/apps/mobile/babel.config.js
@@ -0,0 +1,7 @@
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
+ plugins: ['react-native-reanimated/plugin'],
+ };
+};
diff --git a/apps/matrix/apps/mobile/eas.json b/apps/matrix/apps/mobile/eas.json
new file mode 100644
index 000000000..ce810685e
--- /dev/null
+++ b/apps/matrix/apps/mobile/eas.json
@@ -0,0 +1,21 @@
+{
+ "cli": {
+ "version": ">= 15.0.15",
+ "appVersionSource": "remote"
+ },
+ "build": {
+ "development": {
+ "developmentClient": true,
+ "distribution": "internal"
+ },
+ "preview": {
+ "distribution": "internal"
+ },
+ "production": {
+ "autoIncrement": true
+ }
+ },
+ "submit": {
+ "production": {}
+ }
+}
diff --git a/apps/matrix/apps/mobile/eslint.config.js b/apps/matrix/apps/mobile/eslint.config.js
new file mode 100644
index 000000000..9be743690
--- /dev/null
+++ b/apps/matrix/apps/mobile/eslint.config.js
@@ -0,0 +1,9 @@
+const { defineConfig } = require('eslint/config');
+const expoConfig = require('eslint-config-expo/flat');
+
+module.exports = defineConfig([
+ ...expoConfig,
+ {
+ ignores: ['dist/**', 'build/**', '.expo/**', 'node_modules/**'],
+ },
+]);
diff --git a/apps/matrix/apps/mobile/expo-env.d.ts b/apps/matrix/apps/mobile/expo-env.d.ts
new file mode 100644
index 000000000..bf3c1693a
--- /dev/null
+++ b/apps/matrix/apps/mobile/expo-env.d.ts
@@ -0,0 +1,3 @@
+///
+
+// NOTE: This file should not be edited and should be in your git ignore
diff --git a/apps/matrix/apps/mobile/global.css b/apps/matrix/apps/mobile/global.css
new file mode 100644
index 000000000..b5c61c956
--- /dev/null
+++ b/apps/matrix/apps/mobile/global.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/apps/matrix/apps/mobile/metro.config.js b/apps/matrix/apps/mobile/metro.config.js
new file mode 100644
index 000000000..35589a466
--- /dev/null
+++ b/apps/matrix/apps/mobile/metro.config.js
@@ -0,0 +1,23 @@
+const { getDefaultConfig } = require('expo/metro-config');
+const { withNativeWind } = require('nativewind/metro');
+const path = require('path');
+
+const config = getDefaultConfig(__dirname);
+
+// Polyfills for matrix-js-sdk (browser-oriented SDK)
+config.resolver.extraNodeModules = {
+ ...config.resolver.extraNodeModules,
+ events: require.resolve('events'),
+ buffer: require.resolve('buffer'),
+ stream: require.resolve('stream-browserify'),
+};
+
+// Monorepo workspace support
+const monorepoRoot = path.resolve(__dirname, '../../../..');
+config.watchFolders = [monorepoRoot];
+config.resolver.nodeModulesPaths = [
+ path.resolve(__dirname, 'node_modules'),
+ path.resolve(monorepoRoot, 'node_modules'),
+];
+
+module.exports = withNativeWind(config, { input: './global.css' });
diff --git a/apps/matrix/apps/mobile/nativewind-env.d.ts b/apps/matrix/apps/mobile/nativewind-env.d.ts
new file mode 100644
index 000000000..a13e3136b
--- /dev/null
+++ b/apps/matrix/apps/mobile/nativewind-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/matrix/apps/mobile/package.json b/apps/matrix/apps/mobile/package.json
new file mode 100644
index 000000000..2b15e8699
--- /dev/null
+++ b/apps/matrix/apps/mobile/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "@matrix/mobile",
+ "version": "0.1.0",
+ "main": "expo-router/entry",
+ "scripts": {
+ "dev": "expo start",
+ "ios": "expo run:ios",
+ "android": "expo run:android",
+ "build:dev": "eas build --profile development",
+ "build:preview": "eas build --profile preview",
+ "build:prod": "eas build --profile production",
+ "prebuild": "expo prebuild",
+ "type-check": "tsc --noEmit",
+ "lint": "eslint .",
+ "format": "eslint . --fix"
+ },
+ "dependencies": {
+ "expo": "^55.0.0",
+ "expo-router": "~7.0.0",
+ "expo-constants": "~19.0.0",
+ "expo-status-bar": "~4.0.0",
+ "expo-system-ui": "~7.0.0",
+ "expo-linking": "~9.0.0",
+ "expo-secure-store": "~16.0.0",
+ "expo-image": "~4.0.0",
+ "expo-image-picker": "~16.0.0",
+ "expo-document-picker": "~13.0.0",
+ "expo-media-library": "~17.0.0",
+ "expo-file-system": "~19.0.0",
+ "expo-av": "~15.0.0",
+ "expo-notifications": "~1.0.0",
+ "expo-haptics": "~16.0.0",
+ "expo-web-browser": "~16.0.0",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "react-native": "~0.82.0",
+ "react-native-gesture-handler": "~2.28.0",
+ "react-native-reanimated": "~4.2.0",
+ "react-native-safe-area-context": "~5.6.0",
+ "react-native-screens": "~4.17.0",
+ "react-native-web": "~0.21.0",
+ "nativewind": "latest",
+ "zustand": "^4.5.1",
+ "@react-native-async-storage/async-storage": "2.2.0",
+ "matrix-js-sdk": "^37.1.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "phosphor-react-native": "^2.3.0",
+ "stream-browserify": "^3.0.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.20.0",
+ "@types/react": "~19.2.0",
+ "@types/react-native": "~0.82.0",
+ "eslint": "^9.25.1",
+ "eslint-config-expo": "^9.2.0",
+ "prettier": "^3.2.5",
+ "prettier-plugin-tailwindcss": "^0.5.11",
+ "tailwindcss": "^3.4.0",
+ "typescript": "~5.8.3"
+ },
+ "private": true
+}
diff --git a/apps/matrix/apps/mobile/prettier.config.js b/apps/matrix/apps/mobile/prettier.config.js
new file mode 100644
index 000000000..698fc9790
--- /dev/null
+++ b/apps/matrix/apps/mobile/prettier.config.js
@@ -0,0 +1,8 @@
+module.exports = {
+ semi: true,
+ singleQuote: true,
+ trailingComma: 'all',
+ printWidth: 100,
+ useTabs: true,
+ plugins: ['prettier-plugin-tailwindcss'],
+};
diff --git a/apps/matrix/apps/mobile/src/components/DateSeparator.tsx b/apps/matrix/apps/mobile/src/components/DateSeparator.tsx
new file mode 100644
index 000000000..437094e73
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/DateSeparator.tsx
@@ -0,0 +1,28 @@
+import { View, Text } from 'react-native';
+
+interface Props {
+ timestamp: number;
+}
+
+function formatDate(timestamp: number): string {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000);
+
+ if (diffDays === 0) return 'Today';
+ if (diffDays === 1) return 'Yesterday';
+ if (diffDays < 7) {
+ return date.toLocaleDateString([], { weekday: 'long' });
+ }
+ return date.toLocaleDateString([], { day: 'numeric', month: 'long', year: 'numeric' });
+}
+
+export default function DateSeparator({ timestamp }: Props) {
+ return (
+
+
+ {formatDate(timestamp)}
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/components/ImageViewer.tsx b/apps/matrix/apps/mobile/src/components/ImageViewer.tsx
new file mode 100644
index 000000000..22fe43107
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/ImageViewer.tsx
@@ -0,0 +1,72 @@
+import { Modal, View, Pressable, StatusBar, Dimensions } from 'react-native';
+import { Image } from 'expo-image';
+import { X, DownloadSimple } from 'phosphor-react-native';
+import * as FileSystem from 'expo-file-system';
+import * as MediaLibrary from 'expo-media-library';
+import { useState } from 'react';
+
+interface Props {
+ uri: string | null;
+ onClose: () => void;
+}
+
+const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window');
+
+export default function ImageViewer({ uri, onClose }: Props) {
+ const [saving, setSaving] = useState(false);
+
+ const handleSave = async () => {
+ if (!uri || saving) return;
+ setSaving(true);
+ try {
+ const { status } = await MediaLibrary.requestPermissionsAsync();
+ if (status !== 'granted') return;
+
+ const filename = `manalink_${Date.now()}.jpg`;
+ const localUri = FileSystem.cacheDirectory + filename;
+ await FileSystem.downloadAsync(uri, localUri);
+ await MediaLibrary.saveToLibraryAsync(localUri);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ {/* Controls */}
+
+
+ `w-10 h-10 rounded-full bg-black/50 items-center justify-center ${pressed ? 'opacity-60' : ''}`
+ }
+ >
+
+
+
+ `w-10 h-10 rounded-full bg-black/50 items-center justify-center ${pressed || saving ? 'opacity-60' : ''}`
+ }
+ >
+
+
+
+
+ {/* Image */}
+ {uri && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/components/MessageBubble.tsx b/apps/matrix/apps/mobile/src/components/MessageBubble.tsx
new file mode 100644
index 000000000..e5fc46d21
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/MessageBubble.tsx
@@ -0,0 +1,246 @@
+import { View, Text, Pressable, ActionSheetIOS, Platform, Alert, Clipboard } from 'react-native';
+import { Swipeable } from 'react-native-gesture-handler';
+import Animated, { useAnimatedStyle, interpolate, Extrapolation } from 'react-native-reanimated';
+import { Image } from 'expo-image';
+import { ArrowBendUpLeft } from 'phosphor-react-native';
+import type { SimpleMessage } from '~/src/matrix/types';
+import MessageText from './MessageText';
+import VoiceMessage from './VoiceMessage';
+
+interface Props {
+ message: SimpleMessage;
+ prevMessage: SimpleMessage | null;
+ onReply?: (message: SimpleMessage) => void;
+ onEdit?: (message: SimpleMessage) => void;
+ onReact?: (eventId: string, emoji: string) => void;
+ onDelete?: (eventId: string) => void;
+ onImagePress?: (uri: string) => void;
+ onAvatarPress?: (userId: string) => void;
+}
+
+function formatTime(ts: number) {
+ return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
+
+function AvatarCircle({ name, url, onPress, size = 28 }: { name: string; url?: string; onPress?: () => void; size?: number }) {
+ const inner = (
+
+ {url ? (
+
+ ) : (
+
+ {name[0]?.toUpperCase() ?? '?'}
+
+ )}
+
+ );
+ if (!onPress) return inner;
+ return (
+ `${pressed ? 'opacity-60' : ''}`}>
+ {inner}
+
+ );
+}
+
+const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢'];
+
+function SwipeReplyAction({ progress }: { progress: Animated.SharedValue }) {
+ const style = useAnimatedStyle(() => ({
+ opacity: interpolate(progress.value, [0, 0.5, 1], [0, 0.6, 1], Extrapolation.CLAMP),
+ transform: [{ scale: interpolate(progress.value, [0, 1], [0.5, 1], Extrapolation.CLAMP) }],
+ }));
+ return (
+
+
+
+
+
+ );
+}
+
+export default function MessageBubble({ message, prevMessage, onReply, onEdit, onReact, onDelete, onImagePress, onAvatarPress }: Props) {
+ const isOwn = message.isOwn;
+ const isGrouped =
+ !message.redacted &&
+ prevMessage !== null &&
+ prevMessage.sender === message.sender &&
+ message.timestamp - prevMessage.timestamp < 300_000;
+ const showAvatar = !isOwn && !isGrouped;
+ const showSenderName = !isOwn && !isGrouped;
+
+ const handleLongPress = () => {
+ const extraOptions = isOwn && !message.redacted ? ['Edit', 'Delete'] : [];
+ const options = ['Cancel', 'Reply', ...QUICK_REACTIONS, 'Copy text', ...extraOptions];
+ const destructiveIndex = isOwn && !message.redacted ? options.length - 1 : undefined;
+
+ if (Platform.OS === 'ios') {
+ ActionSheetIOS.showActionSheetWithOptions(
+ { options, cancelButtonIndex: 0, destructiveButtonIndex: destructiveIndex },
+ (index) => {
+ if (index === 0) return;
+ if (index === 1) { onReply?.(message); return; }
+ const ri = index - 2;
+ if (ri < QUICK_REACTIONS.length) { onReact?.(message.id, QUICK_REACTIONS[ri]); return; }
+ const ai = index - 2 - QUICK_REACTIONS.length;
+ if (ai === 0) { Clipboard.setString(message.body); return; }
+ if (ai === 1 && isOwn) { onEdit?.(message); return; }
+ if (ai === 2 && isOwn) { onDelete?.(message.id); }
+ },
+ );
+ } else {
+ Alert.alert('Message', undefined, [
+ { text: 'Reply', onPress: () => onReply?.(message) },
+ ...(isOwn ? [{ text: 'Edit', onPress: () => onEdit?.(message) }] : []),
+ { text: 'Copy text', onPress: () => Clipboard.setString(message.body) },
+ ...(isOwn && !message.redacted
+ ? [{ text: 'Delete', style: 'destructive' as const, onPress: () => onDelete?.(message.id) }]
+ : []),
+ { text: 'Cancel', style: 'cancel' as const },
+ ]);
+ }
+ };
+
+ if (message.redacted) {
+ return (
+
+
+ Message deleted
+
+
+ );
+ }
+
+ const renderLeftActions = isOwn
+ ? undefined
+ : (progress: Animated.SharedValue) => ;
+
+ const renderRightActions = isOwn
+ ? (progress: Animated.SharedValue) =>
+ : undefined;
+
+ return (
+ {
+ if ((direction === 'left' && !isOwn) || (direction === 'right' && isOwn)) {
+ onReply?.(message);
+ }
+ }}
+ friction={2}
+ overshootFriction={8}
+ >
+
+ {/* Left avatar */}
+ {!isOwn && (
+
+ {showAvatar && (
+ onAvatarPress(message.sender) : undefined}
+ />
+ )}
+
+ )}
+
+
+ {showSenderName && (
+ onAvatarPress(message.sender) : undefined}>
+ {message.senderName}
+
+ )}
+
+
+ {/* Reply preview */}
+ {message.replyTo && (
+
+
+ {message.replyToSenderName ?? 'Unknown'}
+
+
+ {message.replyToBody ?? '…'}
+
+
+ )}
+
+ {message.type === 'm.image' && message.media?.thumbnailUrl && (
+ onImagePress?.(message.media!.thumbnailUrl!)}>
+
+
+ )}
+
+ {message.type === 'm.file' && (
+
+ 📎
+
+ {message.media?.filename ?? message.body}
+
+
+ )}
+
+ {message.type === 'm.audio' && message.media?.downloadUrl && (
+
+ )}
+
+ {(message.type === 'm.text' || message.type === 'm.notice' || message.type === 'm.emote') && (
+
+ )}
+
+
+ {/* Reactions */}
+ {message.reactions && message.reactions.length > 0 && (
+
+ {message.reactions.map((r) => (
+ onReact?.(message.id, r.key)}
+ className={`flex-row items-center gap-0.5 px-2 py-0.5 rounded-full border ${
+ r.includesMe ? 'bg-primary/20 border-primary/40' : 'bg-surface border-border'
+ }`}
+ >
+ {r.key}
+ {r.count > 1 && (
+
+ {r.count}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ {formatTime(message.timestamp)}
+ {message.edited && ' · edited'}
+
+
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/components/MessageInput.tsx b/apps/matrix/apps/mobile/src/components/MessageInput.tsx
new file mode 100644
index 000000000..29bffa5a9
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/MessageInput.tsx
@@ -0,0 +1,157 @@
+import { useState, useRef, useEffect } from 'react';
+import { View, TextInput, Pressable, Text } from 'react-native';
+import * as Haptics from 'expo-haptics';
+import { ArrowUp, Paperclip, Microphone, X, PencilSimple } from 'phosphor-react-native';
+import type { SimpleMessage } from '~/src/matrix/types';
+
+interface Props {
+ onSend: (body: string, replyToEventId?: string) => Promise;
+ onEdit?: (eventId: string, newBody: string) => Promise;
+ onTyping: (typing: boolean) => Promise;
+ onAttach?: () => void;
+ onVoiceRecord?: () => void;
+ replyTo?: SimpleMessage | null;
+ onCancelReply?: () => void;
+ editingMessage?: SimpleMessage | null;
+ onCancelEdit?: () => void;
+}
+
+export default function MessageInput({
+ onSend,
+ onEdit,
+ onTyping,
+ onAttach,
+ onVoiceRecord,
+ replyTo,
+ onCancelReply,
+ editingMessage,
+ onCancelEdit,
+}: Props) {
+ const [text, setText] = useState('');
+ const [sending, setSending] = useState(false);
+ const typingTimer = useRef | null>(null);
+
+ // Pre-fill text when entering edit mode
+ useEffect(() => {
+ if (editingMessage) setText(editingMessage.body);
+ else setText('');
+ }, [editingMessage?.id]);
+
+ const handleChangeText = (value: string) => {
+ setText(value);
+ if (!editingMessage) {
+ onTyping(true);
+ if (typingTimer.current) clearTimeout(typingTimer.current);
+ typingTimer.current = setTimeout(() => onTyping(false), 3000);
+ }
+ };
+
+ const handleSubmit = async () => {
+ const body = text.trim();
+ if (!body || sending) return;
+ setSending(true);
+ if (typingTimer.current) clearTimeout(typingTimer.current);
+ onTyping(false);
+ await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ try {
+ if (editingMessage) {
+ await onEdit?.(editingMessage.id, body);
+ onCancelEdit?.();
+ } else {
+ await onSend(body, replyTo?.id);
+ onCancelReply?.();
+ }
+ setText('');
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const canSend = text.trim().length > 0 && !sending;
+ const isEditing = !!editingMessage;
+ const showMic = !canSend && !isEditing && !!onVoiceRecord;
+
+ return (
+
+ {/* Context banner: Reply or Edit */}
+ {(replyTo || isEditing) && (
+
+
+
+
+ {isEditing ? 'Editing message' : `Reply to ${replyTo!.senderName}`}
+
+
+ {isEditing ? editingMessage!.body : replyTo!.body}
+
+
+ `p-1 ${pressed ? 'opacity-50' : ''}`}
+ >
+
+
+
+ )}
+
+ {/* Input row */}
+
+ {onAttach && !isEditing && (
+
+ `w-10 h-10 items-center justify-center rounded-full ${pressed ? 'opacity-50' : ''}`
+ }
+ >
+
+
+ )}
+
+
+
+ {showMic ? (
+
+ `w-10 h-10 rounded-full items-center justify-center bg-surface border border-border ${pressed ? 'opacity-60' : ''}`
+ }
+ >
+
+
+ ) : (
+
+ `w-10 h-10 rounded-full items-center justify-center ${
+ canSend
+ ? isEditing ? 'bg-yellow-500' : 'bg-primary'
+ : 'bg-surface border border-border'
+ } ${pressed ? 'opacity-60' : ''}`
+ }
+ >
+ {isEditing ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/components/MessageText.tsx b/apps/matrix/apps/mobile/src/components/MessageText.tsx
new file mode 100644
index 000000000..7f34d25c0
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/MessageText.tsx
@@ -0,0 +1,80 @@
+import { Text, Linking } from 'react-native';
+
+const URL_REGEX = /(https?:\/\/[^\s<>[\]{}|\\^`"]+)/g;
+const MENTION_REGEX = /(@[\w.-]+:[\w.-]+)/g;
+
+interface Segment {
+ text: string;
+ type: 'text' | 'url' | 'mention';
+}
+
+function parseSegments(body: string): Segment[] {
+ const segments: Segment[] = [];
+ // Split on URLs first, then handle mentions
+ const parts = body.split(URL_REGEX);
+ for (const part of parts) {
+ if (URL_REGEX.test(part)) {
+ segments.push({ text: part, type: 'url' });
+ URL_REGEX.lastIndex = 0;
+ } else {
+ // Split on @mentions
+ const mentionParts = part.split(MENTION_REGEX);
+ for (const mp of mentionParts) {
+ if (MENTION_REGEX.test(mp)) {
+ segments.push({ text: mp, type: 'mention' });
+ MENTION_REGEX.lastIndex = 0;
+ } else if (mp) {
+ segments.push({ text: mp, type: 'text' });
+ }
+ }
+ }
+ }
+ return segments;
+}
+
+interface Props {
+ body: string;
+ isOwn: boolean;
+ className?: string;
+}
+
+export default function MessageText({ body, isOwn, className }: Props) {
+ const segments = parseSegments(body);
+ const baseColor = isOwn ? 'rgba(255,255,255,0.95)' : undefined;
+
+ return (
+
+ {segments.map((seg, i) => {
+ if (seg.type === 'url') {
+ return (
+ Linking.openURL(seg.text).catch(() => {})}
+ >
+ {seg.text}
+
+ );
+ }
+ if (seg.type === 'mention') {
+ return (
+
+ {seg.text}
+
+ );
+ }
+ return (
+
+ {seg.text}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/components/RoomListItem.tsx b/apps/matrix/apps/mobile/src/components/RoomListItem.tsx
new file mode 100644
index 000000000..b03f1cc9e
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/RoomListItem.tsx
@@ -0,0 +1,106 @@
+import { View, Text, Pressable } from 'react-native';
+import { Image } from 'expo-image';
+import type { SimpleRoom } from '~/src/matrix/types';
+
+interface Props {
+ room: SimpleRoom;
+ onPress: () => void;
+}
+
+function formatTime(timestamp?: number): string {
+ if (!timestamp) return '';
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000);
+ if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ if (diffDays === 1) return 'Yesterday';
+ if (diffDays < 7) return date.toLocaleDateString([], { weekday: 'short' });
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
+}
+
+function PresenceDot({ presence }: { presence?: string }) {
+ if (!presence || presence === 'offline') return null;
+ return (
+
+ );
+}
+
+export default function RoomListItem({ room, onPress }: Props) {
+ const hasHighlight = room.highlightCount > 0;
+ const hasUnread = room.unreadCount > 0;
+ const displayName = room.name ?? room.id;
+ const initial = displayName[0]?.toUpperCase() ?? '?';
+
+ return (
+
+ `flex-row items-center px-4 py-3 gap-3 ${pressed ? 'bg-surface/60' : ''}`
+ }
+ >
+ {/* Avatar */}
+
+
+ {room.avatar ? (
+
+ ) : (
+ {initial}
+ )}
+
+ {room.isDirect && }
+
+
+ {/* Content */}
+
+
+
+ {displayName}
+
+
+ {formatTime(room.lastMessageTime)}
+
+
+
+
+
+ {room.lastMessage
+ ? (room.lastMessageSender && !room.isDirect
+ ? `${room.lastMessageSender.split(':')[0].slice(1)}: `
+ : '') + room.lastMessage
+ : room.isEncrypted
+ ? '🔒 Encrypted'
+ : 'No messages'}
+
+
+ {/* Badge */}
+ {(hasUnread || hasHighlight) && (
+
+
+ {hasHighlight ? room.highlightCount : room.unreadCount > 99 ? '99+' : room.unreadCount}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/components/SyncStatusBar.tsx b/apps/matrix/apps/mobile/src/components/SyncStatusBar.tsx
new file mode 100644
index 000000000..0d1f1f1b6
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/SyncStatusBar.tsx
@@ -0,0 +1,26 @@
+import { View, Text } from 'react-native';
+import type { SyncState } from '~/src/matrix/types';
+
+interface Props {
+ syncState: SyncState;
+}
+
+const statusConfig: Record = {
+ STOPPED: { label: 'Disconnected', color: 'bg-destructive/80' },
+ ERROR: { label: 'Connection error', color: 'bg-destructive/80' },
+ RECONNECTING: { label: 'Reconnecting...', color: 'bg-yellow-500/80' },
+ CATCHUP: { label: 'Catching up...', color: 'bg-yellow-500/80' },
+ PREPARED: null,
+ SYNCING: null,
+};
+
+export default function SyncStatusBar({ syncState }: Props) {
+ const config = statusConfig[syncState];
+ if (!config) return null;
+
+ return (
+
+ {config.label}
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/components/TypingIndicator.tsx b/apps/matrix/apps/mobile/src/components/TypingIndicator.tsx
new file mode 100644
index 000000000..7828c94e2
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/TypingIndicator.tsx
@@ -0,0 +1,22 @@
+import { View, Text } from 'react-native';
+
+interface Props {
+ users: string[];
+}
+
+export default function TypingIndicator({ users }: Props) {
+ if (users.length === 0) return null;
+
+ const label =
+ users.length === 1
+ ? `${users[0]} is typing...`
+ : users.length === 2
+ ? `${users[0]} and ${users[1]} are typing...`
+ : 'Several people are typing...';
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/components/UnreadSeparator.tsx b/apps/matrix/apps/mobile/src/components/UnreadSeparator.tsx
new file mode 100644
index 000000000..e439eaaa3
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/UnreadSeparator.tsx
@@ -0,0 +1,11 @@
+import { View, Text } from 'react-native';
+
+export default function UnreadSeparator() {
+ return (
+
+
+ New messages
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx b/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx
new file mode 100644
index 000000000..126e0e767
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx
@@ -0,0 +1,149 @@
+import { Modal, View, Text, Pressable, ActivityIndicator, ScrollView } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { Image } from 'expo-image';
+import { X, ChatCircle } from 'phosphor-react-native';
+import { useEffect, useState } from 'react';
+import { useMatrixStore } from '~/src/matrix/store';
+import { resolveMxcThumbnail } from '~/src/matrix/media';
+import { useRouter } from 'expo-router';
+
+interface UserProfile {
+ userId: string;
+ displayName: string;
+ avatarUrl?: string;
+}
+
+interface Props {
+ userId: string | null;
+ onClose: () => void;
+}
+
+export default function UserProfileModal({ userId, onClose }: Props) {
+ const { client, credentials, rooms, selectRoom } = useMatrixStore();
+ const router = useRouter();
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (!userId || !client || !credentials) return;
+ setLoading(true);
+ setProfile(null);
+
+ client.getProfileInfo(userId)
+ .then((info) => {
+ const rawAvatar = info.avatar_url ?? null;
+ setProfile({
+ userId,
+ displayName: info.displayname ?? userId.split(':')[0].slice(1),
+ avatarUrl: rawAvatar
+ ? resolveMxcThumbnail(rawAvatar, credentials.homeserver, 160, 160) ?? undefined
+ : undefined,
+ });
+ })
+ .catch(() => {
+ setProfile({
+ userId,
+ displayName: userId.split(':')[0].slice(1),
+ });
+ })
+ .finally(() => setLoading(false));
+ }, [userId]);
+
+ // Find an existing DM room with this user
+ const existingDM = userId
+ ? rooms.find((r) => r.isDirect && r.dmUserId === userId)
+ : null;
+
+ const handleStartDM = async () => {
+ if (!client || !userId || !credentials) return;
+
+ if (existingDM) {
+ selectRoom(existingDM.id);
+ router.push(`/room/${existingDM.id}`);
+ onClose();
+ return;
+ }
+
+ try {
+ const room = await client.createRoom({
+ is_direct: true,
+ invite: [userId],
+ preset: 'trusted_private_chat' as any,
+ });
+ selectRoom(room.room_id);
+ router.push(`/room/${room.room_id}`);
+ onClose();
+ } catch {
+ // ignore
+ }
+ };
+
+ const initial = profile?.displayName[0]?.toUpperCase() ?? '?';
+
+ return (
+
+
+
+ e.stopPropagation()}>
+
+ {/* Handle */}
+
+
+
+
+ {/* Close */}
+
+ `p-1 ${pressed ? 'opacity-50' : ''}`}>
+
+
+
+
+
+ {loading ? (
+
+ ) : profile ? (
+ <>
+ {/* Avatar */}
+
+ {profile.avatarUrl ? (
+
+ ) : (
+ {initial}
+ )}
+
+
+ {/* Name */}
+
+ {profile.displayName}
+ {profile.userId}
+
+
+ {/* Actions */}
+ {profile.userId !== credentials?.userId && (
+
+ `flex-row items-center gap-2 bg-primary rounded-2xl px-6 py-3 ${pressed ? 'opacity-70' : ''}`
+ }
+ >
+
+
+ {existingDM ? 'Open conversation' : 'Send message'}
+
+
+ )}
+ >
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx b/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx
new file mode 100644
index 000000000..6874c96da
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx
@@ -0,0 +1,95 @@
+import { useState, useRef } from 'react';
+import { View, Text, Pressable, ActivityIndicator } from 'react-native';
+import { Audio } from 'expo-av';
+import { Play, Pause } from 'phosphor-react-native';
+
+interface Props {
+ uri: string;
+ duration?: number;
+ isOwn: boolean;
+}
+
+function formatDuration(ms: number): string {
+ const secs = Math.floor(ms / 1000);
+ const m = Math.floor(secs / 60);
+ const s = secs % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+export default function VoiceMessage({ uri, duration, isOwn }: Props) {
+ const soundRef = useRef(null);
+ const [playing, setPlaying] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [position, setPosition] = useState(0);
+ const [totalDuration, setTotalDuration] = useState(duration ?? 0);
+
+ const progress = totalDuration > 0 ? position / totalDuration : 0;
+
+ const handleToggle = async () => {
+ if (loading) return;
+
+ if (playing) {
+ await soundRef.current?.pauseAsync();
+ setPlaying(false);
+ return;
+ }
+
+ if (soundRef.current) {
+ await soundRef.current.playAsync();
+ setPlaying(true);
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await Audio.setAudioModeAsync({ playsInSilentModeIOS: true });
+ const { sound } = await Audio.Sound.createAsync(
+ { uri },
+ { shouldPlay: true },
+ (status) => {
+ if (!status.isLoaded) return;
+ setPosition(status.positionMillis);
+ if (status.durationMillis) setTotalDuration(status.durationMillis);
+ if (status.didJustFinish) {
+ setPlaying(false);
+ setPosition(0);
+ }
+ },
+ );
+ soundRef.current = sound;
+ setPlaying(true);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const iconColor = isOwn ? '#fff' : '#7c6bff';
+ const barColor = isOwn ? 'rgba(255,255,255,0.5)' : '#2a2a2a';
+ const fillColor = isOwn ? '#fff' : '#7c6bff';
+
+ return (
+
+ `w-8 h-8 rounded-full items-center justify-center ${pressed ? 'opacity-60' : ''} ${isOwn ? 'bg-white/20' : 'bg-primary/10'}`}
+ >
+ {loading ? (
+
+ ) : playing ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Waveform / progress bar */}
+
+
+
+
+
+ {formatDuration(playing || position > 0 ? position : totalDuration)}
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx b/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx
new file mode 100644
index 000000000..8dd22f771
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx
@@ -0,0 +1,120 @@
+import { useState, useRef, useEffect } from 'react';
+import { View, Text, Pressable, Animated, Alert } from 'react-native';
+import { Audio } from 'expo-av';
+import { Microphone, Stop, Trash, PaperPlaneRight } from 'phosphor-react-native';
+
+interface Props {
+ onSend: (uri: string, durationMs: number) => Promise;
+ onCancel: () => void;
+}
+
+export default function VoiceRecorder({ onSend, onCancel }: Props) {
+ const recordingRef = useRef(null);
+ const [duration, setDuration] = useState(0);
+ const [sending, setSending] = useState(false);
+ const pulseAnim = useRef(new Animated.Value(1)).current;
+ const timerRef = useRef | null>(null);
+
+ useEffect(() => {
+ startRecording();
+ // Pulse animation
+ const pulse = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulseAnim, { toValue: 1.3, duration: 600, useNativeDriver: true }),
+ Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
+ ]),
+ );
+ pulse.start();
+ return () => {
+ pulse.stop();
+ stopRecordingCleanup();
+ };
+ }, []);
+
+ const startRecording = async () => {
+ try {
+ const { status } = await Audio.requestPermissionsAsync();
+ if (status !== 'granted') {
+ Alert.alert('Permission required', 'Microphone access is needed to record voice messages.');
+ onCancel();
+ return;
+ }
+ await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
+ const { recording } = await Audio.Recording.createAsync(
+ Audio.RecordingOptionsPresets.HIGH_QUALITY,
+ );
+ recordingRef.current = recording;
+ timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000);
+ } catch (err) {
+ Alert.alert('Error', 'Could not start recording');
+ onCancel();
+ }
+ };
+
+ const stopRecordingCleanup = async () => {
+ if (timerRef.current) clearInterval(timerRef.current);
+ try {
+ await recordingRef.current?.stopAndUnloadAsync();
+ } catch { /* ignore */ }
+ };
+
+ const handleSend = async () => {
+ if (!recordingRef.current || sending) return;
+ setSending(true);
+ if (timerRef.current) clearInterval(timerRef.current);
+ try {
+ await recordingRef.current.stopAndUnloadAsync();
+ const uri = recordingRef.current.getURI();
+ if (!uri) throw new Error('No recording URI');
+ await onSend(uri, duration * 1000);
+ } catch (err) {
+ Alert.alert('Error', err instanceof Error ? err.message : 'Send failed');
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const handleDiscard = async () => {
+ await stopRecordingCleanup();
+ onCancel();
+ };
+
+ const formatDuration = (secs: number) => {
+ const m = Math.floor(secs / 60);
+ const s = secs % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+ };
+
+ return (
+
+ {/* Discard */}
+ `w-10 h-10 rounded-full bg-destructive/10 items-center justify-center ${pressed ? 'opacity-60' : ''}`}
+ >
+
+
+
+ {/* Recording indicator */}
+
+
+ {formatDuration(duration)}
+ Recording...
+
+
+ {/* Send */}
+
+ `w-10 h-10 rounded-full items-center justify-center ${duration >= 1 ? 'bg-primary' : 'bg-surface border border-border'} ${pressed || sending ? 'opacity-60' : ''}`
+ }
+ >
+ = 1 ? '#fff' : '#6b7280'} weight="fill" />
+
+
+ );
+}
diff --git a/apps/matrix/apps/mobile/src/matrix/client.ts b/apps/matrix/apps/mobile/src/matrix/client.ts
new file mode 100644
index 000000000..9b99c8e06
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/matrix/client.ts
@@ -0,0 +1,99 @@
+import type { MatrixCredentials, LoginResult } from './types';
+
+function normalizeHomeserver(homeserver: string): string {
+ let url = homeserver.trim();
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
+ url = `https://${url}`;
+ }
+ return url.replace(/\/$/, '');
+}
+
+export async function loginWithPassword(
+ homeserver: string,
+ username: string,
+ password: string,
+): Promise {
+ await import('./polyfills');
+ const { createClient } = await import('matrix-js-sdk');
+
+ const baseUrl = normalizeHomeserver(homeserver);
+ const tempClient = createClient({ baseUrl });
+
+ try {
+ const response = await tempClient.login('m.login.password', {
+ user: username,
+ password,
+ initial_device_display_name: 'Manalink Mobile',
+ });
+
+ return {
+ success: true,
+ credentials: {
+ homeserver: baseUrl,
+ accessToken: response.access_token,
+ userId: response.user_id,
+ deviceId: response.device_id,
+ },
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Login failed';
+ if (message.includes('M_FORBIDDEN')) return { success: false, error: 'Invalid username or password' };
+ if (message.includes('M_USER_DEACTIVATED')) return { success: false, error: 'Account is deactivated' };
+ if (message.includes('Failed to fetch') || message.includes('Network')) {
+ return { success: false, error: 'Could not connect to homeserver' };
+ }
+ return { success: false, error: message };
+ }
+}
+
+export async function loginWithToken(
+ homeserver: string,
+ accessToken: string,
+ userId: string,
+ deviceId?: string,
+): Promise {
+ const baseUrl = normalizeHomeserver(homeserver);
+ return {
+ success: true,
+ credentials: {
+ homeserver: baseUrl,
+ accessToken,
+ userId,
+ deviceId: deviceId ?? `MANALINK_${Date.now()}`,
+ },
+ };
+}
+
+export async function checkHomeserver(homeserver: string): Promise<{ ok: boolean; error?: string }> {
+ const baseUrl = normalizeHomeserver(homeserver);
+ try {
+ const response = await fetch(`${baseUrl}/_matrix/client/versions`);
+ if (response.ok) return { ok: true };
+ return { ok: false, error: `Server returned ${response.status}` };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : 'Could not connect' };
+ }
+}
+
+export async function discoverHomeserver(userIdOrDomain: string): Promise {
+ let domain = userIdOrDomain;
+ if (userIdOrDomain.startsWith('@')) {
+ const parts = userIdOrDomain.split(':');
+ if (parts.length < 2) return null;
+ domain = parts[1];
+ }
+ domain = domain.replace(/^https?:\/\//, '');
+
+ try {
+ const response = await fetch(`https://${domain}/.well-known/matrix/client`);
+ if (response.ok) {
+ const data = await response.json();
+ const baseUrl = data['m.homeserver']?.base_url;
+ if (baseUrl) return baseUrl.replace(/\/$/, '');
+ }
+ } catch {
+ // .well-known not available
+ }
+
+ return `https://${domain}`;
+}
diff --git a/apps/matrix/apps/mobile/src/matrix/index.ts b/apps/matrix/apps/mobile/src/matrix/index.ts
new file mode 100644
index 000000000..b831d9e4b
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/matrix/index.ts
@@ -0,0 +1,4 @@
+export * from './types';
+export * from './client';
+export * from './media';
+export { useMatrixStore } from './store';
diff --git a/apps/matrix/apps/mobile/src/matrix/media.ts b/apps/matrix/apps/mobile/src/matrix/media.ts
new file mode 100644
index 000000000..a9a188ce4
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/matrix/media.ts
@@ -0,0 +1,41 @@
+/**
+ * Resolve Matrix mxc:// URLs to HTTPS URLs for display.
+ * mxc://server/mediaId → https://server/_matrix/media/v3/download/server/mediaId
+ */
+export function resolveMxcUrl(mxcUrl: string, homeserverUrl: string): string | null {
+ if (!mxcUrl?.startsWith('mxc://')) return null;
+
+ const withoutProtocol = mxcUrl.slice('mxc://'.length);
+ const slashIndex = withoutProtocol.indexOf('/');
+ if (slashIndex === -1) return null;
+
+ const server = withoutProtocol.slice(0, slashIndex);
+ const mediaId = withoutProtocol.slice(slashIndex + 1);
+
+ // Use the homeserver as proxy (handles auth and federation)
+ const base = homeserverUrl.replace(/\/$/, '');
+ return `${base}/_matrix/media/v3/download/${server}/${mediaId}`;
+}
+
+/**
+ * Resolve mxc:// to a thumbnail URL via the homeserver.
+ */
+export function resolveMxcThumbnail(
+ mxcUrl: string,
+ homeserverUrl: string,
+ width = 96,
+ height = 96,
+ method: 'crop' | 'scale' = 'crop',
+): string | null {
+ if (!mxcUrl?.startsWith('mxc://')) return null;
+
+ const withoutProtocol = mxcUrl.slice('mxc://'.length);
+ const slashIndex = withoutProtocol.indexOf('/');
+ if (slashIndex === -1) return null;
+
+ const server = withoutProtocol.slice(0, slashIndex);
+ const mediaId = withoutProtocol.slice(slashIndex + 1);
+
+ const base = homeserverUrl.replace(/\/$/, '');
+ return `${base}/_matrix/media/v3/thumbnail/${server}/${mediaId}?width=${width}&height=${height}&method=${method}`;
+}
diff --git a/apps/matrix/apps/mobile/src/matrix/polyfills.ts b/apps/matrix/apps/mobile/src/matrix/polyfills.ts
new file mode 100644
index 000000000..6033a5506
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/matrix/polyfills.ts
@@ -0,0 +1,18 @@
+// Polyfills required for matrix-js-sdk in React Native
+import { Buffer } from 'buffer';
+import EventEmitter from 'events';
+
+// @ts-expect-error global polyfill
+global.Buffer = Buffer;
+// @ts-expect-error global polyfill
+global.EventEmitter = EventEmitter;
+
+// process stub (Expo provides process.env but not all fields)
+if (typeof global.process === 'undefined') {
+ // @ts-expect-error global polyfill
+ global.process = { env: {}, nextTick: setImmediate };
+} else if (typeof global.process.nextTick === 'undefined') {
+ global.process.nextTick = setImmediate as unknown as typeof process.nextTick;
+}
+
+export {};
diff --git a/apps/matrix/apps/mobile/src/matrix/store.ts b/apps/matrix/apps/mobile/src/matrix/store.ts
new file mode 100644
index 000000000..5f98ea8a6
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/matrix/store.ts
@@ -0,0 +1,489 @@
+import { create } from 'zustand';
+import * as SecureStore from 'expo-secure-store';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import type { MatrixClient, Room, MatrixEvent } from 'matrix-js-sdk';
+import type {
+ MatrixCredentials,
+ SimpleRoom,
+ SimpleMessage,
+ SyncState,
+ MessageType,
+ RoomMember,
+} from './types';
+import { resolveMxcThumbnail, resolveMxcUrl } from './media';
+import { uploadMedia } from './upload';
+import { showMessageNotification, setBadgeCount } from '../notifications';
+
+const CREDENTIALS_KEY = 'manalink_credentials';
+const LAST_ROOM_KEY = 'manalink_last_room';
+const ROOMS_CACHE_KEY = 'manalink_rooms_cache';
+
+interface MatrixState {
+ client: MatrixClient | null;
+ credentials: MatrixCredentials | null;
+ syncState: SyncState;
+ rooms: SimpleRoom[];
+ invites: SimpleRoom[];
+ currentRoomId: string | null;
+ messages: SimpleMessage[];
+ firstUnreadEventId: string | null;
+ typingUsers: string[];
+ roomMembers: RoomMember[];
+ error: string | null;
+ isReady: boolean;
+
+ initialize: (credentials: MatrixCredentials) => Promise;
+ restoreSession: () => Promise;
+ selectRoom: (roomId: string) => void;
+ loadRoomMembers: (roomId: string) => void;
+ sendMessage: (body: string, replyToEventId?: string) => Promise;
+ sendReaction: (eventId: string, key: string) => Promise;
+ redactMessage: (eventId: string) => Promise;
+ sendTyping: (typing: boolean) => Promise;
+ sendImage: (fileUri: string, filename: string, mimetype: string, width?: number, height?: number) => Promise;
+ sendFile: (fileUri: string, filename: string, mimetype: string) => Promise;
+ editMessage: (eventId: string, newBody: string) => Promise;
+ sendVoice: (fileUri: string, durationMs: number) => Promise;
+ acceptInvite: (roomId: string) => Promise;
+ declineInvite: (roomId: string) => Promise;
+ leaveRoom: (roomId: string) => Promise;
+ logout: () => Promise;
+}
+
+function roomToSimple(room: Room, userId: string, baseUrl: string): SimpleRoom {
+ const timeline = room.getLiveTimeline().getEvents();
+ const lastMsg = timeline.findLast((e) => e.getType() === 'm.room.message');
+
+ const dmUserId = (() => {
+ const members = room.getJoinedMembers();
+ if (members.length === 2) return members.find((m) => m.userId !== userId)?.userId;
+ return undefined;
+ })();
+
+ const rawAvatar = room.getMxcAvatarUrl?.() ?? null;
+ const avatar = rawAvatar ? resolveMxcThumbnail(rawAvatar, baseUrl, 96, 96) ?? undefined : undefined;
+
+ return {
+ id: room.roomId,
+ name: room.name || room.roomId,
+ topic: room.currentState.getStateEvents('m.room.topic', '')?.getContent()?.topic,
+ avatar,
+ lastMessage: lastMsg?.getContent()?.body,
+ lastMessageSender: lastMsg?.getSender() ?? undefined,
+ lastMessageTime: room.getLastActiveTimestamp?.() ?? undefined,
+ unreadCount: room.getUnreadNotificationCount('total') ?? 0,
+ highlightCount: room.getUnreadNotificationCount('highlight') ?? 0,
+ isDirect: !!dmUserId,
+ isEncrypted: room.hasEncryptionStateEvent(),
+ memberCount: room.getJoinedMemberCount(),
+ membership: (room.getMyMembership() as SimpleRoom['membership']) ?? 'leave',
+ inviter: room.getDMInviter?.() ?? undefined,
+ dmUserId,
+ };
+}
+
+function eventToMessage(event: MatrixEvent, userId: string, baseUrl: string, room?: Room): SimpleMessage | null {
+ if (event.getType() !== 'm.room.message') return null;
+
+ const content = event.getContent();
+ const msgtype = content.msgtype as MessageType;
+
+ // Resolve media if present
+ let media = undefined;
+ if (['m.image', 'm.file', 'm.audio', 'm.video'].includes(msgtype) && content.url) {
+ const mxcUrl = content.url as string;
+ const isAudio = msgtype === 'm.audio';
+ media = {
+ mxcUrl,
+ mimetype: content.info?.mimetype,
+ size: content.info?.size,
+ width: content.info?.w,
+ height: content.info?.h,
+ filename: content.body,
+ thumbnailUrl: isAudio ? undefined : (resolveMxcThumbnail(mxcUrl, baseUrl, 400, 300) ?? undefined),
+ downloadUrl: resolveMxcUrl(mxcUrl, baseUrl) ?? undefined,
+ duration: content.info?.duration,
+ };
+ }
+
+ // Resolve sender avatar
+ const senderMember = event.sender;
+ const rawSenderAvatar = senderMember?.getMxcAvatarUrl?.() ?? null;
+ const senderAvatar = rawSenderAvatar
+ ? resolveMxcThumbnail(rawSenderAvatar, baseUrl, 64, 64) ?? undefined
+ : undefined;
+
+ // Reply-to
+ const replyRelation = content['m.relates_to']?.['m.in_reply_to'];
+ const replyToId: string | undefined = replyRelation?.event_id;
+ let replyToBody: string | undefined;
+ let replyToSenderName: string | undefined;
+ if (replyToId && room) {
+ const replyEvent = room.findEventById(replyToId);
+ if (replyEvent) {
+ replyToBody = replyEvent.getContent()?.body;
+ replyToSenderName = replyEvent.sender?.name ?? replyEvent.getSender() ?? undefined;
+ }
+ }
+
+ return {
+ id: event.getId() ?? `${event.getTs()}_${event.getSender()}`,
+ sender: event.getSender() ?? '',
+ senderName: senderMember?.name ?? event.getSender() ?? '',
+ senderAvatar,
+ body: content.body ?? '',
+ formattedBody: content.formatted_body,
+ timestamp: event.getTs(),
+ type: msgtype,
+ isOwn: event.getSender() === userId,
+ replyTo: replyToId,
+ replyToBody,
+ replyToSenderName,
+ edited: !!event.replacingEvent(),
+ redacted: event.isRedacted(),
+ media,
+ };
+}
+
+function buildSimpleRooms(client: MatrixClient, userId: string, baseUrl: string): SimpleRoom[] {
+ return client
+ .getRooms()
+ .filter((r) => r.getMyMembership() === 'join')
+ .map((r) => roomToSimple(r, userId, baseUrl))
+ .sort((a, b) => (b.lastMessageTime ?? 0) - (a.lastMessageTime ?? 0));
+}
+
+function buildInvites(client: MatrixClient, userId: string, baseUrl: string): SimpleRoom[] {
+ return client
+ .getRooms()
+ .filter((r) => r.getMyMembership() === 'invite')
+ .map((r) => roomToSimple(r, userId, baseUrl));
+}
+
+function buildMessages(room: Room, userId: string, baseUrl: string): SimpleMessage[] {
+ return room
+ .getLiveTimeline()
+ .getEvents()
+ .map((e) => eventToMessage(e, userId, baseUrl, room))
+ .filter((m): m is SimpleMessage => m !== null);
+}
+
+export const useMatrixStore = create((set, get) => ({
+ client: null,
+ credentials: null,
+ syncState: 'STOPPED',
+ rooms: [],
+ invites: [],
+ currentRoomId: null,
+ messages: [],
+ firstUnreadEventId: null,
+ typingUsers: [],
+ roomMembers: [],
+ error: null,
+ isReady: false,
+
+ initialize: async (credentials: MatrixCredentials) => {
+ const existing = get().client;
+ if (existing) existing.stopClient();
+
+ await import('./polyfills');
+ const { createClient } = await import('matrix-js-sdk');
+
+ const client = createClient({
+ baseUrl: credentials.homeserver,
+ accessToken: credentials.accessToken,
+ userId: credentials.userId,
+ deviceId: credentials.deviceId,
+ });
+
+ await SecureStore.setItemAsync(CREDENTIALS_KEY, JSON.stringify(credentials));
+ set({ client, credentials });
+
+ const { userId, homeserver: baseUrl } = credentials;
+
+ // Load cached rooms immediately for fast startup
+ try {
+ const cached = await AsyncStorage.getItem(ROOMS_CACHE_KEY);
+ if (cached) set({ rooms: JSON.parse(cached) });
+ } catch { /* ignore cache errors */ }
+
+ const refresh = () => {
+ const rooms = buildSimpleRooms(client, userId, baseUrl);
+ const invites = buildInvites(client, userId, baseUrl);
+ set({ rooms, invites });
+ // Update badge count
+ const totalUnread = rooms.reduce((n, r) => n + r.highlightCount, 0);
+ setBadgeCount(totalUnread).catch(() => {});
+ // Persist rooms cache
+ AsyncStorage.setItem(ROOMS_CACHE_KEY, JSON.stringify(rooms)).catch(() => {});
+ };
+
+ const refreshMessages = (room: Room) => {
+ const { currentRoomId } = get();
+ if (room.roomId !== currentRoomId) return;
+ set({ messages: buildMessages(room, userId, baseUrl) });
+ };
+
+ client.on('sync' as any, (state: SyncState) => {
+ set({ syncState: state });
+ if (state === 'PREPARED' || state === 'SYNCING') {
+ refresh();
+ set({ isReady: true, error: null });
+ }
+ if (state === 'ERROR') set({ error: 'Sync error — reconnecting...' });
+ });
+
+ client.on('Room.timeline' as any, (event: MatrixEvent, room: Room) => {
+ refresh();
+ refreshMessages(room);
+
+ // Foreground notification for incoming messages
+ const { currentRoomId } = get();
+ if (
+ event.getType() === 'm.room.message' &&
+ event.getSender() !== userId &&
+ room.roomId !== currentRoomId
+ ) {
+ const senderName = event.sender?.name ?? event.getSender() ?? 'Someone';
+ const body = event.getContent()?.body ?? 'New message';
+ showMessageNotification(senderName, room.name, body, room.roomId).catch(() => {});
+ }
+ });
+
+ client.on('Room.redaction' as any, (_: unknown, room: Room) => {
+ refresh();
+ refreshMessages(room);
+ });
+
+ client.on('Room.name' as any, () => refresh());
+ client.on('RoomState.events' as any, () => refresh());
+ client.on('Room.myMembership' as any, () => refresh());
+
+ client.on('RoomMember.typing' as any, (_: unknown, member: any) => {
+ const { currentRoomId } = get();
+ if (!currentRoomId || member.roomId !== currentRoomId) return;
+ const room = client.getRoom(currentRoomId);
+ if (!room) return;
+ const typing = room
+ .getMembersWithMembership('join')
+ .filter((m: any) => m.typing && m.userId !== userId)
+ .map((m: any) => m.name || m.userId);
+ set({ typingUsers: typing });
+ });
+
+ await client.startClient({ initialSyncLimit: 50 });
+ },
+
+ restoreSession: async () => {
+ try {
+ const stored = await SecureStore.getItemAsync(CREDENTIALS_KEY);
+ if (!stored) return false;
+ const credentials: MatrixCredentials = JSON.parse(stored);
+ await get().initialize(credentials);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+
+ selectRoom: (roomId: string) => {
+ const { client, credentials } = get();
+ set({ currentRoomId: roomId, typingUsers: [], messages: [], roomMembers: [], firstUnreadEventId: null });
+ if (!client || !credentials) return;
+
+ const room = client.getRoom(roomId);
+ if (!room) return;
+
+ // Capture first unread event before marking as read
+ const { userId, homeserver: baseUrl } = credentials;
+ let firstUnreadEventId: string | null = null;
+ const unreadCount = room.getUnreadNotificationCount('total') ?? 0;
+ if (unreadCount > 0) {
+ const lastReadEventId = (room as any).getEventReadUpTo?.(userId) as string | null;
+ if (lastReadEventId) {
+ const timeline = room.getLiveTimeline().getEvents();
+ const lastReadIdx = timeline.findIndex((e) => e.getId() === lastReadEventId);
+ if (lastReadIdx >= 0) {
+ const firstUnread = timeline.slice(lastReadIdx + 1).find((e) => e.getType() === 'm.room.message');
+ firstUnreadEventId = firstUnread?.getId() ?? null;
+ }
+ }
+ }
+
+ set({ messages: buildMessages(room, userId, baseUrl), firstUnreadEventId });
+
+ SecureStore.setItemAsync(LAST_ROOM_KEY, roomId).catch(() => {});
+
+ const lastEvent = room.getLiveTimeline().getEvents().at(-1);
+ if (lastEvent) client.sendReadReceipt(lastEvent).catch(() => {});
+ },
+
+ loadRoomMembers: (roomId: string) => {
+ const { client, credentials } = get();
+ if (!client || !credentials) return;
+ const room = client.getRoom(roomId);
+ if (!room) return;
+
+ const members: RoomMember[] = room
+ .getMembersWithMembership('join')
+ .map((m: any) => {
+ const rawAvatar = m.getMxcAvatarUrl?.() ?? null;
+ return {
+ userId: m.userId,
+ displayName: m.name || m.userId,
+ avatarUrl: rawAvatar
+ ? resolveMxcThumbnail(rawAvatar, credentials.homeserver, 64, 64) ?? undefined
+ : undefined,
+ membership: 'join' as const,
+ powerLevel: m.powerLevel ?? 0,
+ };
+ })
+ .sort((a: RoomMember, b: RoomMember) => b.powerLevel - a.powerLevel);
+
+ set({ roomMembers: members });
+ },
+
+ sendMessage: async (body: string, replyToEventId?: string) => {
+ const { client, currentRoomId } = get();
+ if (!client || !currentRoomId) return;
+
+ if (replyToEventId) {
+ const room = client.getRoom(currentRoomId);
+ const replyEvent = room?.findEventById(replyToEventId);
+ if (replyEvent) {
+ await (client as any).sendMessage(currentRoomId, {
+ msgtype: 'm.text',
+ body,
+ 'm.relates_to': {
+ 'm.in_reply_to': { event_id: replyToEventId },
+ },
+ });
+ return;
+ }
+ }
+
+ await client.sendTextMessage(currentRoomId, body);
+ },
+
+ sendReaction: async (eventId: string, key: string) => {
+ const { client, currentRoomId } = get();
+ if (!client || !currentRoomId) return;
+ await (client as any).sendEvent(currentRoomId, 'm.reaction', {
+ 'm.relates_to': { rel_type: 'm.annotation', event_id: eventId, key },
+ });
+ },
+
+ redactMessage: async (eventId: string) => {
+ const { client, currentRoomId } = get();
+ if (!client || !currentRoomId) return;
+ await client.redactEvent(currentRoomId, eventId);
+ },
+
+ sendTyping: async (typing: boolean) => {
+ const { client, currentRoomId } = get();
+ if (!client || !currentRoomId) return;
+ client.sendTyping(currentRoomId, typing, 4000).catch(() => {});
+ },
+
+ sendImage: async (fileUri, filename, mimetype, width, height) => {
+ const { client, currentRoomId } = get();
+ if (!client || !currentRoomId) return;
+ const uploaded = await uploadMedia(client, fileUri, filename, mimetype);
+ await (client as any).sendMessage(currentRoomId, {
+ msgtype: 'm.image',
+ body: filename,
+ url: uploaded.mxcUrl,
+ info: {
+ mimetype,
+ size: uploaded.size,
+ ...(width ? { w: width } : {}),
+ ...(height ? { h: height } : {}),
+ },
+ });
+ },
+
+ sendFile: async (fileUri, filename, mimetype) => {
+ const { client, currentRoomId } = get();
+ if (!client || !currentRoomId) return;
+ const uploaded = await uploadMedia(client, fileUri, filename, mimetype);
+ await (client as any).sendMessage(currentRoomId, {
+ msgtype: 'm.file',
+ body: filename,
+ url: uploaded.mxcUrl,
+ info: { mimetype, size: uploaded.size },
+ });
+ },
+
+ editMessage: async (eventId: string, newBody: string) => {
+ const { client, currentRoomId } = get();
+ if (!client || !currentRoomId) return;
+ await (client as any).sendMessage(currentRoomId, {
+ msgtype: 'm.text',
+ body: `* ${newBody}`,
+ 'm.new_content': { msgtype: 'm.text', body: newBody },
+ 'm.relates_to': { rel_type: 'm.replace', event_id: eventId },
+ });
+ },
+
+ sendVoice: async (fileUri: string, durationMs: number) => {
+ const { client, currentRoomId } = get();
+ if (!client || !currentRoomId) return;
+ const filename = `voice_${Date.now()}.m4a`;
+ const uploaded = await uploadMedia(client, fileUri, filename, 'audio/m4a');
+ await (client as any).sendMessage(currentRoomId, {
+ msgtype: 'm.audio',
+ body: filename,
+ url: uploaded.mxcUrl,
+ info: { mimetype: 'audio/m4a', size: uploaded.size, duration: durationMs },
+ });
+ },
+
+ leaveRoom: async (roomId: string) => {
+ const { client } = get();
+ if (!client) return;
+ await client.leave(roomId);
+ // If we left the current room, clear it
+ const { currentRoomId } = get();
+ if (currentRoomId === roomId) {
+ set({ currentRoomId: null, messages: [], roomMembers: [] });
+ }
+ },
+
+ acceptInvite: async (roomId: string) => {
+ const { client } = get();
+ if (!client) return;
+ await client.joinRoom(roomId);
+ },
+
+ declineInvite: async (roomId: string) => {
+ const { client } = get();
+ if (!client) return;
+ await client.leave(roomId);
+ },
+
+ logout: async () => {
+ const { client } = get();
+ try {
+ await client?.logout();
+ } catch {
+ // non-fatal
+ }
+ client?.stopClient();
+ await SecureStore.deleteItemAsync(CREDENTIALS_KEY).catch(() => {});
+ await SecureStore.deleteItemAsync(LAST_ROOM_KEY).catch(() => {});
+ await AsyncStorage.removeItem(ROOMS_CACHE_KEY).catch(() => {});
+ await setBadgeCount(0).catch(() => {});
+ set({
+ client: null,
+ credentials: null,
+ syncState: 'STOPPED',
+ rooms: [],
+ invites: [],
+ currentRoomId: null,
+ messages: [],
+ firstUnreadEventId: null,
+ error: null,
+ isReady: false,
+ });
+ },
+}));
diff --git a/apps/matrix/apps/mobile/src/matrix/types.ts b/apps/matrix/apps/mobile/src/matrix/types.ts
new file mode 100644
index 000000000..3f80f26b4
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/matrix/types.ts
@@ -0,0 +1,91 @@
+export type SyncState = 'STOPPED' | 'PREPARED' | 'SYNCING' | 'ERROR' | 'RECONNECTING' | 'CATCHUP';
+
+export interface MatrixCredentials {
+ homeserver: string;
+ accessToken: string;
+ userId: string;
+ deviceId: string;
+}
+
+export interface LoginResult {
+ success: boolean;
+ credentials?: MatrixCredentials;
+ error?: string;
+}
+
+export type PresenceState = 'online' | 'offline' | 'unavailable';
+export type RoomMembership = 'join' | 'invite' | 'leave' | 'ban' | 'knock';
+export type MessageType = 'm.text' | 'm.image' | 'm.file' | 'm.audio' | 'm.video' | 'm.emote' | 'm.notice';
+
+export interface SimpleRoom {
+ id: string;
+ name: string;
+ topic?: string;
+ avatar?: string;
+ lastMessage?: string;
+ lastMessageSender?: string;
+ lastMessageTime?: number;
+ unreadCount: number;
+ highlightCount: number;
+ isDirect: boolean;
+ isEncrypted: boolean;
+ memberCount: number;
+ membership: RoomMembership;
+ inviter?: string;
+ dmUserId?: string;
+ presence?: PresenceState;
+ lastActiveAgo?: number;
+}
+
+export interface MediaInfo {
+ mxcUrl: string;
+ mimetype?: string;
+ size?: number;
+ width?: number;
+ height?: number;
+ filename?: string;
+ thumbnailUrl?: string;
+ downloadUrl?: string;
+ duration?: number;
+}
+
+export interface MessageReaction {
+ key: string;
+ count: number;
+ users: string[];
+ includesMe: boolean;
+}
+
+export interface ReadReceipt {
+ userId: string;
+ userName: string;
+ timestamp: number;
+}
+
+export interface SimpleMessage {
+ id: string;
+ sender: string;
+ senderName: string;
+ senderAvatar?: string;
+ body: string;
+ formattedBody?: string;
+ timestamp: number;
+ type: MessageType;
+ isOwn: boolean;
+ replyTo?: string;
+ replyToBody?: string;
+ replyToSenderName?: string;
+ edited?: boolean;
+ redacted?: boolean;
+ media?: MediaInfo;
+ reactions?: MessageReaction[];
+ readBy?: ReadReceipt[];
+}
+
+export interface RoomMember {
+ userId: string;
+ displayName: string;
+ avatarUrl?: string;
+ membership: 'join' | 'invite' | 'leave' | 'ban' | 'knock';
+ powerLevel: number;
+}
diff --git a/apps/matrix/apps/mobile/src/matrix/upload.ts b/apps/matrix/apps/mobile/src/matrix/upload.ts
new file mode 100644
index 000000000..40c7eb046
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/matrix/upload.ts
@@ -0,0 +1,63 @@
+import type { MatrixClient } from 'matrix-js-sdk';
+
+export interface UploadResult {
+ mxcUrl: string;
+ mimetype: string;
+ size: number;
+ width?: number;
+ height?: number;
+ filename?: string;
+}
+
+/**
+ * Upload a local file URI to the Matrix homeserver.
+ * Returns the mxc:// URL and metadata.
+ */
+export async function uploadMedia(
+ client: MatrixClient,
+ fileUri: string,
+ filename: string,
+ mimetype: string,
+): Promise {
+ // Fetch the file as a blob
+ const response = await fetch(fileUri);
+ const blob = await response.blob();
+
+ // Use the matrix-js-sdk upload endpoint
+ const uploadResponse = await (client as any).uploadContent(blob, {
+ name: filename,
+ type: mimetype,
+ rawResponse: false,
+ });
+
+ const mxcUrl: string = uploadResponse?.content_uri ?? uploadResponse;
+
+ return {
+ mxcUrl,
+ mimetype,
+ size: blob.size,
+ filename,
+ };
+}
+
+export function getMimetypeFromFilename(filename: string): string {
+ const ext = filename.split('.').pop()?.toLowerCase() ?? '';
+ const map: Record = {
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ png: 'image/png',
+ gif: 'image/gif',
+ webp: 'image/webp',
+ heic: 'image/heic',
+ mp4: 'video/mp4',
+ mov: 'video/quicktime',
+ mp3: 'audio/mpeg',
+ m4a: 'audio/mp4',
+ ogg: 'audio/ogg',
+ pdf: 'application/pdf',
+ doc: 'application/msword',
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ zip: 'application/zip',
+ };
+ return map[ext] ?? 'application/octet-stream';
+}
diff --git a/apps/matrix/apps/mobile/src/notifications/index.ts b/apps/matrix/apps/mobile/src/notifications/index.ts
new file mode 100644
index 000000000..b41647985
--- /dev/null
+++ b/apps/matrix/apps/mobile/src/notifications/index.ts
@@ -0,0 +1,107 @@
+import * as Notifications from 'expo-notifications';
+import { router } from 'expo-router';
+import type { MatrixClient } from 'matrix-js-sdk';
+
+// Show notifications even when app is in foreground
+Notifications.setNotificationHandler({
+ handleNotification: async () => ({
+ shouldShowAlert: true,
+ shouldPlaySound: true,
+ shouldSetBadge: true,
+ shouldShowBanner: true,
+ shouldShowList: true,
+ }),
+});
+
+export async function requestNotificationPermissions(): Promise {
+ const { status: existing } = await Notifications.getPermissionsAsync();
+ if (existing === 'granted') return true;
+ const { status } = await Notifications.requestPermissionsAsync();
+ return status === 'granted';
+}
+
+/**
+ * Get the Expo push token for this device.
+ * projectId from app.json extra.eas.projectId.
+ */
+export async function getExpoPushToken(projectId?: string): Promise {
+ try {
+ const token = await Notifications.getExpoPushTokenAsync(
+ projectId ? { projectId } : undefined,
+ );
+ return token.data;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Register a Matrix HTTP pusher pointing to the Expo push proxy.
+ * This requires a compatible Matrix push gateway (e.g. a custom proxy or sygnal).
+ * For development, this is optional — sync keeps the app connected.
+ */
+export async function registerMatrixPusher(
+ client: MatrixClient,
+ pushToken: string,
+ appId: string,
+ appDisplayName: string,
+ deviceDisplayName: string,
+ pushGatewayUrl: string,
+): Promise {
+ await (client as any).setPusher({
+ pushkey: pushToken,
+ kind: 'http',
+ app_id: appId,
+ app_display_name: appDisplayName,
+ device_display_name: deviceDisplayName,
+ lang: 'en',
+ data: {
+ url: `${pushGatewayUrl}/_matrix/push/v1/notify`,
+ format: 'event_id_only',
+ },
+ });
+}
+
+/**
+ * Display a local notification for an incoming message.
+ * Called from the Matrix sync event handler for messages
+ * while the app is in the foreground.
+ */
+export async function showMessageNotification(
+ senderName: string,
+ roomName: string,
+ body: string,
+ roomId: string,
+): Promise {
+ await Notifications.scheduleNotificationAsync({
+ content: {
+ title: `${senderName} in ${roomName}`,
+ body,
+ data: { roomId },
+ sound: true,
+ },
+ trigger: null, // fire immediately
+ });
+}
+
+/**
+ * Set the app badge count.
+ */
+export async function setBadgeCount(count: number): Promise {
+ await Notifications.setBadgeCountAsync(count);
+}
+
+/**
+ * Listen for notification taps and navigate to the room.
+ * Returns a cleanup function.
+ */
+export function setupNotificationNavigation(): () => void {
+ const subscription = Notifications.addNotificationResponseReceivedListener((response) => {
+ const roomId = response.notification.request.content.data?.roomId as string | undefined;
+ if (roomId) {
+ router.push(`/room/${roomId}`);
+ }
+ });
+
+ return () => subscription.remove();
+}
diff --git a/apps/matrix/apps/mobile/tailwind.config.js b/apps/matrix/apps/mobile/tailwind.config.js
new file mode 100644
index 000000000..cd7c4ef81
--- /dev/null
+++ b/apps/matrix/apps/mobile/tailwind.config.js
@@ -0,0 +1,21 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ['./app/**/*.{js,ts,tsx}', './src/**/*.{js,ts,tsx}'],
+ presets: [require('nativewind/preset')],
+ theme: {
+ extend: {
+ colors: {
+ background: '#0f0f0f',
+ surface: '#1a1a1a',
+ border: '#2a2a2a',
+ primary: '#7c6bff',
+ 'primary-foreground': '#ffffff',
+ muted: '#6b7280',
+ foreground: '#f9fafb',
+ 'muted-foreground': '#9ca3af',
+ destructive: '#ef4444',
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/apps/matrix/apps/mobile/tsconfig.json b/apps/matrix/apps/mobile/tsconfig.json
new file mode 100644
index 000000000..7bef4d58e
--- /dev/null
+++ b/apps/matrix/apps/mobile/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./*"]
+ }
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ ".expo/types/**/*.ts",
+ "expo-env.d.ts",
+ "nativewind-env.d.ts"
+ ]
+}